@microagi/alchemy-gcp 0.11.6 → 0.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as iam from "@distilled.cloud/gcp/unstable/iam-v1";
3
+ import { Resource } from "alchemy";
4
+ import { Unowned } from "alchemy/AdoptPolicy";
5
+ import { isResolved } from "alchemy/Diff";
6
+ import * as Provider from "alchemy/Provider";
7
+ import * as Effect from "effect/Effect";
8
+ import {
9
+ descriptionHasAlchemyMarker,
10
+ gcpAlchemyDescription,
11
+ stripAlchemyMarker,
12
+ } from "../Tags.ts";
13
+ import type * as GCP from "../Providers.ts";
14
+ import { makeAwaitOperation } from "./Operations.ts";
15
+ import { poolResourceName } from "./WorkloadIdentityPool.ts";
16
+
17
+ /**
18
+ * An OIDC **provider** inside a
19
+ * {@link import("./WorkloadIdentityPool.ts").WorkloadIdentityPool} — the
20
+ * thing that actually declares "I trust tokens issued by this URL".
21
+ *
22
+ * The canonical use is keyless federation from another cloud: point
23
+ * `oidc.issuerUri` at an EKS/AKS/GKE cluster's OIDC issuer, and pods holding
24
+ * a projected ServiceAccount token can impersonate a GSA with no key material
25
+ * anywhere in the system.
26
+ *
27
+ * ### `attributeMapping` is required and `google.subject` is mandatory
28
+ *
29
+ * The mapping turns claims on the incoming token into Google attributes. At
30
+ * minimum `google.subject` must be mapped; it is what `principal://` members
31
+ * match on. For a Kubernetes issuer the subject claim is
32
+ * `system:serviceaccount:{namespace}:{name}`, so:
33
+ *
34
+ * ```typescript
35
+ * attributeMapping: { "google.subject": "assertion.sub" }
36
+ * ```
37
+ *
38
+ * ### Set `allowedAudiences`, or the default is surprising
39
+ *
40
+ * When `oidc.allowedAudiences` is empty, GCP accepts only the *default*
41
+ * audience — the full provider resource name prefixed with
42
+ * `https://iam.googleapis.com/`. A Kubernetes projected token minted for
43
+ * audience `sts.googleapis.com` (or anything else) is then rejected with a
44
+ * generic audience error. Set this explicitly to whatever audience the
45
+ * token is actually minted for.
46
+ *
47
+ * ### Constrain trust with `attributeCondition`
48
+ *
49
+ * An issuer-only trust accepts **every** identity that issuer can mint — on
50
+ * a Kubernetes cluster, that is every ServiceAccount in every namespace. Pair
51
+ * the provider with an `attributeCondition` (and a narrow `principal://`
52
+ * member on the GSA binding) so a token from an unrelated namespace cannot
53
+ * impersonate anything.
54
+ *
55
+ * @section Creating a WorkloadIdentityPoolProvider
56
+ * @example Trust one Kubernetes ServiceAccount on an EKS cluster
57
+ * ```typescript
58
+ * const provider = yield* GCP.WorkloadIdentityPoolProvider("EksOidc", {
59
+ * project: "123456789",
60
+ * poolId: pool.poolId,
61
+ * providerId: "eks-oidc",
62
+ * oidc: {
63
+ * issuerUri: "https://oidc.eks.us-east-1.amazonaws.com/id/ABC123",
64
+ * allowedAudiences: ["sts.googleapis.com"],
65
+ * },
66
+ * attributeMapping: { "google.subject": "assertion.sub" },
67
+ * attributeCondition:
68
+ * "assertion.sub == 'system:serviceaccount:admin:eks-auth-reconciler'",
69
+ * });
70
+ * ```
71
+ */
72
+ export type WorkloadIdentityPoolProviderProps = {
73
+ /** Project that owns the pool. Prefer the project **number**. */
74
+ project: string;
75
+ /** ID of the enclosing pool. */
76
+ poolId: string;
77
+ /**
78
+ * Provider ID, 4–32 characters of `[a-z0-9-]`. Immutable — changing it
79
+ * replaces the provider. The `gcp-` prefix is reserved by Google.
80
+ */
81
+ providerId: string;
82
+ /** The OIDC issuer this provider trusts. */
83
+ oidc: {
84
+ /** Issuer URL. Must be HTTPS and serve an OIDC discovery document. */
85
+ issuerUri: string;
86
+ /**
87
+ * Acceptable `aud` values. Leave unset only if the token is minted for
88
+ * the provider's default audience — see the note above.
89
+ */
90
+ allowedAudiences?: string[];
91
+ /**
92
+ * Inline JWKS, for issuers whose keys are not publicly reachable. When
93
+ * unset, GCP fetches `jwks_uri` from the issuer's discovery document,
94
+ * which requires the issuer to be reachable from Google's network.
95
+ */
96
+ jwksJson?: string;
97
+ };
98
+ /**
99
+ * Claim-to-attribute mapping. Must include `google.subject`.
100
+ */
101
+ attributeMapping: Record<string, string>;
102
+ /** CEL expression further restricting which credentials are accepted. */
103
+ attributeCondition?: string;
104
+ /** Display name, max 32 characters. */
105
+ displayName?: string;
106
+ /** Description, max 256 characters. */
107
+ description?: string;
108
+ /** Disable the provider without deleting it. */
109
+ disabled?: boolean;
110
+ };
111
+
112
+ export type WorkloadIdentityPoolProviderAttributes = {
113
+ /** Full resource name, `…/workloadIdentityPools/{pool}/providers/{id}`. */
114
+ name: string;
115
+ project: string;
116
+ poolId: string;
117
+ providerId: string;
118
+ issuerUri: string | undefined;
119
+ // `readonly` because that is how the SDK models repeated fields; widening it
120
+ // to a mutable array here would only force a copy at every use site.
121
+ allowedAudiences: readonly string[] | undefined;
122
+ attributeMapping: Record<string, string> | undefined;
123
+ attributeCondition: string | undefined;
124
+ displayName: string | undefined;
125
+ description: string | undefined;
126
+ state: string | undefined;
127
+ disabled: boolean | undefined;
128
+ /**
129
+ * The audience string to put in a credential configuration, i.e.
130
+ * `//iam.googleapis.com/{name}`. Provided because assembling it by hand is
131
+ * easy to get subtly wrong (the leading `//`, and no scheme).
132
+ */
133
+ audience: string;
134
+ };
135
+
136
+ export interface WorkloadIdentityPoolProvider
137
+ extends Resource<
138
+ "GCP.WorkloadIdentityPoolProvider",
139
+ WorkloadIdentityPoolProviderProps,
140
+ WorkloadIdentityPoolProviderAttributes,
141
+ never,
142
+ GCP.Providers
143
+ > {}
144
+
145
+ export const WorkloadIdentityPoolProvider =
146
+ Resource<WorkloadIdentityPoolProvider>("GCP.WorkloadIdentityPoolProvider");
147
+
148
+ export const providerResourceName = (
149
+ project: string,
150
+ poolId: string,
151
+ providerId: string,
152
+ ) => `${poolResourceName(project, poolId)}/providers/${providerId}`;
153
+
154
+ /**
155
+ * Compare two claim mappings by CONTENT, not serialisation.
156
+ *
157
+ * `JSON.stringify` preserves key insertion order, and the order GCP returns
158
+ * these keys in need not match the order they were sent in. A raw stringify
159
+ * comparison therefore reports drift for two identical mappings and fires a
160
+ * patch LRO on every single deploy.
161
+ */
162
+ const sameMapping = (
163
+ a: Record<string, string> | undefined,
164
+ b: Record<string, string> | undefined,
165
+ ) => {
166
+ const entries = (r: Record<string, string> | undefined) =>
167
+ Object.entries(r ?? {}).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
168
+ return JSON.stringify(entries(a)) === JSON.stringify(entries(b));
169
+ };
170
+
171
+ /**
172
+ * Compare audience allow-lists as SETS.
173
+ *
174
+ * `allowedAudiences` is an allow-list: membership is what matters, order is
175
+ * not meaningful, and GCP is free to return it in any order. Comparing it
176
+ * positionally would make a reordering look like drift forever.
177
+ */
178
+ const sameAudiences = (
179
+ a: readonly string[] | undefined,
180
+ b: readonly string[] | undefined,
181
+ ) => {
182
+ const sorted = (v: readonly string[] | undefined) => [...(v ?? [])].sort();
183
+ return JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
184
+ };
185
+
186
+ /**
187
+ * Treat absent and empty-string as the same value.
188
+ *
189
+ * The patch below uses a FIXED `updateMask` while the request body omits
190
+ * unset optional fields — which is deliberate: under a field mask, an omitted
191
+ * field means "clear it", so dropping `displayName` from the props really
192
+ * should clear it on GCP. The hazard is only in the comparison afterwards. If
193
+ * GCP echoes a cleared field back as `""` rather than omitting it, a raw
194
+ * `!==` against `undefined` would report drift on every single deploy and
195
+ * churn a long-running patch operation forever.
196
+ */
197
+ const sameText = (a: string | undefined, b: string | undefined) =>
198
+ (a ?? "") === (b ?? "");
199
+
200
+ export const WorkloadIdentityPoolProviderProvider = () =>
201
+ Provider.effect(
202
+ WorkloadIdentityPoolProvider,
203
+ Effect.gen(function* () {
204
+ const getProvider =
205
+ yield* iam.getProjectsLocationsWorkloadIdentityPoolsProviders;
206
+ const createProvider =
207
+ yield* iam.createProjectsLocationsWorkloadIdentityPoolsProviders;
208
+ const patchProvider =
209
+ yield* iam.patchProjectsLocationsWorkloadIdentityPoolsProviders;
210
+ const deleteProvider =
211
+ yield* iam.deleteProjectsLocationsWorkloadIdentityPoolsProviders;
212
+ const undeleteProvider =
213
+ yield* iam.undeleteProjectsLocationsWorkloadIdentityPoolsProviders;
214
+ const getOperations =
215
+ yield* iam.getProjectsLocationsWorkloadIdentityPoolsProvidersOperations;
216
+ const awaitOperation = makeAwaitOperation(
217
+ getOperations,
218
+ "Workload Identity provider",
219
+ );
220
+
221
+ const observeProvider = (
222
+ project: string,
223
+ poolId: string,
224
+ providerId: string,
225
+ ) =>
226
+ getProvider({
227
+ name: providerResourceName(project, poolId, providerId),
228
+ }).pipe(
229
+ Effect.catchTag("NotFound", () =>
230
+ Effect.succeed(
231
+ undefined as iam.WorkloadIdentityPoolProvider | undefined,
232
+ ),
233
+ ),
234
+ Effect.catchTag("Forbidden", () =>
235
+ Effect.succeed(
236
+ undefined as iam.WorkloadIdentityPoolProvider | undefined,
237
+ ),
238
+ ),
239
+ );
240
+
241
+ const toAttrs = (
242
+ project: string,
243
+ poolId: string,
244
+ providerId: string,
245
+ provider: iam.WorkloadIdentityPoolProvider,
246
+ ): WorkloadIdentityPoolProviderAttributes => {
247
+ const name =
248
+ provider.name ?? providerResourceName(project, poolId, providerId);
249
+ return {
250
+ name,
251
+ project,
252
+ poolId,
253
+ providerId,
254
+ issuerUri: provider.oidc?.issuerUri,
255
+ allowedAudiences: provider.oidc?.allowedAudiences,
256
+ attributeMapping: provider.attributeMapping,
257
+ attributeCondition: provider.attributeCondition,
258
+ displayName: provider.displayName,
259
+ description: stripAlchemyMarker(provider.description),
260
+ state: provider.state,
261
+ disabled: provider.disabled,
262
+ audience: `//iam.googleapis.com/${name}`,
263
+ };
264
+ };
265
+
266
+ return {
267
+ stables: ["name", "project", "poolId", "providerId", "audience"],
268
+ diff: Effect.fn(function* ({ olds = {}, news, output }) {
269
+ if (!isResolved(news)) return undefined;
270
+ const oldProps = olds as Partial<WorkloadIdentityPoolProviderProps>;
271
+ // Prefer live attributes over persisted props, and fall back to the
272
+ // DESIRED value when neither is known — see the equivalent comment
273
+ // in WorkloadIdentityPool.ts. Defaulting to `undefined` would make
274
+ // an adoption replace a live, correct provider.
275
+ //
276
+ // All three fields are part of the resource name; the API cannot
277
+ // move a provider between pools or rename it.
278
+ const currentProject = output?.project || oldProps.project || news.project;
279
+ const currentPoolId = output?.poolId || oldProps.poolId || news.poolId;
280
+ const currentProviderId =
281
+ output?.providerId || oldProps.providerId || news.providerId;
282
+ if (
283
+ currentProject !== news.project ||
284
+ currentPoolId !== news.poolId ||
285
+ currentProviderId !== news.providerId
286
+ ) {
287
+ return { action: "replace" } as const;
288
+ }
289
+ }),
290
+ read: Effect.fn(function* ({ id, olds, output }) {
291
+ const project = output?.project || olds?.project;
292
+ const poolId = output?.poolId || olds?.poolId;
293
+ const providerId = output?.providerId || olds?.providerId;
294
+ if (!project || !poolId || !providerId) return undefined;
295
+ const provider = yield* observeProvider(project, poolId, providerId);
296
+ if (!provider) return undefined;
297
+ const attrs = toAttrs(project, poolId, providerId, provider);
298
+ return (yield* descriptionHasAlchemyMarker(id, provider.description))
299
+ ? attrs
300
+ : Unowned(attrs);
301
+ }),
302
+ reconcile: Effect.fn(function* ({ id, news, session }) {
303
+ const { project, poolId, providerId } = news;
304
+ if (!news.attributeMapping["google.subject"]) {
305
+ // Caught here rather than at the API, which rejects it with a
306
+ // message that does not name the missing key.
307
+ return yield* new ConfigError({
308
+ message: `Workload Identity provider ${providerId} must map 'google.subject' in attributeMapping.`,
309
+ });
310
+ }
311
+ const description = yield* gcpAlchemyDescription(
312
+ id,
313
+ news.description,
314
+ );
315
+ // Scalars are TOTAL over the updateMask below — always present,
316
+ // explicit empty/default when unset — so clearing is stated in code
317
+ // rather than implied by field-mask semantics.
318
+ //
319
+ // `attributeCondition` and `oidc.jwksJson` are the exception and
320
+ // stay conditional: both are validated formats (a CEL expression
321
+ // and a JWKS document), and sending "" risks a parse rejection
322
+ // where omission is unambiguous. They are still cleared correctly
323
+ // when unset — `oidc` is masked as a whole message, so the body's
324
+ // `oidc` replaces it outright.
325
+ const body: iam.WorkloadIdentityPoolProvider = {
326
+ displayName: news.displayName ?? "",
327
+ description,
328
+ disabled: news.disabled ?? false,
329
+ attributeMapping: news.attributeMapping,
330
+ ...(news.attributeCondition
331
+ ? { attributeCondition: news.attributeCondition }
332
+ : {}),
333
+ oidc: {
334
+ issuerUri: news.oidc.issuerUri,
335
+ allowedAudiences: news.oidc.allowedAudiences ?? [],
336
+ ...(news.oidc.jwksJson ? { jwksJson: news.oidc.jwksJson } : {}),
337
+ },
338
+ };
339
+
340
+ let provider = yield* observeProvider(project, poolId, providerId);
341
+
342
+ // Same soft-delete reasoning as the pool: the ID is held until the
343
+ // purge, so undelete rather than strand the deploy.
344
+ if (provider?.state === "DELETED") {
345
+ yield* session.note(
346
+ `Undeleting soft-deleted Workload Identity provider ${providerId}…`,
347
+ );
348
+ const op = yield* undeleteProvider({
349
+ name: providerResourceName(project, poolId, providerId),
350
+ body: {},
351
+ });
352
+ if (op.name) yield* awaitOperation(op.name, session);
353
+ provider = yield* observeProvider(project, poolId, providerId);
354
+ }
355
+
356
+ if (!provider) {
357
+ const op = yield* createProvider({
358
+ parent: poolResourceName(project, poolId),
359
+ workloadIdentityPoolProviderId: providerId,
360
+ body,
361
+ });
362
+ if (op.name) yield* awaitOperation(op.name, session);
363
+ provider = yield* observeProvider(project, poolId, providerId);
364
+ if (!provider) {
365
+ return yield* new ConfigError({
366
+ message: `Workload Identity provider ${providerId} in pool ${poolId} was not readable after create.`,
367
+ });
368
+ }
369
+ } else {
370
+ const needsPatch =
371
+ !sameText(provider.displayName, news.displayName) ||
372
+ !sameText(provider.description, description) ||
373
+ (provider.disabled ?? false) !== (news.disabled ?? false) ||
374
+ !sameText(provider.attributeCondition, news.attributeCondition) ||
375
+ !sameMapping(provider.attributeMapping, news.attributeMapping) ||
376
+ !sameText(provider.oidc?.issuerUri, news.oidc.issuerUri) ||
377
+ !sameAudiences(
378
+ provider.oidc?.allowedAudiences,
379
+ news.oidc.allowedAudiences,
380
+ ) ||
381
+ !sameText(provider.oidc?.jwksJson, news.oidc.jwksJson);
382
+ if (needsPatch) {
383
+ const op = yield* patchProvider({
384
+ name: providerResourceName(project, poolId, providerId),
385
+ updateMask:
386
+ "displayName,description,disabled,attributeMapping,attributeCondition,oidc",
387
+ body,
388
+ });
389
+ if (op.name) yield* awaitOperation(op.name, session);
390
+ provider =
391
+ (yield* observeProvider(project, poolId, providerId)) ??
392
+ provider;
393
+ }
394
+ }
395
+
396
+ yield* session.note(
397
+ providerResourceName(project, poolId, providerId),
398
+ );
399
+ return toAttrs(project, poolId, providerId, provider);
400
+ }),
401
+ delete: Effect.fn(function* ({ olds, output, session }) {
402
+ // Props fallback for the same reason as the pool: incomplete
403
+ // attributes must not turn destroy into a silent no-op that leaks a
404
+ // soft-deleted-name-holding resource.
405
+ // `||` not `??`: an empty string is never a valid identity here,
406
+ // and a persisted `""` must fall through to the next source rather
407
+ // than be taken as real. With `??` a corrupt/partial state entry
408
+ // would short-circuit the fallback and turn destroy into a silent
409
+ // no-op (or make diff replace a live resource).
410
+ const project = output?.project || olds?.project;
411
+ const poolId = output?.poolId || olds?.poolId;
412
+ const providerId = output?.providerId || olds?.providerId;
413
+ if (!project || !poolId || !providerId) return;
414
+ const op = yield* deleteProvider({
415
+ name: providerResourceName(project, poolId, providerId),
416
+ }).pipe(
417
+ Effect.catchTag("NotFound", () =>
418
+ Effect.succeed({ name: undefined } as iam.Operation),
419
+ ),
420
+ );
421
+ if (op.name) yield* awaitOperation(op.name, session);
422
+ }),
423
+ };
424
+ }),
425
+ );
package/src/Iam/index.ts CHANGED
@@ -6,3 +6,13 @@ export {
6
6
  ServiceAccountKeyProvider,
7
7
  } from "./ServiceAccountKey.ts";
