1import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
2import * as iam from "@distilled.cloud/gcp/unstable/iam_v1";
3import * as Config from "effect/Config";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";
8import * as Schedule from "effect/Schedule";
9import * as Binding from "../Binding.ts";
10import type { Input } from "../Input.ts";
11import {
12 Resource,
13 type ResourceBinding,
14 type ResourceLike,
15} from "../Resource.ts";
16import {
17 hasIamMembership,
18 projectRolesOf,
19 revokeIamMembership,
20 updateIamMembership,
21 type GcpIamResourceKind,
22 type IamCondition,
23} from "./IamPolicy.ts";
24import { parseServiceAccountKey } from "./Token.ts";
25
26export type { GcpIamResourceKind } from "./IamPolicy.ts";
27
28/**
29 * IAM grant a binding attaches to a GCP runtime host (Cloud Run
30 * Service/Job/WorkerPool, Cloud Function, or a `Kubernetes.Deployment` /
31 * `Kubernetes.Job` on GKE) — the GCP analog of an AWS binding's
32 * `policyStatements`. The role is granted to the host's runtime identity:
33 * the runtime service account, or on GKE the Kubernetes ServiceAccount's
34 * Workload Identity Federation principal.
35 */
36export type GcpIamGrant = {
37 /**
38 * Predefined or custom role, e.g. `roles/pubsub.publisher`. Pick the
39 * narrowest role that covers the binding's operation.
40 */
41 role: string;
42 /**
43 * Grant on this one resource's IAM policy (the analog of an IAM
44 * statement's `Resource: [arn]`). Omit only for services with no
45 * resource-level IAM, in which case the role is granted on the project.
46 */
47 resource?: {
48 kind: Exclude<GcpIamResourceKind, "project">;
49 /**
50 * Full resource name, e.g. `projects/p/topics/t` (a bucket name for
51 * `storage.bucket`), or {@link HOST_SERVICE_ACCOUNT} with kind
52 * `iam.serviceAccount` for the host's own runtime service account.
53 */
54 name: string;
55 };
56 /**
57 * For a project-level grant on a service with no per-resource IAM
58 * policy: an IAM Condition narrowing the role to one resource, e.g.
59 * `resource.name == "projects/p/databases/d"`.
60 */
61 condition?: IamCondition;
62};
63
64/**
65 * Binding contract for GCP effectful hosts. Capability implementations
66 * call `host.bind` with env (packed into the container/function) and IAM
67 * grants (applied to the runtime service account).
68 */
69export type GcpHostBinding = {
70 env?: Record<string, any>;
71 iam?: GcpIamGrant[];
72 /**
73 * Cloud SQL connection names (`project:region:instance`) to mount as
74 * Unix sockets under `/cloudsql` (Cloud Run services only).
75 */
76 cloudSqlInstances?: string[];
77};
78
79/**
80 * One role granted to a host's runtime service account on one resource,
81 * recorded on the host's attributes so a later deploy can revoke it once
82 * the binding that asked for it is gone.
83 */
84export type AppliedIamGrant = {
85 kind: GcpIamResourceKind;
86 /** Full resource name, or the project id for `project`. */
87 name: string;
88 role: string;
89 /** IAM Condition the role is bound under, if any. */
90 condition?: IamCondition;
91};
92
93const GCP_HOST_TYPES = new Set([
94 "GCP.Run.Service",
95 "GCP.Run.Job",
96 "GCP.Run.WorkerPool",
97 "GCP.CloudFunctions.Function",
98 // Kubernetes workloads accept `{ env, iam }` through the augmented
99 // `WorkloadBindingContract`; the GKE cluster adapter grants `iam` to the
100 // workload's Workload Identity principal.
101 "Kubernetes.Deployment",
102 "Kubernetes.Job",
103]);
104
105/**
106 * True for any Alchemy host that accepts {@link GcpHostBinding}: the Cloud
107 * Run and Cloud Functions hosts, and `Kubernetes.Deployment` /
108 * `Kubernetes.Job` (granted through the GKE cluster adapter). HTTP
109 * `Binding.Service` implementations guard `host.bind` with this before
110 * granting IAM / injecting env.
111 */
112export const isGcpHost = (
113 value: ResourceLike | undefined,
114): value is Resource<string, object, object, GcpHostBinding> =>
115 typeof value === "object" &&
116 value !== null &&
117 "Type" in value &&
118 GCP_HOST_TYPES.has((value as { Type: string }).Type);
119
120/** Merge the env and IAM grants of every binding attached to a host. */
121export const collectHostBindings = (
122 bindings: readonly ResourceBinding<GcpHostBinding>[],
123): { env: Record<string, any>; iam: GcpIamGrant[] } => ({
124 env: bindings
125 .map((binding) => binding.data?.env)
126 .reduce<Record<string, any>>((acc, next) => ({ ...acc, ...next }), {}),
127 iam: bindings.flatMap((binding) => binding.data?.iam ?? []),
128});
129
130const lastSegment = (value: string) => {
131 const trimmed = value.replace(/\/+$/, "");
132 const parts = trimmed.split("/");
133 return parts[parts.length - 1] || trimmed;
134};
135
136/**
137 * Project number for the default Compute Engine service account
138 * (`{number}-compute@developer.gserviceaccount.com`).
139 */
140export const projectNumber = (project: string) =>
141 resourcemanager
142 .getProjects({ name: `projects/${project}` })
143 .pipe(Effect.map((resource) => lastSegment(resource.name ?? "")));
144
145export const defaultComputeServiceAccount = (project: string) =>
146 projectNumber(project).pipe(
147 Effect.map((number) => `${number}-compute@developer.gserviceaccount.com`),
148 );
149
150/** 64-bit FNV-1a, hex. Deterministic and dependency-free. */
151const fnv1a64 = (input: string): string => {
152 let hash = 0xcbf29ce484222325n;
153 for (let index = 0; index < input.length; index++) {
154 hash ^= BigInt(input.charCodeAt(index));
155 hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn;
156 }
157 return hash.toString(16).padStart(16, "0");
158};
159
160/**
161 * RFC1035 account id (6–30 chars) for the runtime service account Alchemy
162 * mints for one physical host. The hash covers the host type and full
163 * resource name, so two hosts never share an account — not across
164 * stages, not a Service and a Job with the same id, and not the two
165 * generations of a create-before-delete replacement.
166 */
167export const hostServiceAccountId = (
168 hostType: string,
169 resourceName: string,
170): string => {
171 const slug = lastSegment(resourceName)
172 .toLowerCase()
173 .replace(/[^a-z0-9-]/g, "-")
174 .replace(/-+/g, "-")
175 .replace(/^-+|-+$/g, "")
176 .slice(0, 12)
177 .replace(/-+$/g, "");
178 const hash = fnv1a64(`${hostType}:${resourceName}`).slice(0, 10);
179 return /^[a-z]/.test(slug) ? `alch-${slug}-${hash}` : `alch-${hash}`;
180};
181
182export const hostServiceAccountEmail = (project: string, accountId: string) =>
183 `${accountId}@${project}.iam.gserviceaccount.com`;
184
185/** Display name stamped on Alchemy-minted host SAs so nuke can list them. */
186export const ALCHEMY_HOST_SA_DISPLAY_NAME = "alchemy-host";
187
188const serviceAccountName = (project: string, email: string) =>
189 `projects/${project}/serviceAccounts/${email}`;
190
191/**
192 * Let the deploying principal and the serverless robots `actAs` the host
193 * service account so Cloud Run / Functions accept it as the runtime
194 * identity.
195 */
196const grantActAs = (project: string, email: string) =>
197 Effect.gen(function* () {
198 const number = yield* projectNumber(project);
199 const members = [
200 `serviceAccount:service-${number}@serverless-robot-prod.iam.gserviceaccount.com`,
201 `serviceAccount:service-${number}@gcf-admin-robot.iam.gserviceaccount.com`,
202 ];
203 const keyFile = yield* Config.option(
204 Config.String("GOOGLE_APPLICATION_CREDENTIALS"),
205 );
206 if (Option.isSome(keyFile)) {
207 const fs = yield* FileSystem.FileSystem;
208 const raw = yield* fs
209 .readFileString(keyFile.value)
210 .pipe(
211 Effect.catchReason("PlatformError", "NotFound", () =>
212 Effect.succeed(""),
213 ),
214 );
215 if (raw.length > 0) {
216 const parsed = yield* parseServiceAccountKey(raw).pipe(
217 Effect.catchTag("AuthError", () => Effect.succeed(undefined)),
218 );
219 if (parsed?.client_email) {
220 members.push(`serviceAccount:${parsed.client_email}`);
221 }
222 }
223 }
224 for (const member of members) {
225 yield* updateIamMembership({
226 kind: "iam.serviceAccount",
227 name: serviceAccountName(project, email),
228 member,
229 add: ["roles/iam.serviceAccountUser"],
230 });
231 }
232 });
233
234/**
235 * Create (or adopt) the runtime service account for one host and let
236 * Cloud Run / Functions `actAs` it. `created` reports whether this call
237 * minted it, so a failed first deploy can clean up only what it made.
238 */
239export const ensureHostServiceAccount = (options: {
240 project: string;
241 hostType: string;
242 resourceName: string;
243}) => {
244 const accountId = hostServiceAccountId(
245 options.hostType,
246 options.resourceName,
247 );
248 const email = hostServiceAccountEmail(options.project, accountId);
249 return Effect.gen(function* () {
250 const existing = yield* iam
251 .getProjectsServiceAccounts({
252 name: serviceAccountName(options.project, email),
253 })
254 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
255 let created = false;
256 if (existing?.email === undefined || existing.email.length === 0) {
257 created = yield* iam
258 .createProjectsServiceAccounts({
259 name: `projects/${options.project}`,
260 body: {
261 accountId,
262 serviceAccount: { displayName: ALCHEMY_HOST_SA_DISPLAY_NAME },
263 },
264 })
265 .pipe(
266 Effect.as(true),
267 Effect.retry({
268 while: (error) => error._tag === "ServiceAccountQuotaExceeded",
269 times: 6,
270 schedule: Schedule.exponential("2 seconds"),
271 }),
272 Effect.catchTag("Conflict", () => Effect.succeed(false)),
273 );
274 }
275 yield* grantActAs(options.project, email);
276 return { email, created };
277 });
278};
279
280/** Retry Cloud Run / Functions create while IAM `actAs` is propagating. */
281export const retryActAs = <A, E extends { _tag: string }, R>(
282 effect: Effect.Effect<A, E, R>,
283) =>
284 effect.pipe(
285 Effect.retry({
286 while: (error) => error._tag === "Forbidden",
287 times: 8,
288 schedule: Schedule.spaced("2 seconds"),
289 }),
290 );
291
292const conditionKey = (condition: IamCondition | undefined) =>
293 condition === undefined
294 ? ""
295 : `${condition.title ?? ""}\u0001${condition.expression ?? ""}`;
296
297const grantKey = (grant: AppliedIamGrant) =>
298 `${grant.kind}\u0000${grant.name}\u0000${grant.role}\u0000${conditionKey(grant.condition)}`;
299
300const groupByTarget = (grants: readonly AppliedIamGrant[]) => {
301 const groups = new Map<
302 string,
303 {
304 kind: GcpIamResourceKind;
305 name: string;
306 condition: IamCondition | undefined;
307 roles: Set<string>;
308 }
309 >();
310 for (const grant of grants) {
311 const key = `${grant.kind}\u0000${grant.name}\u0000${conditionKey(grant.condition)}`;
312 const group = groups.get(key) ?? {
313 kind: grant.kind,
314 name: grant.name,
315 condition: grant.condition,
316 roles: new Set<string>(),
317 };
318 group.roles.add(grant.role);
319 groups.set(key, group);
320 }
321 return [...groups.values()];
322};
323
324/**
325 * Placeholder resource name (kind `iam.serviceAccount`) for the host's
326 * own runtime service account, e.g. to let it sign blobs as itself.
327 * Resolved when the host syncs IAM; dropped for hosts whose identity is
328 * not a service account (GKE Workload Identity principals).
329 */
330export const HOST_SERVICE_ACCOUNT = "@host";
331
332/** Desired grants for a host, deduplicated and resolved to concrete targets. */
333export const desiredHostGrants = (
334 project: string,
335 grants: readonly GcpIamGrant[],
336 serviceAccount?: string,
337): AppliedIamGrant[] => {
338 const ownAccount =
339 serviceAccount !== undefined && !serviceAccount.includes(":")
340 ? `projects/-/serviceAccounts/${serviceAccount}`
341 : undefined;
342 const unique = new Map<string, AppliedIamGrant>();
343 for (const grant of grants) {
344 if (grant.role.length === 0) continue;
345 const self = grant.resource?.name === HOST_SERVICE_ACCOUNT;
346 if (self && ownAccount === undefined) continue;
347 const applied: AppliedIamGrant =
348 grant.resource === undefined
349 ? {
350 kind: "project",
351 name: project,
352 role: grant.role,
353 ...(grant.condition ? { condition: grant.condition } : {}),
354 }
355 : {
356 kind: grant.resource.kind,
357 name: self ? ownAccount! : grant.resource.name,
358 role: grant.role,
359 };
360 unique.set(grantKey(applied), applied);
361 }
362 return [...unique.values()].sort((left, right) =>
363 grantKey(left).localeCompare(grantKey(right)),
364 );
365};
366
367/**
368 * Converge the runtime service account's IAM onto the host's bindings.
369 *
370 * Every desired grant is added on its target resource. Grants recorded
371 * on a previous deploy that no binding asks for any more are revoked.
372 * For an Alchemy-minted account, the host project policy is additionally
373 * synced against the observed policy, so roles granted out of band (or
374 * lost from state) are revoked too; a user-supplied account is only ever
375 * stripped of grants Alchemy itself recorded.
376 */
377export const syncHostIam = Effect.fn(function* (options: {
378 project: string;
379 serviceAccount: string;
380 managed: boolean;
381 bindings: readonly ResourceBinding<GcpHostBinding>[];
382 previous: readonly AppliedIamGrant[] | undefined;
383}) {
384 const collected = collectHostBindings(options.bindings);
385 const desired = desiredHostGrants(
386 options.project,
387 collected.iam,
388 options.serviceAccount,
389 );
390 const desiredKeys = new Set(desired.map(grantKey));
391 const stale = (options.previous ?? []).filter(
392 (grant) => !desiredKeys.has(grantKey(grant)),
393 );
394
395 // A principal Alchemy doesn't own (user-supplied service account, GKE
396 // Workload Identity principal) may already hold a role for other
397 // reasons. Such a grant is used but never recorded as ours, so a later
398 // deploy or destroy never revokes it.
399 const previousKeys = new Set((options.previous ?? []).map(grantKey));
400 const preexisting = new Set<string>();
401 if (!options.managed) {
402 for (const grant of desired) {
403 if (previousKeys.has(grantKey(grant)) || grant.condition !== undefined) {
404 continue;
405 }
406 const held = yield* hasIamMembership({
407 kind: grant.kind,
408 name: grant.name,
409 member: options.serviceAccount,
410 role: grant.role,
411 });
412 if (held) preexisting.add(grantKey(grant));
413 }
414 }
415
416 for (const group of groupByTarget(desired)) {
417 yield* updateIamMembership({
418 kind: group.kind,
419 name: group.name,
420 member: options.serviceAccount,
421 add: group.roles,
422 condition: group.condition,
423 });
424 }
425
426 // Unconditional project grants of a minted account are synced against
427 // the observed policy below; conditional ones only via the record.
428 const isHostProject = (grant: {
429 kind: string;
430 name: string;
431 condition?: IamCondition;
432 }) =>
433 grant.kind === "project" &&
434 grant.name === options.project &&
435 grant.condition === undefined;
436
437 if (options.managed) {
438 const wanted = new Set(
439 desired.filter(isHostProject).map((grant) => grant.role),
440 );
441 const held = yield* projectRolesOf(options.project, options.serviceAccount);
442 const extra = held.filter((role) => !wanted.has(role));
443 if (extra.length > 0) {
444 yield* revokeIamMembership({
445 kind: "project",
446 name: options.project,
447 member: options.serviceAccount,
448 roles: extra,
449 });
450 }
451 }
452
453 for (const group of groupByTarget(stale)) {
454 if (options.managed && isHostProject(group)) continue;
455 yield* revokeIamMembership({
456 kind: group.kind,
457 name: group.name,
458 member: options.serviceAccount,
459 roles: group.roles,
460 condition: group.condition,
461 });
462 }
463
464 return {
465 env: collected.env,
466 grants: desired.filter((grant) => !preexisting.has(grantKey(grant))),
467 };
468});
469
470/** Revoke every recorded grant from the host's runtime service account. */
471export const revokeHostIam = Effect.fn(function* (options: {
472 serviceAccount: string;
473 grants: readonly AppliedIamGrant[];
474}) {
475 for (const group of groupByTarget(options.grants)) {
476 yield* revokeIamMembership({
477 kind: group.kind,
478 name: group.name,
479 member: options.serviceAccount,
480 roles: group.roles,
481 condition: group.condition,
482 });
483 }
484});
485
486/**
487 * Delete an Alchemy-minted host service account (no-op if it is already
488 * gone) after revoking its project roles and every recorded grant, so no
489 * `deleted:serviceAccount:` members linger on other resources.
490 */
491export const deleteHostServiceAccount = Effect.fn(function* (options: {
492 project: string;
493 email: string;
494 grants?: readonly AppliedIamGrant[];
495}) {
496 yield* revokeHostIam({
497 serviceAccount: options.email,
498 grants: options.grants ?? [],
499 });
500 const held = yield* projectRolesOf(options.project, options.email);
501 if (held.length > 0) {
502 yield* revokeIamMembership({
503 kind: "project",
504 name: options.project,
505 member: options.email,
506 roles: held,
507 });
508 }
509 const name = serviceAccountName(options.project, options.email);
510 yield* iam
511 .deleteProjectsServiceAccounts({ name })
512 .pipe(Effect.catchTag("NotFound", () => Effect.void));
513 // Block until IAM stops serving the account.
514 yield* iam.getProjectsServiceAccounts({ name }).pipe(
515 Effect.flatMap(() =>
516 Effect.fail(new HostServiceAccountStillExists({ email: options.email })),
517 ),
518 Effect.catchTag("NotFound", () => Effect.void),
519 Effect.retry({
520 while: (error) => error._tag === "GCP.HostServiceAccountStillExists",
521 times: 30,
522 schedule: Schedule.spaced("2 seconds"),
523 }),
524 );
525});
526
527export class HostServiceAccountStillExists extends Data.TaggedError(
528 "GCP.HostServiceAccountStillExists",
529)<{ email: string }> {}
530
531/**
532 * Bind IAM (+ optional env) onto the ambient GCP host at deploy time.
533 * No-op inside the deployed runtime (`__ALCHEMY_RUNTIME__`).
534 */
535export const bindGcpHost = (options: {
536 tag: string;
537 resource: { readonly LogicalId: string };
538 iam: Input<GcpIamGrant>[];
539 env?: Record<string, any>;
540 cloudSqlInstances?: Input<string>[];
541}) =>
542 Effect.gen(function* () {
543 if (globalThis.__ALCHEMY_RUNTIME__) return;
544 const host = yield* Binding.Host;
545 if (!isGcpHost(host)) return;
546 yield* host.bind`Allow(${host}, ${options.tag}(${options.resource}))`({
547 iam: options.iam,
548 env: options.env,
549 ...(options.cloudSqlInstances
550 ? { cloudSqlInstances: options.cloudSqlInstances }
551 : {}),
552 });
553 });