1import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1";
2import * as bigquery from "@distilled.cloud/gcp/bigquery_v2";
3import * as bigqueryconnection from "@distilled.cloud/gcp/bigqueryconnection_v1";
4import * as bigtableadmin from "@distilled.cloud/gcp/bigtableadmin_v2";
5import * as binaryauthorization from "@distilled.cloud/gcp/binaryauthorization_v1";
6import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
7import * as cloudfunctions from "@distilled.cloud/gcp/cloudfunctions_v2";
8import * as kms from "@distilled.cloud/gcp/cloudkms_v1";
9import * as cloudtasks from "@distilled.cloud/gcp/cloudtasks_v2";
10import * as compute from "@distilled.cloud/gcp/compute_v1";
11import * as containeranalysis from "@distilled.cloud/gcp/containeranalysis_v1";
12import * as dataproc from "@distilled.cloud/gcp/dataproc_v1";
13import * as iam from "@distilled.cloud/gcp/unstable/iam_v1";
14import * as ml from "@distilled.cloud/gcp/ml_v1";
15import * as privateca from "@distilled.cloud/gcp/privateca_v1";
16import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
17import * as pubsub from "@distilled.cloud/gcp/pubsub_v1";
18import * as run from "@distilled.cloud/gcp/run_v2";
19import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1";
20import * as servicedirectory from "@distilled.cloud/gcp/servicedirectory_v1";
21import * as spanner from "@distilled.cloud/gcp/spanner_v1";
22import * as storage from "@distilled.cloud/gcp/storage_v1";
23import * as workstations from "@distilled.cloud/gcp/workstations_v1";
24import * as Data from "effect/Data";
25import * as Effect from "effect/Effect";
26import * as Schedule from "effect/Schedule";
27
28/**
29 * The subset of a Google IAM `Policy` Alchemy reads and writes. Every
30 * service's generated `Policy` type is structurally compatible.
31 */
32export interface IamPolicy {
33 version?: number;
34 etag?: string;
35 bindings?: ReadonlyArray<IamPolicyBinding>;
36 auditConfigs?: ReadonlyArray<unknown>;
37}
38
39export interface IamPolicyBinding {
40 role?: string;
41 members?: ReadonlyArray<string>;
42 condition?: IamCondition;
43}
44
45/**
46 * An IAM Condition (CEL). Alchemy uses it to narrow a project-level grant
47 * to one resource on services with no per-resource IAM policy (Firestore,
48 * Cloud SQL, Memorystore, …): `resource.name == "projects/p/…"`.
49 */
50export interface IamCondition {
51 title?: string;
52 expression?: string;
53 description?: string;
54}
55
56/** Condition identity: two conditions are the same binding iff equal. */
57const sameCondition = (
58 left: IamCondition | undefined,
59 right: IamCondition | undefined,
60) =>
61 (left === undefined && right === undefined) ||
62 (left !== undefined &&
63 right !== undefined &&
64 left.expression === right.expression &&
65 (left.title ?? "") === (right.title ?? ""));
66
67/**
68 * Resource kinds whose own IAM policy a binding can grant on. Kinds map
69 * 1:1 onto a service's `getIamPolicy` / `setIamPolicy` pair, so a grant
70 * reaches exactly one resource (the GCP analog of an IAM statement's
71 * `Resource: [arn]`).
72 */
73export type GcpIamResourceKind =
74 | "project"
75 | "artifactregistry.repository"
76 | "bigquery.dataset"
77 | "bigquery.table"
78 | "bigqueryconnection.connection"
79 | "bigtable.instance"
80 | "bigtable.table"
81 | "binaryauthorization.attestor"
82 | "cloudfunctions.function"
83 | "cloudtasks.queue"
84 | "compute.instance"
85 | "containeranalysis.note"
86 | "dataproc.cluster"
87 | "iam.serviceAccount"
88 | "kms.cryptoKey"
89 | "ml.model"
90 | "privateca.caPool"
91 | "pubsub.schema"
92 | "pubsub.subscription"
93 | "pubsub.topic"
94 | "run.job"
95 | "run.service"
96 | "run.workerPool"
97 | "secretmanager.secret"
98 | "servicedirectory.namespace"
99 | "servicedirectory.service"
100 | "spanner.database"
101 | "spanner.instance"
102 | "storage.bucket"
103 | "workstations.workstation"
104 | "workstations.workstationConfig";
105
106/** Error from any IAM policy operation, narrowed to the fields Alchemy reads. */
107type IamPolicyError = { readonly _tag: string; readonly message?: string };
108
109interface IamPolicyTarget {
110 get: Effect.Effect<IamPolicy, IamPolicyError, GcpOpContext>;
111 set: (
112 policy: IamPolicy,
113 ) => Effect.Effect<unknown, IamPolicyError, GcpOpContext>;
114}
115
116// The generated per-service `Policy` shapes are structurally identical to
117// `IamPolicy`, but each is a distinct nominal type; these adapters bridge
118// the two once instead of casting at every call site.
119type QueryGet = (input: {
120 resource: string;
121 "options.requestedPolicyVersion"?: number;
122}) => Effect.Effect<any, IamPolicyError, GcpOpContext>;
123type BodyGet = (input: {
124 resource: string;
125 body?: { options?: { requestedPolicyVersion?: number } };
126}) => Effect.Effect<any, IamPolicyError, GcpOpContext>;
127type Set = (input: {
128 resource: string;
129 body?: { policy?: any };
130}) => Effect.Effect<any, IamPolicyError, GcpOpContext>;
131
132const queryStyle =
133 (get: QueryGet, set: Set) =>
134 (resource: string): IamPolicyTarget => ({
135 get: get({ resource, "options.requestedPolicyVersion": 3 }),
136 set: (policy) => set({ resource, body: { policy } }),
137 });
138
139const bodyStyle =
140 (get: BodyGet, set: Set, policyVersion = 3) =>
141 (resource: string): IamPolicyTarget => ({
142 get: get({
143 resource,
144 body: { options: { requestedPolicyVersion: policyVersion } },
145 }),
146 set: (policy) => set({ resource, body: { policy } }),
147 });
148
149const bucketName = (name: string) =>
150 name.replace(/^projects\/_\/buckets\//, "").replace(/^buckets\//, "");
151
152const computeInstance = (name: string): IamPolicyTarget => {
153 const match = /projects\/([^/]+)\/zones\/([^/]+)\/instances\/([^/]+)/.exec(
154 name,
155 );
156 const [, project = "", zone = "", resource = ""] = match ?? [];
157 return {
158 get: compute.getIamPolicyInstances({
159 project,
160 zone,
161 resource,
162 optionsRequestedPolicyVersion: 3,
163 }),
164 set: (policy) =>
165 compute.setIamPolicyInstances({
166 project,
167 zone,
168 resource,
169 body: { policy: policy as compute.Policy },
170 }),
171 };
172};
173
174type DatasetAccessItem = bigquery.DatasetAccessItem;
175
176/** BigQuery stores the three basic dataset roles under legacy names. */
177const LEGACY_DATASET_ROLES: Record<string, string> = {
178 READER: "roles/bigquery.dataViewer",
179 WRITER: "roles/bigquery.dataEditor",
180 OWNER: "roles/bigquery.dataOwner",
181};
182const datasetRole = (role: string) => LEGACY_DATASET_ROLES[role] ?? role;
183
184const principalOf = (item: DatasetAccessItem): string | undefined =>
185 item.iamMember ??
186 (item.userByEmail
187 ? `${item.userByEmail.endsWith(".gserviceaccount.com") ? "serviceAccount" : "user"}:${item.userByEmail}`
188 : item.groupByEmail
189 ? `group:${item.groupByEmail}`
190 : item.domain
191 ? `domain:${item.domain}`
192 : undefined);
193
194const accessItemOf = (role: string, member: string): DatasetAccessItem => {
195 const [type, id = ""] = member.split(/:(.*)/s);
196 switch (type) {
197 case "serviceAccount":
198 case "user":
199 return { role, userByEmail: id };
200 case "group":
201 return { role, groupByEmail: id };
202 case "domain":
203 return { role, domain: id };
204 default:
205 return { role, iamMember: member };
206 }
207};
208
209/**
210 * BigQuery datasets have no `setIamPolicy`; their access list is the
211 * policy. Principal entries map to bindings; view, routine, dataset,
212 * special-group, and conditional entries are carried through untouched.
213 */
214const bigqueryDataset = (name: string): IamPolicyTarget => {
215 const match = /projects\/([^/]+)\/datasets\/([^/]+)/.exec(name);
216 const [, projectId = "", datasetId = ""] = match ?? [];
217 const isPrincipal = (item: DatasetAccessItem) =>
218 item.condition === undefined &&
219 item.role !== undefined &&
220 principalOf(item) !== undefined;
221 const read = bigquery.getDatasets({ projectId, datasetId });
222 return {
223 get: read.pipe(
224 Effect.map((dataset) => {
225 const byRole = new Map<string, string[]>();
226 for (const item of dataset.access ?? []) {
227 if (!isPrincipal(item)) continue;
228 const role = datasetRole(item.role!);
229 const members = byRole.get(role) ?? [];
230 members.push(principalOf(item)!);
231 byRole.set(role, members);
232 }
233 return {
234 etag: dataset.etag,
235 bindings: [...byRole].map(([role, members]) => ({ role, members })),
236 } satisfies IamPolicy;
237 }),
238 ),
239 set: (policy) =>
240 read.pipe(
241 Effect.flatMap((dataset) =>
242 bigquery.patchDatasets({
243 projectId,
244 datasetId,
245 body: {
246 etag: dataset.etag,
247 access: [
248 ...(dataset.access ?? []).filter((item) => !isPrincipal(item)),
249 ...(policy.bindings ?? []).flatMap((binding) =>
250 (binding.members ?? []).map((member) =>
251 accessItemOf(binding.role ?? "", member),
252 ),
253 ),
254 ],
255 },
256 }),
257 ),
258 ),
259 };
260};
261
262const secret = (name: string): IamPolicyTarget =>
263 /\/locations\//.test(name)
264 ? queryStyle(
265 secretmanager.getIamPolicyProjectsLocationsSecrets,
266 secretmanager.setIamPolicyProjectsLocationsSecrets,
267 )(name)
268 : queryStyle(
269 secretmanager.getIamPolicyProjectsSecrets,
270 secretmanager.setIamPolicyProjectsSecrets,
271 )(name);
272
273const TARGETS: Record<GcpIamResourceKind, (name: string) => IamPolicyTarget> = {
274 project: bodyStyle(
275 resourcemanager.getIamPolicyProjects,
276 resourcemanager.setIamPolicyProjects,
277 ),
278 "artifactregistry.repository": queryStyle(
279 artifactregistry.getIamPolicyProjectsLocationsRepositories,
280 artifactregistry.setIamPolicyProjectsLocationsRepositories,
281 ),
282 "bigquery.dataset": bigqueryDataset,
283 // BigQuery table policies reject requestedPolicyVersion 3.
284 "bigquery.table": bodyStyle(
285 bigquery.getIamPolicyTables,
286 bigquery.setIamPolicyTables,
287 1,
288 ),
289 "bigqueryconnection.connection": bodyStyle(
290 bigqueryconnection.getIamPolicyProjectsLocationsConnections,
291 bigqueryconnection.setIamPolicyProjectsLocationsConnections,
292 ),
293 "bigtable.instance": bodyStyle(
294 bigtableadmin.getIamPolicyProjectsInstances,
295 bigtableadmin.setIamPolicyProjectsInstances,
296 ),
297 "bigtable.table": bodyStyle(
298 bigtableadmin.getIamPolicyProjectsInstancesTables,
299 bigtableadmin.setIamPolicyProjectsInstancesTables,
300 ),
301 "binaryauthorization.attestor": queryStyle(
302 binaryauthorization.getIamPolicyProjectsAttestors,
303 binaryauthorization.setIamPolicyProjectsAttestors,
304 ),
305 "cloudfunctions.function": queryStyle(
306 cloudfunctions.getIamPolicyProjectsLocationsFunctions,
307 cloudfunctions.setIamPolicyProjectsLocationsFunctions,
308 ),
309 "cloudtasks.queue": bodyStyle(
310 cloudtasks.getIamPolicyProjectsLocationsQueues,
311 cloudtasks.setIamPolicyProjectsLocationsQueues,
312 ),
313 "compute.instance": computeInstance,
314 "containeranalysis.note": bodyStyle(
315 containeranalysis.getIamPolicyProjectsNotes,
316 containeranalysis.setIamPolicyProjectsNotes,
317 ),
318 "dataproc.cluster": bodyStyle(
319 dataproc.getIamPolicyProjectsRegionsClusters,
320 dataproc.setIamPolicyProjectsRegionsClusters,
321 ),
322 "iam.serviceAccount": queryStyle(
323 iam.getIamPolicyProjectsServiceAccounts,
324 iam.setIamPolicyProjectsServiceAccounts,
325 ),
326 "kms.cryptoKey": queryStyle(
327 kms.getIamPolicyProjectsLocationsKeyRingsCryptoKeys,
328 kms.setIamPolicyProjectsLocationsKeyRingsCryptoKeys,
329 ),
330 "ml.model": queryStyle(
331 ml.getIamPolicyProjectsModels,
332 ml.setIamPolicyProjectsModels,
333 ),
334 "privateca.caPool": queryStyle(
335 privateca.getIamPolicyProjectsLocationsCaPools,
336 privateca.setIamPolicyProjectsLocationsCaPools,
337 ),
338 "pubsub.schema": queryStyle(
339 pubsub.getIamPolicyProjectsSchemas,
340 pubsub.setIamPolicyProjectsSchemas,
341 ),
342 "pubsub.subscription": queryStyle(
343 pubsub.getIamPolicyProjectsSubscriptions,
344 pubsub.setIamPolicyProjectsSubscriptions,
345 ),
346 "pubsub.topic": queryStyle(
347 pubsub.getIamPolicyProjectsTopics,
348 pubsub.setIamPolicyProjectsTopics,
349 ),
350 "run.job": queryStyle(
351 run.getIamPolicyProjectsLocationsJobs,
352 run.setIamPolicyProjectsLocationsJobs,
353 ),
354 "run.service": queryStyle(
355 run.getIamPolicyProjectsLocationsServices,
356 run.setIamPolicyProjectsLocationsServices,
357 ),
358 "run.workerPool": queryStyle(
359 run.getIamPolicyProjectsLocationsWorkerPools,
360 run.setIamPolicyProjectsLocationsWorkerPools,
361 ),
362 "secretmanager.secret": secret,
363 "servicedirectory.namespace": bodyStyle(
364 servicedirectory.getIamPolicyProjectsLocationsNamespaces,
365 servicedirectory.setIamPolicyProjectsLocationsNamespaces,
366 ),
367 "servicedirectory.service": bodyStyle(
368 servicedirectory.getIamPolicyProjectsLocationsNamespacesServices,
369 servicedirectory.setIamPolicyProjectsLocationsNamespacesServices,
370 ),
371 "spanner.database": bodyStyle(
372 spanner.getIamPolicyProjectsInstancesDatabases,
373 spanner.setIamPolicyProjectsInstancesDatabases,
374 ),
375 "spanner.instance": bodyStyle(
376 spanner.getIamPolicyProjectsInstances,
377 spanner.setIamPolicyProjectsInstances,
378 ),
379 "storage.bucket": (name) => ({
380 get: storage.getIamPolicyBuckets({
381 bucket: bucketName(name),
382 optionsRequestedPolicyVersion: 3,
383 }),
384 set: (policy) =>
385 storage.setIamPolicyBuckets({
386 bucket: bucketName(name),
387 body: policy as storage.Policy,
388 }),
389 }),
390 "workstations.workstation": queryStyle(
391 workstations.getIamPolicyProjectsLocationsWorkstationClustersWorkstationConfigsWorkstations,
392 workstations.setIamPolicyProjectsLocationsWorkstationClustersWorkstationConfigsWorkstations,
393 ),
394 "workstations.workstationConfig": queryStyle(
395 workstations.getIamPolicyProjectsLocationsWorkstationClustersWorkstationConfigs,
396 workstations.setIamPolicyProjectsLocationsWorkstationClustersWorkstationConfigs,
397 ),
398};
399
400/** A single `role` → `member` edge on one resource's IAM policy. */
401export interface IamMembership {
402 kind: GcpIamResourceKind;
403 /** Full resource name (`projects/p/topics/t`) or project id for `project`. */
404 name: string;
405 role: string;
406 member: string;
407}
408
409const principal = (member: string) =>
410 /^(serviceAccount|user|group|domain|principal|principalSet):/.test(member) ||
411 member === "allUsers" ||
412 member === "allAuthenticatedUsers"
413 ? member
414 : `serviceAccount:${member}`;
415
416const targetName = (kind: GcpIamResourceKind, name: string) =>
417 kind === "project" && !name.startsWith("projects/")
418 ? `projects/${name}`
419 : name;
420
421/**
422 * Rewrite `policy` so `member` holds exactly `add` roles out of `managed`
423 * (roles in `managed` but not in `add` are revoked from the member).
424 * Conditional bindings are never touched — only unconditional
425 * role → members edges are Alchemy's to manage. Returns `undefined` when
426 * the policy already matches.
427 */
428const rewrite = (
429 policy: IamPolicy,
430 member: string,
431 add: ReadonlySet<string>,
432 remove: ReadonlySet<string>,
433 condition?: IamCondition,
434): IamPolicy | undefined => {
435 const bindings = (policy.bindings ?? []).map((binding) => ({
436 ...binding,
437 members: [...(binding.members ?? [])],
438 }));
439 let dirty = false;
440 for (const role of add) {
441 const existing = bindings.find(
442 (binding) =>
443 binding.role === role && sameCondition(binding.condition, condition),
444 );
445 if (existing === undefined) {
446 bindings.push(
447 condition === undefined
448 ? { role, members: [member] }
449 : { role, members: [member], condition },
450 );
451 dirty = true;
452 } else if (!existing.members.includes(member)) {
453 existing.members.push(member);
454 dirty = true;
455 }
456 }
457 for (const binding of bindings) {
458 if (!sameCondition(binding.condition, condition)) continue;
459 if (binding.role === undefined || !remove.has(binding.role)) continue;
460 if (add.has(binding.role)) continue;
461 const next = binding.members.filter((item) => item !== member);
462 if (next.length !== binding.members.length) {
463 binding.members = next;
464 dirty = true;
465 }
466 }
467 if (!dirty) return undefined;
468 // Keep the version the policy was read at: conditional bindings only
469 // come back (and must be written) at v3, and some resources (BigQuery
470 // tables) accept nothing above v1.
471 return {
472 ...policy,
473 // Writing a conditional binding requires policy version 3.
474 ...(condition !== undefined ? { version: 3 } : {}),
475 bindings: bindings.filter((binding) => binding.members.length > 0),
476 };
477};
478
479/**
480 * Read-modify-write one resource's IAM policy under its etag, re-reading
481 * on a concurrent-write `Conflict` (Cloud Storage: `IamPolicyEtagMismatch`)
482 * so parallel host deploys converge
483 * instead of failing. A freshly created service account can take a few
484 * seconds to become a valid policy member, surfaced as `BadRequest`.
485 */
486export const updateIamMembership = (options: {
487 kind: GcpIamResourceKind;
488 name: string;
489 member: string;
490 add?: Iterable<string>;
491 remove?: Iterable<string>;
492 /** Manage the bindings carrying exactly this condition. */
493 condition?: IamCondition;
494}) => {
495 const target = TARGETS[options.kind](targetName(options.kind, options.name));
496 const member = principal(options.member);
497 const add = new Set(options.add ?? []);
498 const remove = new Set(options.remove ?? []);
499 const write = Effect.gen(function* () {
500 const policy = yield* target.get;
501 const next = rewrite(policy, member, add, remove, options.condition);
502 if (next === undefined) return;
503 yield* target.set(next);
504 }).pipe(
505 Effect.retry({
506 while: (error) =>
507 error._tag === "Conflict" ||
508 // Cloud Storage reports a stale etag as 412, not 409.
509 error._tag === "IamPolicyEtagMismatch" ||
510 (error._tag === "BadRequest" &&
511 /does not exist/i.test(error.message ?? "")),
512 // ~60s total: long enough for a new service account to propagate.
513 times: 6,
514 schedule: Schedule.exponential("1 second"),
515 }),
516 );
517 // Don't report success until GCP reads the change back: callers (and
518 // the resources that depend on them) must never race a write that has
519 // not landed. A lost write (read-after-write lag, a concurrent writer
520 // without etag) is re-applied.
521 const confirmed = target.get.pipe(
522 Effect.flatMap((policy) =>
523 rewrite(policy, member, add, remove, options.condition) === undefined
524 ? Effect.void
525 : Effect.fail(new IamPolicyNotConverged({ name: options.name })),
526 ),
527 );
528 return write.pipe(
529 Effect.andThen(confirmed),
530 Effect.retry({
531 while: (error) => error._tag === "GCP.IamPolicyNotConverged",
532 times: 10,
533 schedule: Schedule.spaced("2 seconds"),
534 }),
535 );
536};
537
538/** A written IAM policy did not read back with the change applied. */
539export class IamPolicyNotConverged extends Data.TaggedError(
540 "GCP.IamPolicyNotConverged",
541)<{ name: string }> {}
542
543/**
544 * Remove `member` from every unconditional binding of `roles` on the
545 * resource. A resource that is already gone has nothing to revoke.
546 */
547export const revokeIamMembership = (options: {
548 kind: GcpIamResourceKind;
549 name: string;
550 member: string;
551 roles: Iterable<string>;
552 condition?: IamCondition;
553}) =>
554 updateIamMembership({
555 kind: options.kind,
556 name: options.name,
557 member: options.member,
558 remove: options.roles,
559 condition: options.condition,
560 }).pipe(
561 Effect.catchIf(
562 (error) => error._tag === "NotFound",
563 () => Effect.void,
564 ),
565 );
566
567/** Roles `member` currently holds unconditionally on the project. */
568export const projectRolesOf = (project: string, member: string) =>
569 TARGETS.project(targetName("project", project)).get.pipe(
570 Effect.map((policy) => {
571 const principalId = principal(member);
572 return (policy.bindings ?? [])
573 .filter(
574 (binding) =>
575 binding.condition === undefined &&
576 (binding.members ?? []).includes(principalId),
577 )
578 .flatMap((binding) => (binding.role ? [binding.role] : []));
579 }),
580 );
581
582/** True when `member` holds `role` unconditionally on the resource. */
583export const hasIamMembership = (options: {
584 kind: GcpIamResourceKind;
585 name: string;
586 member: string;
587 role: string;
588}) =>
589 TARGETS[options.kind](targetName(options.kind, options.name)).get.pipe(
590 Effect.map((policy) =>
591 (policy.bindings ?? []).some(
592 (binding) =>
593 binding.role === options.role &&
594 binding.condition === undefined &&
595 (binding.members ?? []).includes(principal(options.member)),
596 ),
597 ),
598 Effect.catchIf(
599 (error) => error._tag === "NotFound",
600 () => Effect.succeed(false),
601 ),
602 );