8
8
  export type { ServiceAccountKeyProps } from "./ServiceAccountKey.ts";
9
+ export {
10
+ WorkloadIdentityPool,
11
+ WorkloadIdentityPoolResourceProvider,
12
+ } from "./WorkloadIdentityPool.ts";
13
+ export type { WorkloadIdentityPoolProps } from "./WorkloadIdentityPool.ts";
14
+ export {
15
+ WorkloadIdentityPoolProvider,
16
+ WorkloadIdentityPoolProviderProvider,
17
+ } from "./WorkloadIdentityPoolProvider.ts";
18
+ export type { WorkloadIdentityPoolProviderProps } from "./WorkloadIdentityPoolProvider.ts";
package/src/Providers.ts CHANGED
@@ -39,6 +39,14 @@ import {
39
39
  ServiceAccountKey,
40
40
  ServiceAccountKeyProvider,
41
41
  } from "./Iam/ServiceAccountKey.ts";
42
+ import {
43
+ WorkloadIdentityPool,
44
+ WorkloadIdentityPoolResourceProvider,
45
+ } from "./Iam/WorkloadIdentityPool.ts";
46
+ import {
47
+ WorkloadIdentityPoolProvider,
48
+ WorkloadIdentityPoolProviderProvider,
49
+ } from "./Iam/WorkloadIdentityPoolProvider.ts";
42
50
  import {
43
51
  HelmRelease,
44
52
  HelmReleaseProvider,
@@ -123,6 +131,8 @@ export const providers = () =>
123
131
  ArtifactRegistryRepositoryIamMember,
124
132
  ServiceAccount,
125
133
  ServiceAccountKey,
134
+ WorkloadIdentityPool,
135
+ WorkloadIdentityPoolProvider,
126
136
  KubernetesSecret,
127
137
  KubernetesManifest,
128
138
  HelmRelease,
@@ -156,6 +166,8 @@ export const providers = () =>
156
166
  ArtifactRegistryRepositoryIamMemberProvider(),
157
167
  ServiceAccountProvider(),
158
168
  ServiceAccountKeyProvider(),
169
+ WorkloadIdentityPoolResourceProvider(),
170
+ WorkloadIdentityPoolProviderProvider(),
159
171
  KubernetesSecretProvider(),
160
172
  KubernetesManifestProvider(),
161
173
  HelmReleaseProvider(),