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 type { Providers } from "../Providers.ts";
9import {
10 collectPages,
11 jsonEqual,
12 lastSegment,
13 organizationParent,
14 replaceOnIdentity,
15 resolveOrganization,
16 resourceNameFromOperation,
17 sameStringList,
18 tryResolveOrganization,
19 waitForOperation,
20 waitUntilExists,
21 waitUntilGone,
22} from "./internal.ts";
23
24export type GcpUserAccessBindingApplication = acm.Application;
25export type GcpUserAccessBindingSessionSettings = acm.SessionSettings;
26export type GcpUserAccessBindingScopedAccessSettings = acm.ScopedAccessSettings;
27
28export type GcpUserAccessBindingProps = {
29 /**
30 * Organization parent (`organizations/{organization}` or the numeric
31 * organization id). If omitted, Alchemy uses `GOOGLE_ORGANIZATION_ID`
32 * or the project's Resource Manager parent. Immutable — changing it
33 * replaces the binding.
34 */
35 organization?: string;
36 /**
37 * Google Group id whose users are subject to this binding. Directory
38 * API group `id`, not the group email. Immutable — changing it
39 * replaces the binding. Unique per organization.
40 */
41 groupKey: string;
42 /**
43 * Access levels a user must satisfy. At most one entry. Example:
44 * `accessPolicies/9522/accessLevels/device_trusted`.
45 */
46 accessLevels?: string[];
47 /**
48 * Dry-run access levels that are evaluated but not enforced. At most
49 * one entry.
50 */
51 dryRunAccessLevels?: string[];
52 /**
53 * Google Cloud session length settings for the group.
54 */
55 sessionSettings?: GcpUserAccessBindingSessionSettings;
56 /**
57 * Applications subject to this binding. Empty applies the binding to
58 * every application. Mutually exclusive with `scopedAccessSettings`.
59 */
60 restrictedClientApplications?: GcpUserAccessBindingApplication[];
61 /**
62 * Per-application access settings. Mutually exclusive with
63 * `restrictedClientApplications`.
64 */
65 scopedAccessSettings?: GcpUserAccessBindingScopedAccessSettings[];
66};
67
68export type GcpUserAccessBinding = Resource<
69 "GCP.AccessContextManager.GcpUserAccessBinding",
70 GcpUserAccessBindingProps,
71 {
72 /** Resource name `organizations/{org}/gcpUserAccessBindings/{id}`. */
73 name: string;
74 /** Binding id (last path segment). Assigned by the API. */
75 gcpUserAccessBindingId: string;
76 /** Organization id. */
77 organization: string;
78 /** Google Group id. */
79 groupKey: string | undefined;
80 /** Enforced access levels. */
81 accessLevels: string[];
82 /** Dry-run access levels. */
83 dryRunAccessLevels: string[];
84 /** Session settings, if set. */
85 sessionSettings: GcpUserAccessBindingSessionSettings | undefined;
86 /** Restricted client applications. */
87 restrictedClientApplications: GcpUserAccessBindingApplication[];
88 /** Scoped access settings. */
89 scopedAccessSettings: GcpUserAccessBindingScopedAccessSettings[];
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A Context-Aware Access binding of Cloud Console / API restrictions to
97 * a Google Group.
98 *
99 * Bindings live on an organization. The API assigns the resource name
100 * and has no labels or description field. Identity is
101 * `(organization, groupKey)` — Alchemy treats existence at that pair as
102 * ownership, and `list` returns every binding on the current
103 * organization so `pnpm nuke:gcp` can clean leaks.
104 *
105 * `organization` and `groupKey` are immutable. Access levels, dry-run
106 * levels, session settings, restricted applications, and scoped settings
107 * update in place.
108 *
109 * ### Creating a Binding
110 * **Example:** Require an access level for a group
111 * ```typescript
112 * const binding = yield* GCP.AccessContextManager.GcpUserAccessBinding(
113 * "Engineers",
114 * {
115 * groupKey: "01d520gv4vjcrht",
116 * accessLevels: ["accessPolicies/9522/accessLevels/device_trusted"],
117 * },
118 * );
119 * ```
120 *
121 * ### Updating a Binding
122 * **Example:** Change the required access level
123 * ```typescript
124 * const binding = yield* GCP.AccessContextManager.GcpUserAccessBinding(
125 * "Engineers",
126 * {
127 * groupKey: "01d520gv4vjcrht",
128 * accessLevels: ["accessPolicies/9522/accessLevels/corp_network"],
129 * },
130 * );
131 * ```
132 *
133 * @resource
134 * @category AccessContextManager
135 */
136export const GcpUserAccessBinding = Resource<GcpUserAccessBinding>(
137 "GCP.AccessContextManager.GcpUserAccessBinding",
138);
139
140export class GcpUserAccessBindingNotResolved extends Data.TaggedError(
141 "GCP.AccessContextManager.GcpUserAccessBindingNotResolved",
142)<{
143 name: string;
144}> {}
145
146const toAttrs = (
147 binding: acm.GcpUserAccessBinding,
148): GcpUserAccessBinding["Attributes"] => {
149 const name = binding.name ?? "";
150 const parts = name.split("/").filter((part) => part.length > 0);
151 const orgsAt = parts.lastIndexOf("organizations");
152 return {
153 name,
154 gcpUserAccessBindingId: lastSegment(name),
155 organization: orgsAt >= 0 && parts[orgsAt + 1] ? parts[orgsAt + 1]! : "",
156 groupKey: binding.groupKey,
157 accessLevels: binding.accessLevels ?? [],
158 dryRunAccessLevels: binding.dryRunAccessLevels ?? [],
159 sessionSettings: binding.sessionSettings,
160 restrictedClientApplications: binding.restrictedClientApplications ?? [],
161 scopedAccessSettings: binding.scopedAccessSettings ?? [],
162 };
163};
164
165const getByName = (name: string) =>
166 acm
167 .getOrganizationsGcpUserAccessBindings({ name })
168 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
169
170const listBindings = (organization: string) =>
171 collectPages(
172 acm.listOrganizationsGcpUserAccessBindings.pages({
173 parent: organizationParent(organization),
174 pageSize: 100,
175 }),
176 (page) => page.gcpUserAccessBindings,
177 ).pipe(
178 Effect.catchTag("NotFound", () =>
179 Effect.succeed([] as acm.GcpUserAccessBinding[]),
180 ),
181 );
182
183const findByGroupKey = (organization: string, groupKey: string) =>
184 Effect.gen(function* () {
185 const bindings = yield* listBindings(organization);
186 return bindings.find((binding) => binding.groupKey === groupKey);
187 });
188
189const observe = (
190 name: string | undefined,
191 organization: string,
192 groupKey: string | undefined,
193) =>
194 Effect.gen(function* () {
195 if (name !== undefined && name.length > 0) {
196 const byName = yield* getByName(name);
197 if (byName !== undefined) return byName;
198 }
199 if (groupKey !== undefined && groupKey.length > 0) {
200 return yield* findByGroupKey(organization, groupKey);
201 }
202 return undefined;
203 });
204
205export const GcpUserAccessBindingProvider = () =>
206 Provider.succeed(GcpUserAccessBinding, {
207 stables: ["name", "gcpUserAccessBindingId", "organization", "groupKey"],
208
209 diff: Effect.fn(function* ({ news, olds, output }) {
210 if (!isResolved(news)) return undefined;
211 const previousOrg = olds?.organization ?? output?.organization;
212 const nextOrg = news.organization ?? previousOrg;
213 const orgChanged =
214 previousOrg !== undefined &&
215 nextOrg !== undefined &&
216 lastSegment(previousOrg) !== lastSegment(nextOrg);
217 const previousGroup = olds?.groupKey ?? output?.groupKey;
218 const groupChanged =
219 previousGroup !== undefined && news.groupKey !== previousGroup;
220 return replaceOnIdentity(orgChanged || groupChanged);
221 }),
222
223 read: Effect.fn(function* ({ olds, output }) {
224 const organization = yield* resolveOrganization(
225 olds?.organization,
226 output?.organization,
227 );
228 const existing = yield* observe(
229 output?.name,
230 organization,
231 olds?.groupKey ?? output?.groupKey,
232 );
233 if (existing === undefined) return undefined;
234 const attrs = toAttrs(existing);
235 if (output?.name !== undefined && existing.name === output.name) {
236 return attrs;
237 }
238 if (
239 (olds?.groupKey ?? output?.groupKey) !== undefined &&
240 existing.groupKey === (olds?.groupKey ?? output?.groupKey)
241 ) {
242 return attrs;
243 }
244 return Unowned(attrs);
245 }),
246
247 list: () =>
248 Effect.gen(function* () {
249 const organization = yield* tryResolveOrganization();
250 if (organization === undefined) return [];
251 const bindings = yield* listBindings(organization);
252 return bindings.map(toAttrs);
253 }),
254
255 reconcile: Effect.fn(function* ({ news, output }) {
256 const organization = yield* resolveOrganization(
257 news.organization,
258 output?.organization,
259 );
260 const desiredLevels = news.accessLevels ?? [];
261 const desiredDryRun = news.dryRunAccessLevels ?? [];
262
263 let current = yield* observe(output?.name, organization, news.groupKey);
264
265 if (current === undefined) {
266 const created = yield* acm
267 .createOrganizationsGcpUserAccessBindings({
268 parent: organization,
269 body: {
270 groupKey: news.groupKey,
271 accessLevels:
272 desiredLevels.length > 0 ? desiredLevels : undefined,
273 dryRunAccessLevels:
274 desiredDryRun.length > 0 ? desiredDryRun : undefined,
275 sessionSettings: news.sessionSettings,
276 scopedAccessSettings: news.scopedAccessSettings,
277 restrictedClientApplications: news.restrictedClientApplications,
278 },
279 })
280 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
281 if (created !== undefined) {
282 // ALREADY_EXISTS (6): a concurrent create won the race.
283 const settled = yield* waitForOperation(created).pipe(
284 Effect.catchTag("GCP.OperationFailed", (error) =>
285 error.code === 6 ? Effect.succeed(created) : Effect.fail(error),
286 ),
287 );
288 const createdName =
289 resourceNameFromOperation(settled) ??
290 resourceNameFromOperation(created) ??
291 output?.name;
292 current =
293 createdName !== undefined
294 ? yield* waitUntilExists(getByName(createdName), createdName)
295 : yield* waitUntilExists(
296 findByGroupKey(organization, news.groupKey),
297 `${organization}/gcpUserAccessBindings/${news.groupKey}`,
298 );
299 } else {
300 current = yield* waitUntilExists(
301 findByGroupKey(organization, news.groupKey),
302 `${organization}/gcpUserAccessBindings/${news.groupKey}`,
303 );
304 }
305 }
306
307 if (current === undefined || current.name === undefined) {
308 return yield* new GcpUserAccessBindingNotResolved({
309 name: output?.name ?? `${organization}/${news.groupKey}`,
310 });
311 }
312
313 const levelsChanged = !sameStringList(
314 current.accessLevels,
315 desiredLevels,
316 );
317 const dryRunChanged = !sameStringList(
318 current.dryRunAccessLevels,
319 desiredDryRun,
320 );
321 const sessionChanged =
322 news.sessionSettings !== undefined &&
323 !jsonEqual(current.sessionSettings, news.sessionSettings);
324 const appsChanged =
325 news.restrictedClientApplications !== undefined &&
326 !jsonEqual(
327 current.restrictedClientApplications,
328 news.restrictedClientApplications,
329 );
330 const scopedChanged =
331 news.scopedAccessSettings !== undefined &&
332 !jsonEqual(current.scopedAccessSettings, news.scopedAccessSettings);
333
334 const updateMask = [
335 levelsChanged ? "access_levels" : undefined,
336 dryRunChanged ? "dry_run_access_levels" : undefined,
337 sessionChanged ? "session_settings" : undefined,
338 appsChanged ? "restricted_client_applications" : undefined,
339 scopedChanged ? "scoped_access_settings" : undefined,
340 ].filter((field): field is string => field !== undefined);
341
342 if (updateMask.length > 0) {
343 const operation = yield* acm.patchOrganizationsGcpUserAccessBindings({
344 name: current.name,
345 updateMask: updateMask.join(","),
346 body: {
347 name: current.name,
348 accessLevels: desiredLevels,
349 dryRunAccessLevels: desiredDryRun,
350 sessionSettings: news.sessionSettings,
351 scopedAccessSettings: news.scopedAccessSettings,
352 restrictedClientApplications: news.restrictedClientApplications,
353 },
354 });
355 yield* waitForOperation(operation);
356 current = yield* waitUntilExists(getByName(current.name), current.name);
357 }
358
359 if (current === undefined) {
360 return yield* new GcpUserAccessBindingNotResolved({
361 name: output?.name ?? `${organization}/${news.groupKey}`,
362 });
363 }
364
365 return toAttrs(current);
366 }),
367
368 delete: Effect.fn(function* ({ output }) {
369 const operation = yield* acm
370 .deleteOrganizationsGcpUserAccessBindings({ name: output.name })
371 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
372 if (operation !== undefined) {
373 // NOT_FOUND (5): already gone.
374 yield* waitForOperation(operation).pipe(
375 Effect.catchTag("GCP.OperationFailed", (error) =>
376 error.code === 5 ? Effect.void : Effect.fail(error),
377 ),
378 );
379 }
380 yield* waitUntilGone(getByName(output.name), output.name);
381 }),
382 });