1import * as acm from "@distilled.cloud/gcp/accesscontextmanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalLabels } from "../Labels.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 collectPages,
12 encodeOwnership,
13 encodeOwnershipLine,
14 jsonEqual,
15 listOwnedPolicies,
16 MAX_DESCRIPTION_LENGTH,
17 MAX_TITLE_LENGTH,
18 ownedByAlchemy,
19 parseName,
20 parseOwnership,
21 policyNameOf,
22 replaceOnIdentity,
23 resourceNameOf,
24 toAcmId,
25 waitForOperation,
26 waitUntilExists,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type AccessLevelOsConstraint = acm.OsConstraint;
31export type AccessLevelDevicePolicy = acm.DevicePolicy;
32export type AccessLevelVpcNetworkSource = acm.VpcNetworkSource;
33export type AccessLevelCondition = acm.Condition;
34export type AccessLevelBasic = acm.BasicLevel;
35export type AccessLevelCustom = acm.CustomLevel;
36
37export type AccessPoliciesAccessLevelProps = {
38 /**
39 * Parent access policy (`accessPolicies/{policy}` or the policy id).
40 * Immutable — changing it replaces the access level.
41 */
42 policy: string;
43 /**
44 * Access level id (the `{access_level}` segment). If omitted, a unique
45 * name is generated from the stack, stage, and logical id. Must begin
46 * with a letter, then alphanumeric or `_`, max 50 characters.
47 * Immutable — changing it replaces the access level.
48 */
49 accessLevelId?: string;
50 /**
51 * Human-readable title. Must be unique within the policy.
52 */
53 title?: string;
54 /**
55 * Description of the access level. Access levels have no labels, so
56 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
57 * from attributes.
58 */
59 description?: string;
60 /**
61 * Basic level composed of conditions. Mutually exclusive with
62 * `custom`.
63 */
64 basic?: AccessLevelBasic;
65 /**
66 * Custom CEL level. Mutually exclusive with `basic`.
67 */
68 custom?: AccessLevelCustom;
69};
70
71export type AccessPoliciesAccessLevel = Resource<
72 "GCP.AccessContextManager.AccessPoliciesAccessLevel",
73 AccessPoliciesAccessLevelProps,
74 {
75 /** Resource name `accessPolicies/{policy}/accessLevels/{accessLevel}`. */
76 name: string;
77 /** Access level id (last path segment). */
78 accessLevelId: string;
79 /** Parent policy name `accessPolicies/{policy}`. */
80 policy: string;
81 /** User title with the Alchemy ownership prefix stripped. */
82 title: string | undefined;
83 /** User description with the Alchemy ownership prefix stripped. */
84 description: string | undefined;
85 /** Basic level, if set. */
86 basic: AccessLevelBasic | undefined;
87 /** Custom CEL level, if set. */
88 custom: AccessLevelCustom | undefined;
89 },
90 never,
91 Providers
92>;
93
94/**
95 * An Access Context Manager access level.
96 *
97 * Access levels label requests to Google Cloud services with a set of
98 * requirements (IP ranges, device policy, members, regions, or a custom
99 * CEL expression). They live under an {@link AccessPolicy}.
100 *
101 * Access levels have no labels. Alchemy stamps ownership into
102 * `description` so `list` / `pnpm nuke:gcp` can find them. `policy` and
103 * `accessLevelId` are immutable — changing them replaces the level.
104 * Title, description, `basic`, and `custom` update in place.
105 *
106 * ### Creating an Access Level
107 * **Example:** Basic level that allows the US
108 * ```typescript
109 * const policy = yield* GCP.AccessContextManager.AccessPolicy("Corp", {
110 * scopes: ["projects/123456789"],
111 * });
112 * const level = yield* GCP.AccessContextManager.AccessPoliciesAccessLevel(
113 * "CorpUsers",
114 * {
115 * policy: policy.name,
116 * title: "corp users",
117 * basic: { conditions: [{ regions: ["US"] }] },
118 * },
119 * );
120 * ```
121 *
122 * **Example:** Custom CEL level
123 * ```typescript
124 * const level = yield* GCP.AccessContextManager.AccessPoliciesAccessLevel(
125 * "Employees",
126 * {
127 * policy: policy.name,
128 * title: "employees",
129 * custom: {
130 * expr: { expression: "request.time.getHours() >= 9" },
131 * },
132 * },
133 * );
134 * ```
135 *
136 * ### Updating an Access Level
137 * **Example:** Add an IP condition
138 * ```typescript
139 * const level = yield* GCP.AccessContextManager.AccessPoliciesAccessLevel(
140 * "CorpUsers",
141 * {
142 * policy: policy.name,
143 * title: "corp users",
144 * basic: {
145 * conditions: [{ ipSubnetworks: ["203.0.113.0/24"] }],
146 * },
147 * },
148 * );
149 * ```
150 *
151 * @resource
152 * @category AccessContextManager
153 */
154export const AccessPoliciesAccessLevel = Resource<AccessPoliciesAccessLevel>(
155 "GCP.AccessContextManager.AccessPoliciesAccessLevel",
156);
157
158export class AccessPoliciesAccessLevelNotResolved extends Data.TaggedError(
159 "GCP.AccessContextManager.AccessPoliciesAccessLevelNotResolved",
160)<{
161 name: string;
162}> {}
163
164const toAttrs = (
165 level: acm.AccessLevel,
166): AccessPoliciesAccessLevel["Attributes"] => {
167 const name = level.name ?? "";
168 const parsed = parseName(name, "accessLevels");
169 const title = parseOwnership(level.title);
170 const description = parseOwnership(level.description);
171 return {
172 name,
173 accessLevelId: parsed.id,
174 policy: parsed.parent,
175 title: title.text,
176 description: description.text,
177 basic: level.basic,
178 custom: level.custom,
179 };
180};
181
182const getByName = (name: string) =>
183 acm
184 .getAccessPoliciesAccessLevels({ name })
185 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
186
187const listLevels = (policy: string) =>
188 collectPages(
189 acm.listAccessPoliciesAccessLevels.pages({
190 parent: policyNameOf(policy),
191 pageSize: 100,
192 }),
193 (page) => page.accessLevels,
194 ).pipe(
195 Effect.catchTag("NotFound", () => Effect.succeed([] as acm.AccessLevel[])),
196 );
197
198export const AccessPoliciesAccessLevelProvider = () =>
199 Provider.succeed(AccessPoliciesAccessLevel, {
200 stables: ["name", "accessLevelId", "policy"],
201
202 diff: Effect.fn(function* ({ news, olds, output }) {
203 if (!isResolved(news)) return undefined;
204 const previousId = olds?.accessLevelId ?? output?.accessLevelId;
205 const idChanged =
206 previousId !== undefined &&
207 news.accessLevelId !== undefined &&
208 news.accessLevelId !== previousId;
209 const previousPolicy = olds?.policy ?? output?.policy;
210 const policyChanged =
211 previousPolicy !== undefined &&
212 policyNameOf(news.policy) !== policyNameOf(previousPolicy);
213 return replaceOnIdentity(idChanged || policyChanged);
214 }),
215
216 read: Effect.fn(function* ({ id, olds, output }) {
217 const accessLevelId = yield* toAcmId(
218 id,
219 olds?.accessLevelId,
220 output?.accessLevelId,
221 );
222 const policy = olds?.policy ?? output?.policy;
223 if (policy === undefined) return undefined;
224 const name =
225 output?.name ?? resourceNameOf(policy, "accessLevels", accessLevelId);
226 const existing = yield* getByName(name);
227 if (existing === undefined) return undefined;
228 const attrs = toAttrs(existing);
229 return (yield* ownedByAlchemy(id, existing.description ?? existing.title))
230 ? attrs
231 : Unowned(attrs);
232 }),
233
234 list: () =>
235 Effect.gen(function* () {
236 const policies = yield* listOwnedPolicies();
237 const levels = yield* Effect.forEach(
238 policies,
239 (policy) =>
240 policy.name
241 ? listLevels(policy.name)
242 : Effect.succeed([] as acm.AccessLevel[]),
243 { concurrency: 4 },
244 );
245 return levels
246 .flat()
247 .filter(
248 (level) =>
249 parseOwnership(level.description ?? level.title).labels[
250 "alchemy-id"
251 ],
252 )
253 .map(toAttrs);
254 }),
255
256 reconcile: Effect.fn(function* ({ id, news, output }) {
257 const policy = policyNameOf(news.policy);
258 const accessLevelId = yield* toAcmId(
259 id,
260 news.accessLevelId,
261 output?.accessLevelId,
262 );
263 const name = resourceNameOf(policy, "accessLevels", accessLevelId);
264 const ownership = yield* createInternalLabels(id);
265 const desiredTitle = encodeOwnershipLine(
266 ownership,
267 news.title ?? accessLevelId,
268 MAX_TITLE_LENGTH,
269 );
270 const desiredDescription = encodeOwnership(
271 ownership,
272 news.description,
273 MAX_DESCRIPTION_LENGTH,
274 );
275
276 let current = yield* getByName(output?.name ?? name);
277
278 if (current === undefined) {
279 const created = yield* acm
280 .createAccessPoliciesAccessLevels({
281 parent: policy,
282 body: {
283 name,
284 title: desiredTitle,
285 description: desiredDescription,
286 basic: news.basic,
287 custom: news.custom,
288 },
289 })
290 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
291 if (created !== undefined) {
292 // ALREADY_EXISTS (6): a concurrent create won the race.
293 yield* waitForOperation(created).pipe(
294 Effect.catchTag("GCP.OperationFailed", (error) =>
295 error.code === 6 ? Effect.void : Effect.fail(error),
296 ),
297 );
298 }
299 current = yield* waitUntilExists(getByName(name), name);
300 }
301
302 if (current === undefined) {
303 return yield* new AccessPoliciesAccessLevelNotResolved({ name });
304 }
305
306 const titleChanged = (current.title ?? "") !== desiredTitle;
307 const descriptionChanged =
308 (current.description ?? "") !== desiredDescription;
309 const basicChanged = !jsonEqual(current.basic, news.basic);
310 const customChanged = !jsonEqual(current.custom, news.custom);
311
312 const updateMask = [
313 titleChanged ? "title" : undefined,
314 descriptionChanged ? "description" : undefined,
315 news.basic !== undefined && basicChanged ? "basic" : undefined,
316 news.custom !== undefined && customChanged ? "custom" : undefined,
317 ].filter((field): field is string => field !== undefined);
318
319 if (updateMask.length > 0) {
320 const operation = yield* acm.patchAccessPoliciesAccessLevels({
321 name: current.name ?? name,
322 updateMask: updateMask.join(","),
323 body: {
324 name: current.name ?? name,
325 title: desiredTitle,
326 description: desiredDescription,
327 basic: news.basic,
328 custom: news.custom,
329 },
330 });
331 yield* waitForOperation(operation);
332 current = yield* waitUntilExists(
333 getByName(current.name ?? name),
334 current.name ?? name,
335 );
336 }
337
338 if (current === undefined) {
339 return yield* new AccessPoliciesAccessLevelNotResolved({ name });
340 }
341
342 return toAttrs(current);
343 }),
344
345 delete: Effect.fn(function* ({ output }) {
346 const operation = yield* acm
347 .deleteAccessPoliciesAccessLevels({ name: output.name })
348 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
349 if (operation !== undefined) {
350 // NOT_FOUND (5): already gone.
351 yield* waitForOperation(operation).pipe(
352 Effect.catchTag("GCP.OperationFailed", (error) =>
353 error.code === 5 ? Effect.void : Effect.fail(error),
354 ),
355 );
356 }
357 yield* waitUntilGone(getByName(output.name), output.name);
358 }),
359 });