| 1 | import * as kms from "@distilled.cloud/gcp/cloudkms_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { tagRecord } from "../../Tags.ts"; |
| 10 | import { GcpEnvironment } from "../Environment.ts"; |
| 11 | import { |
| 12 | createInternalLabels, |
| 13 | diffLabels, |
| 14 | hasAlchemyLabels, |
| 15 | stripInternalLabels, |
| 16 | toLabels, |
| 17 | } from "../Labels.ts"; |
| 18 | import type { Providers } from "../Providers.ts"; |
| 19 | import { |
| 20 | type OperationFailed, |
| 21 | type OperationTimedOut, |
| 22 | waitForOperation as waitForGcpOperation, |
| 23 | } from "../Operation.ts"; |
| 24 | import { deterministicKmsId } from "./internal.ts"; |
| 25 | |
| 26 | const DEFAULT_PURPOSE: kms.CryptoKeyPurposeEnum = "ENCRYPT_DECRYPT"; |
| 27 | const DEFAULT_ALGORITHM: kms.CryptoKeyVersionTemplateAlgorithmEnum = |
| 28 | "GOOGLE_SYMMETRIC_ENCRYPTION"; |
| 29 | const DEFAULT_PROTECTION: kms.CryptoKeyVersionTemplateProtectionLevelEnum = |
| 30 | "SOFTWARE"; |
| 31 | const DELETABLE_VERSION_STATES = new Set([ |
| 32 | "DESTROYED", |
| 33 | "IMPORT_FAILED", |
| 34 | "GENERATION_FAILED", |
| 35 | ]); |
| 36 | const DESTROYABLE_VERSION_STATES = new Set(["ENABLED", "DISABLED"]); |
| 37 | const USABLE_VERSION_STATES = new Set(["ENABLED", "PENDING_GENERATION"]); |
| 38 | |
| 39 | /** |
| 40 | * Cloud KMS never deletes a key while any version is merely |
| 41 | * `DESTROY_SCHEDULED` (at least 24h), never deletes a key ring, and |
| 42 | * retires a deleted key's name forever. On delete Alchemy therefore never |
| 43 | * deletes the key: it destroys every version and replaces the ownership |
| 44 | * labels with this marker. A released key holds no usable key material, so |
| 45 | * any stack may reclaim it on create: reconcile re-stamps ownership and |
| 46 | * mints a fresh primary. This keeps both fixed and default (deterministic) |
| 47 | * `cryptoKeyId`s reusable across destroy/redeploy cycles. |
| 48 | */ |
| 49 | export const RELEASED_LABEL = "alchemy-released"; |
| 50 | |
| 51 | const isReleased = (key: kms.CryptoKey) => |
| 52 | (key.labels ?? {})[RELEASED_LABEL] !== undefined; |
| 53 | |
| 54 | export type CryptoKeyVersionTemplate = { |
| 55 | /** |
| 56 | * Algorithm for new versions. Defaults to `GOOGLE_SYMMETRIC_ENCRYPTION` |
| 57 | * when `purpose` is `ENCRYPT_DECRYPT`. |
| 58 | */ |
| 59 | algorithm?: kms.CryptoKeyVersionTemplateAlgorithmEnum | (string & {}); |
| 60 | /** |
| 61 | * Protection level for new versions. Immutable — changing it replaces |
| 62 | * the key. |
| 63 | * @default "SOFTWARE" |
| 64 | */ |
| 65 | protectionLevel?: |
| 66 | | kms.CryptoKeyVersionTemplateProtectionLevelEnum |
| 67 | | (string & {}); |
| 68 | }; |
| 69 | |
| 70 | export type CryptoKeyProps = { |
| 71 | /** |
| 72 | * Parent KeyRing. Full name |
| 73 | * `projects/{project}/locations/{location}/keyRings/{keyRing}` or the |
| 74 | * key ring id (combined with `location`). Immutable — changing it |
| 75 | * replaces the key. |
| 76 | */ |
| 77 | keyRing: string; |
| 78 | /** |
| 79 | * Cloud KMS location (`us-central1`, `global`, `us`, …). Used when |
| 80 | * `keyRing` is a bare id. Immutable — changing it replaces the key. |
| 81 | * `US-CENTRAL1` is accepted and normalized to `us-central1`. |
| 82 | * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`) |
| 83 | */ |
| 84 | location?: string; |
| 85 | /** |
| 86 | * CryptoKey id (the last path segment). If omitted, a deterministic |
| 87 | * name `{stack}-{id}-{stage}` is derived (no random suffix), plus a short |
| 88 | * hash of `purpose`, `versionTemplate.protectionLevel`, `importOnly`, |
| 89 | * `destroyScheduledDuration`, and `cryptoKeyBackend` when any differ |
| 90 | * from their defaults — so a redeploy after destroy reclaims the same |
| 91 | * key, and a replacement caused by those settings gets a distinct name. |
| 92 | * Must match `[a-zA-Z0-9_-]{1,63}`. Immutable — changing it replaces the |
| 93 | * key. |
| 94 | */ |
| 95 | cryptoKeyId?: string; |
| 96 | /** |
| 97 | * Immutable purpose of this key. |
| 98 | * @default "ENCRYPT_DECRYPT" |
| 99 | */ |
| 100 | purpose?: kms.CryptoKeyPurposeEnum; |
| 101 | /** |
| 102 | * Template for new CryptoKeyVersions. `protectionLevel` is immutable |
| 103 | * (replacement); `algorithm` can be patched. |
| 104 | */ |
| 105 | versionTemplate?: CryptoKeyVersionTemplate; |
| 106 | /** |
| 107 | * User labels. Alchemy ownership labels are merged in automatically. |
| 108 | */ |
| 109 | labels?: Record<string, string>; |
| 110 | /** |
| 111 | * Automatic rotation period (e.g. `"2592000s"`). ENCRYPT_DECRYPT keys |
| 112 | * only. Must be paired with `nextRotationTime` (Alchemy fills |
| 113 | * `now + period` when omitted). |
| 114 | */ |
| 115 | rotationPeriod?: string; |
| 116 | /** |
| 117 | * RFC3339 time of the next automatic rotation. ENCRYPT_DECRYPT keys |
| 118 | * only. |
| 119 | */ |
| 120 | nextRotationTime?: string; |
| 121 | /** |
| 122 | * How long versions stay in `DESTROY_SCHEDULED` before `DESTROYED`. |
| 123 | * Immutable. Minimum 24h except import-only keys. |
| 124 | */ |
| 125 | destroyScheduledDuration?: string; |
| 126 | /** |
| 127 | * Create the key with no versions (and keep it version-less when a |
| 128 | * released key is reclaimed), e.g. for import-only or externally |
| 129 | * managed material. |
| 130 | * @default false |
| 131 | */ |
| 132 | skipInitialVersionCreation?: boolean; |
| 133 | /** |
| 134 | * Whether this key may contain imported versions only. Immutable. |
| 135 | * @default false |
| 136 | */ |
| 137 | importOnly?: boolean; |
| 138 | /** |
| 139 | * Backend for EXTERNAL_VPC / HSM_SINGLE_TENANT keys. Immutable. |
| 140 | */ |
| 141 | cryptoKeyBackend?: string; |
| 142 | }; |
| 143 | |
| 144 | export type CryptoKeyAttrs = { |
| 145 | /** Full resource name `projects/.../cryptoKeys/{cryptoKey}`. */ |
| 146 | name: string; |
| 147 | /** CryptoKey id (last path segment). */ |
| 148 | cryptoKeyId: string; |
| 149 | /** Parent KeyRing resource name. */ |
| 150 | keyRing: string; |
| 151 | /** Location id (`us-central1`, `global`, …). */ |
| 152 | location: string; |
| 153 | /** Project id. */ |
| 154 | project: string; |
| 155 | /** Key purpose. */ |
| 156 | purpose: string; |
| 157 | /** User labels (Alchemy ownership labels stripped). */ |
| 158 | labels: Record<string, string>; |
| 159 | /** Version template currently applied. */ |
| 160 | versionTemplate: CryptoKeyVersionTemplate | undefined; |
| 161 | /** Automatic rotation period, if set. */ |
| 162 | rotationPeriod: string | undefined; |
| 163 | /** Next automatic rotation time, if set. */ |
| 164 | nextRotationTime: string | undefined; |
| 165 | /** Scheduled-destruction duration. */ |
| 166 | destroyScheduledDuration: string | undefined; |
| 167 | /** Whether the key is import-only. */ |
| 168 | importOnly: boolean; |
| 169 | /** External / single-tenant HSM backend, if any. */ |
| 170 | cryptoKeyBackend: string | undefined; |
| 171 | /** Primary version resource name, if any. */ |
| 172 | primaryVersion: string | undefined; |
| 173 | /** RFC3339 creation timestamp. */ |
| 174 | createTime: string | undefined; |
| 175 | }; |
| 176 | |
| 177 | export type CryptoKey = Resource< |
| 178 | "GCP.KMS.CryptoKey", |
| 179 | CryptoKeyProps, |
| 180 | CryptoKeyAttrs, |
| 181 | never, |
| 182 | Providers |
| 183 | >; |
| 184 | |
| 185 | /** |
| 186 | * A Cloud KMS CryptoKey — a named key that holds zero or more versions. |
| 187 | * |
| 188 | * Purpose, location, parent KeyRing, import-only, backend, |
| 189 | * `destroyScheduledDuration`, and `versionTemplate.protectionLevel` are |
| 190 | * immutable (changing them replaces the key). Labels, rotation, and |
| 191 | * `versionTemplate.algorithm` update in place. |
| 192 | * |
| 193 | * Cloud KMS only permanently deletes a CryptoKey after every version is |
| 194 | * gone, and a deleted CryptoKey's name is retired forever — it can never |
| 195 | * be created again in the project. |
| 196 | * |
| 197 | * Destroying a key therefore *releases* it instead of deleting it: every |
| 198 | * version is scheduled for destruction and the ownership labels are |
| 199 | * replaced by `alchemy-released`. A later deploy with the same |
| 200 | * `cryptoKeyId` — from this stack or any other — reclaims the released key |
| 201 | * and mints a fresh primary version. The default `cryptoKeyId` is |
| 202 | * deterministic per stack, stage, and logical id, so destroy/redeploy |
| 203 | * cycles reuse one key rather than leaving a new released key behind each |
| 204 | * time. Ciphertext encrypted under the old versions is not recoverable. |
| 205 | * |
| 206 | * ### Creating a CryptoKey |
| 207 | * **Example:** Generated name on an existing KeyRing |
| 208 | * ```typescript |
| 209 | * const ring = yield* GCP.KMS.KeyRing("Keys", {}); |
| 210 | * const key = yield* GCP.KMS.CryptoKey("Data", { |
| 211 | * keyRing: ring.name, |
| 212 | * }); |
| 213 | * ``` |
| 214 | * |
| 215 | * **Example:** Explicit id, labels, and no initial version |
| 216 | * ```typescript |
| 217 | * const key = yield* GCP.KMS.CryptoKey("Data", { |
| 218 | * keyRing: ring.name, |
| 219 | * cryptoKeyId: "app-data", |
| 220 | * labels: { env: "prod" }, |
| 221 | * skipInitialVersionCreation: true, |
| 222 | * }); |
| 223 | * ``` |
| 224 | * |
| 225 | * ### Encrypting and Decrypting |
| 226 | * **Example:** Encrypt then decrypt |
| 227 | * ```typescript |
| 228 | * const encrypt = yield* GCP.KMS.Encrypt(key); |
| 229 | * const decrypt = yield* GCP.KMS.Decrypt(key); |
| 230 | * const { ciphertext } = yield* encrypt({ |
| 231 | * body: { plaintext: btoa("hello") }, |
| 232 | * }); |
| 233 | * const { plaintext } = yield* decrypt({ |
| 234 | * body: { ciphertext }, |
| 235 | * }); |
| 236 | * ``` |
| 237 | * |
| 238 | * @resource |
| 239 | * @category KMS |
| 240 | */ |
| 241 | export const CryptoKey = Resource<CryptoKey>("GCP.KMS.CryptoKey"); |
| 242 | |
| 243 | export class CryptoKeyNotResolved extends Data.TaggedError( |
| 244 | "GCP.KMS.CryptoKeyNotResolved", |
| 245 | )<{ |
| 246 | name: string; |
| 247 | }> {} |
| 248 | |
| 249 | export class CryptoKeyOperationFailed extends Data.TaggedError( |
| 250 | "GCP.KMS.CryptoKeyOperationFailed", |
| 251 | )<{ |
| 252 | operation: string; |
| 253 | message: string; |
| 254 | }> {} |
| 255 | |
| 256 | export class CryptoKeyVersionPending extends Data.TaggedError( |
| 257 | "GCP.KMS.CryptoKeyVersionPending", |
| 258 | )<{ |
| 259 | name: string; |
| 260 | state: string; |
| 261 | }> {} |
| 262 | |
| 263 | const lastSegment = (value: string) => { |
| 264 | const trimmed = value.replace(/\/+$/, ""); |
| 265 | const parts = trimmed.split("/"); |
| 266 | return parts[parts.length - 1] || trimmed; |
| 267 | }; |
| 268 | |
| 269 | const normalizeLocation = (location: string | undefined, fallback: string) => |
| 270 | lastSegment(location ?? fallback).toLowerCase(); |
| 271 | |
| 272 | const normalizeProtection = ( |
| 273 | value: string | undefined, |
| 274 | ): kms.CryptoKeyVersionTemplateProtectionLevelEnum => |
| 275 | !value || value === "PROTECTION_LEVEL_UNSPECIFIED" |
| 276 | ? DEFAULT_PROTECTION |
| 277 | : (value as kms.CryptoKeyVersionTemplateProtectionLevelEnum); |
| 278 | |
| 279 | const parseName = (name: string, fallbackLocation: string) => { |
| 280 | const parts = name.split("/").filter((part) => part.length > 0); |
| 281 | const cryptoKeysAt = parts.lastIndexOf("cryptoKeys"); |
| 282 | const keyRingsAt = parts.lastIndexOf("keyRings"); |
| 283 | const locationsAt = parts.lastIndexOf("locations"); |
| 284 | const projectsAt = parts.lastIndexOf("projects"); |
| 285 | const keyRing = |
| 286 | keyRingsAt >= 0 ? parts.slice(0, keyRingsAt + 2).join("/") : ""; |
| 287 | return { |
| 288 | project: |
| 289 | projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "", |
| 290 | location: |
| 291 | locationsAt >= 0 && parts[locationsAt + 1] |
| 292 | ? parts[locationsAt + 1]! |
| 293 | : fallbackLocation, |
| 294 | keyRing, |
| 295 | cryptoKeyId: |
| 296 | cryptoKeysAt >= 0 && parts[cryptoKeysAt + 1] |
| 297 | ? parts[cryptoKeysAt + 1]! |
| 298 | : lastSegment(name), |
| 299 | }; |
| 300 | }; |
| 301 | |
| 302 | const resolveParent = ( |
| 303 | project: string, |
| 304 | keyRing: string, |
| 305 | location: string | undefined, |
| 306 | region: string, |
| 307 | ) => { |
| 308 | if (keyRing.includes("/")) { |
| 309 | const parsed = parseName( |
| 310 | keyRing.includes("/cryptoKeys/") ? keyRing : `${keyRing}/cryptoKeys/_`, |
| 311 | region, |
| 312 | ); |
| 313 | return { |
| 314 | parent: parsed.keyRing, |
| 315 | location: parsed.location, |
| 316 | project: parsed.project || project, |
| 317 | }; |
| 318 | } |
| 319 | const loc = normalizeLocation(location, region); |
| 320 | return { |
| 321 | parent: `projects/${project}/locations/${loc}/keyRings/${keyRing}`, |
| 322 | location: loc, |
| 323 | project, |
| 324 | }; |
| 325 | }; |
| 326 | |
| 327 | const resourceName = (parent: string, cryptoKeyId: string) => |
| 328 | `${parent}/cryptoKeys/${cryptoKeyId}`; |
| 329 | |
| 330 | const userLabels = ( |
| 331 | labels: Record<string, string | undefined> | null | undefined, |
| 332 | ): Record<string, string> => stripInternalLabels(tagRecord(labels)); |
| 333 | |
| 334 | type ImmutableKeyProps = Pick< |
| 335 | CryptoKeyProps, |
| 336 | | "purpose" |
| 337 | | "versionTemplate" |
| 338 | | "importOnly" |
| 339 | | "destroyScheduledDuration" |
| 340 | | "cryptoKeyBackend" |
| 341 | >; |
| 342 | |
| 343 | /** |
| 344 | * The replacement-triggering settings (besides id and parent) folded into |
| 345 | * the generated id, or `undefined` when all are defaults so the common |
| 346 | * case keeps the plain `{stack}-{id}-{stage}` name. |
| 347 | */ |
| 348 | const immutableVariant = (props: ImmutableKeyProps | undefined) => { |
| 349 | const purpose = props?.purpose ?? DEFAULT_PURPOSE; |
| 350 | const protectionLevel = normalizeProtection( |
| 351 | props?.versionTemplate?.protectionLevel, |
| 352 | ); |
| 353 | const importOnly = props?.importOnly === true; |
| 354 | const destroyScheduledDuration = props?.destroyScheduledDuration; |
| 355 | const cryptoKeyBackend = props?.cryptoKeyBackend || undefined; |
| 356 | if ( |
| 357 | purpose === DEFAULT_PURPOSE && |
| 358 | protectionLevel === DEFAULT_PROTECTION && |
| 359 | !importOnly && |
| 360 | destroyScheduledDuration === undefined && |
| 361 | cryptoKeyBackend === undefined |
| 362 | ) { |
| 363 | return undefined; |
| 364 | } |
| 365 | return { |
| 366 | purpose, |
| 367 | protectionLevel, |
| 368 | importOnly, |
| 369 | destroyScheduledDuration, |
| 370 | cryptoKeyBackend, |
| 371 | }; |
| 372 | }; |
| 373 | |
| 374 | const toId = ( |
| 375 | id: string, |
| 376 | props: (ImmutableKeyProps & { cryptoKeyId?: string }) | undefined, |
| 377 | existing?: string, |
| 378 | ) => |
| 379 | Effect.gen(function* () { |
| 380 | return ( |
| 381 | props?.cryptoKeyId ?? |
| 382 | existing ?? |
| 383 | (yield* deterministicKmsId(id, immutableVariant(props))) |
| 384 | ); |
| 385 | }); |
| 386 | |
| 387 | const toAttrs = ( |
| 388 | key: kms.CryptoKey, |
| 389 | project: string, |
| 390 | region: string, |
| 391 | ): CryptoKeyAttrs => { |
| 392 | const name = key.name ?? ""; |
| 393 | const parsed = parseName(name, region); |
| 394 | const template = key.versionTemplate; |
| 395 | return { |
| 396 | name, |
| 397 | cryptoKeyId: parsed.cryptoKeyId, |
| 398 | keyRing: parsed.keyRing, |
| 399 | location: parsed.location, |
| 400 | project: parsed.project || project, |
| 401 | purpose: key.purpose ?? DEFAULT_PURPOSE, |
| 402 | labels: userLabels(key.labels), |
| 403 | versionTemplate: |
| 404 | template === undefined |
| 405 | ? undefined |
| 406 | : { |
| 407 | algorithm: template.algorithm, |
| 408 | protectionLevel: template.protectionLevel, |
| 409 | }, |
| 410 | rotationPeriod: key.rotationPeriod, |
| 411 | nextRotationTime: key.nextRotationTime, |
| 412 | destroyScheduledDuration: key.destroyScheduledDuration, |
| 413 | importOnly: key.importOnly === true, |
| 414 | cryptoKeyBackend: key.cryptoKeyBackend, |
| 415 | primaryVersion: key.primary?.name, |
| 416 | createTime: key.createTime, |
| 417 | }; |
| 418 | }; |
| 419 | |
| 420 | const getByName = (name: string) => |
| 421 | kms |
| 422 | .getProjectsLocationsKeyRingsCryptoKeys({ name }) |
| 423 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 424 | |
| 425 | const paginate = <A, E, R>( |
| 426 | fetch: ( |
| 427 | pageToken: string | undefined, |
| 428 | ) => Effect.Effect<{ items: A[]; nextPageToken?: string }, E, R>, |
| 429 | ) => |
| 430 | Effect.gen(function* () { |
| 431 | const found: A[] = []; |
| 432 | let pageToken: string | undefined; |
| 433 | for (let page = 0; page < 10; page++) { |
| 434 | const response = yield* fetch(pageToken); |
| 435 | found.push(...response.items); |
| 436 | pageToken = response.nextPageToken; |
| 437 | if (pageToken === undefined || pageToken === "") break; |
| 438 | } |
| 439 | return found; |
| 440 | }); |
| 441 | |
| 442 | const listKeyRingsAt = (parent: string) => |
| 443 | paginate((pageToken) => |
| 444 | kms |
| 445 | .listProjectsLocationsKeyRings({ |
| 446 | parent, |
| 447 | pageSize: 1000, |
| 448 | pageToken, |
| 449 | }) |
| 450 | .pipe( |
| 451 | Effect.map((response) => ({ |
| 452 | items: response.keyRings ?? [], |
| 453 | nextPageToken: response.nextPageToken, |
| 454 | })), |
| 455 | Effect.catchTag("NotFound", () => |
| 456 | Effect.succeed({ |
| 457 | items: [] as kms.KeyRing[], |
| 458 | nextPageToken: undefined, |
| 459 | }), |
| 460 | ), |
| 461 | ), |
| 462 | ); |
| 463 | |
| 464 | const listKeysInRing = (parent: string) => |
| 465 | paginate((pageToken) => |
| 466 | kms |
| 467 | .listProjectsLocationsKeyRingsCryptoKeys({ |
| 468 | parent, |
| 469 | pageSize: 1000, |
| 470 | pageToken, |
| 471 | }) |
| 472 | .pipe( |
| 473 | Effect.map((response) => ({ |
| 474 | // Released keys hold no key material and belong to no stack. |
| 475 | items: (response.cryptoKeys ?? []).filter( |
| 476 | (key) => |
| 477 | !isReleased(key) && |
| 478 | Object.keys(key.labels ?? {}).some((label) => |
| 479 | label.startsWith("alchemy-"), |
| 480 | ), |
| 481 | ), |
| 482 | nextPageToken: response.nextPageToken, |
| 483 | })), |
| 484 | Effect.catchTag("NotFound", () => |
| 485 | Effect.succeed({ |
| 486 | items: [] as kms.CryptoKey[], |
| 487 | nextPageToken: undefined, |
| 488 | }), |
| 489 | ), |
| 490 | ), |
| 491 | ); |
| 492 | |
| 493 | const listCryptoKeysAt = (locationParent: string) => |
| 494 | Effect.gen(function* () { |
| 495 | const rings = yield* listKeyRingsAt(locationParent); |
| 496 | const pages = yield* Effect.forEach( |
| 497 | rings, |
| 498 | (ring) => |
| 499 | ring.name |
| 500 | ? listKeysInRing(ring.name) |
| 501 | : Effect.succeed([] as kms.CryptoKey[]), |
| 502 | { concurrency: 4 }, |
| 503 | ); |
| 504 | return pages.flat(); |
| 505 | }); |
| 506 | |
| 507 | const listVersions = (parent: string) => |
| 508 | paginate((pageToken) => |
| 509 | kms |
| 510 | .listProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ |
| 511 | parent, |
| 512 | pageSize: 1000, |
| 513 | pageToken, |
| 514 | }) |
| 515 | .pipe( |
| 516 | Effect.map((response) => ({ |
| 517 | items: response.cryptoKeyVersions ?? [], |
| 518 | nextPageToken: response.nextPageToken, |
| 519 | })), |
| 520 | Effect.catchTag("NotFound", () => |
| 521 | Effect.succeed({ |
| 522 | items: [] as kms.CryptoKeyVersion[], |
| 523 | nextPageToken: undefined, |
| 524 | }), |
| 525 | ), |
| 526 | ), |
| 527 | ); |
| 528 | |
| 529 | const waitOperation = (operation: kms.Operation) => |
| 530 | waitForGcpOperation( |
| 531 | operation, |
| 532 | (name) => kms.getProjectsLocationsOperations({ name }), |
| 533 | { budget: "10 minutes" }, |
| 534 | ).pipe( |
| 535 | // Re-read the finished operation for its typed response. |
| 536 | Effect.flatMap(() => |
| 537 | operation.name === undefined |
| 538 | ? Effect.succeed(operation) |
| 539 | : kms.getProjectsLocationsOperations({ name: operation.name }), |
| 540 | ), |
| 541 | ); |
| 542 | |
| 543 | const waitPrimaryReady = ( |
| 544 | name: string, |
| 545 | ): Effect.Effect< |
| 546 | kms.CryptoKey, |
| 547 | | CryptoKeyNotResolved |
| 548 | | CryptoKeyOperationFailed |
| 549 | | CryptoKeyVersionPending |
| 550 | | kms.GetProjectsLocationsKeyRingsCryptoKeysError, |
| 551 | kms.GcpOpContext |
| 552 | > => { |
| 553 | const probe: Effect.Effect< |
| 554 | kms.CryptoKey, |
| 555 | | CryptoKeyNotResolved |
| 556 | | CryptoKeyOperationFailed |
| 557 | | CryptoKeyVersionPending |
| 558 | | kms.GetProjectsLocationsKeyRingsCryptoKeysError, |
| 559 | kms.GcpOpContext |
| 560 | > = getByName(name).pipe( |
| 561 | Effect.flatMap( |
| 562 | ( |
| 563 | key, |
| 564 | ): Effect.Effect< |
| 565 | kms.CryptoKey, |
| 566 | | CryptoKeyNotResolved |
| 567 | | CryptoKeyOperationFailed |
| 568 | | CryptoKeyVersionPending |
| 569 | > => { |
| 570 | if (key === undefined) { |
| 571 | return Effect.fail(new CryptoKeyNotResolved({ name })); |
| 572 | } |
| 573 | const state = key.primary?.state; |
| 574 | if (state === undefined || state === "ENABLED") { |
| 575 | return Effect.succeed(key); |
| 576 | } |
| 577 | if (state === "GENERATION_FAILED") { |
| 578 | return Effect.fail( |
| 579 | new CryptoKeyOperationFailed({ |
| 580 | operation: key.primary?.name ?? name, |
| 581 | message: key.primary?.generationFailureReason ?? state, |
| 582 | }), |
| 583 | ); |
| 584 | } |
| 585 | return Effect.fail( |
| 586 | new CryptoKeyVersionPending({ |
| 587 | name, |
| 588 | state, |
| 589 | }), |
| 590 | ); |
| 591 | }, |
| 592 | ), |
| 593 | ); |
| 594 | return probe.pipe( |
| 595 | Effect.retry({ |
| 596 | while: (error) => error._tag === "GCP.KMS.CryptoKeyVersionPending", |
| 597 | times: 8, |
| 598 | schedule: Schedule.spaced("500 millis"), |
| 599 | }), |
| 600 | ); |
| 601 | }; |
| 602 | |
| 603 | const desiredAlgorithm = ( |
| 604 | purpose: kms.CryptoKeyPurposeEnum, |
| 605 | template: CryptoKeyVersionTemplate | undefined, |
| 606 | ) => |
| 607 | template?.algorithm ?? |
| 608 | (purpose === "ENCRYPT_DECRYPT" ? DEFAULT_ALGORITHM : template?.algorithm); |
| 609 | |
| 610 | const nextRotationFromPeriod = (period: string) => |
| 611 | Effect.sync(() => { |
| 612 | const match = /^(\d+)s$/.exec(period); |
| 613 | const seconds = match ? Number(match[1]) : 86_400; |
| 614 | return new Date(Date.now() + seconds * 1000).toISOString(); |
| 615 | }); |
| 616 | |
| 617 | const destroyVersion = (name: string) => |
| 618 | kms |
| 619 | .destroyProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ |
| 620 | name, |
| 621 | body: {}, |
| 622 | }) |
| 623 | .pipe( |
| 624 | Effect.catchTag("NotFound", () => Effect.void), |
| 625 | Effect.catchTag("BadRequest", () => Effect.void), |
| 626 | Effect.catchTag("Conflict", () => Effect.void), |
| 627 | ); |
| 628 | |
| 629 | const deleteVersion = (name: string) => |
| 630 | kms.deleteProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ name }).pipe( |
| 631 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 632 | Effect.catchTag("BadRequest", () => Effect.succeed(undefined)), |
| 633 | Effect.catchTag("Conflict", () => Effect.succeed(undefined)), |
| 634 | Effect.flatMap( |
| 635 | ( |
| 636 | operation, |
| 637 | ): Effect.Effect< |
| 638 | void, |
| 639 | | CryptoKeyOperationFailed |
| 640 | | OperationFailed |
| 641 | | OperationTimedOut |
| 642 | | kms.GetProjectsLocationsOperationsError, |
| 643 | kms.GcpOpContext |
| 644 | > => |
| 645 | operation === undefined |
| 646 | ? Effect.void |
| 647 | : waitOperation(operation).pipe(Effect.asVoid), |
| 648 | ), |
| 649 | ); |
| 650 | |
| 651 | const clearRotation = (name: string, current: kms.CryptoKey) => { |
| 652 | if (!current.rotationPeriod && !current.nextRotationTime) { |
| 653 | return Effect.void; |
| 654 | } |
| 655 | return kms |
| 656 | .patchProjectsLocationsKeyRingsCryptoKeys({ |
| 657 | name, |
| 658 | updateMask: "rotationPeriod,nextRotationTime", |
| 659 | body: {}, |
| 660 | }) |
| 661 | .pipe( |
| 662 | Effect.catchTag("NotFound", () => Effect.void), |
| 663 | Effect.asVoid, |
| 664 | ); |
| 665 | }; |
| 666 | |
| 667 | /** |
| 668 | * Mark a key reclaimable: keep user labels, drop the stack/stage/id |
| 669 | * ownership labels, and add {@link RELEASED_LABEL}. |
| 670 | */ |
| 671 | const releaseKey = (name: string, current: kms.CryptoKey) => |
| 672 | isReleased(current) |
| 673 | ? Effect.void |
| 674 | : kms |
| 675 | .patchProjectsLocationsKeyRingsCryptoKeys({ |
| 676 | name, |
| 677 | updateMask: "labels", |
| 678 | body: { |
| 679 | labels: { ...userLabels(current.labels), [RELEASED_LABEL]: "true" }, |
| 680 | }, |
| 681 | }) |
| 682 | .pipe( |
| 683 | Effect.catchTag("NotFound", () => Effect.void), |
| 684 | Effect.asVoid, |
| 685 | ); |
| 686 | |
| 687 | /** |
| 688 | * Give a key usable material when it has none — a reclaimed released key, |
| 689 | * or one whose versions were destroyed out of band. Symmetric keys also |
| 690 | * need that version as their primary. |
| 691 | */ |
| 692 | const ensureUsableVersion = ( |
| 693 | name: string, |
| 694 | current: kms.CryptoKey, |
| 695 | purpose: kms.CryptoKeyPurposeEnum | (string & {}), |
| 696 | ) => |
| 697 | Effect.gen(function* () { |
| 698 | const symmetric = purpose === "ENCRYPT_DECRYPT"; |
| 699 | if (symmetric && USABLE_VERSION_STATES.has(current.primary?.state ?? "")) { |
| 700 | return current; |
| 701 | } |
| 702 | const versions = yield* listVersions(name); |
| 703 | let usable = versions.find((version) => |
| 704 | USABLE_VERSION_STATES.has(version.state ?? ""), |
| 705 | ); |
| 706 | if (usable === undefined) { |
| 707 | usable = |
| 708 | yield* kms.createProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersions({ |
| 709 | parent: name, |
| 710 | body: {}, |
| 711 | }); |
| 712 | } |
| 713 | if (!symmetric) return current; |
| 714 | const versionId = lastSegment(usable.name ?? ""); |
| 715 | yield* kms.updatePrimaryVersionProjectsLocationsKeyRingsCryptoKeys({ |
| 716 | name, |
| 717 | body: { cryptoKeyVersionId: versionId }, |
| 718 | }); |
| 719 | return yield* waitPrimaryReady(name); |
| 720 | }); |
| 721 | |
| 722 | export const CryptoKeyProvider = () => |
| 723 | Provider.succeed(CryptoKey, { |
| 724 | stables: [ |
| 725 | "name", |
| 726 | "cryptoKeyId", |
| 727 | "keyRing", |
| 728 | "location", |
| 729 | "project", |
| 730 | "purpose", |
| 731 | "destroyScheduledDuration", |
| 732 | "importOnly", |
| 733 | "cryptoKeyBackend", |
| 734 | "createTime", |
| 735 | ], |
| 736 | |
| 737 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 738 | if (!isResolved(news)) return undefined; |
| 739 | const env = yield* GcpEnvironment.current; |
| 740 | |
| 741 | const previousId = olds?.cryptoKeyId ?? output?.cryptoKeyId; |
| 742 | const nextId = news.cryptoKeyId ?? previousId; |
| 743 | const idChanged = |
| 744 | previousId !== undefined && |
| 745 | nextId !== undefined && |
| 746 | nextId !== previousId; |
| 747 | |
| 748 | const previousParent = |
| 749 | output?.keyRing ?? |
| 750 | (olds?.keyRing |
| 751 | ? resolveParent("", olds.keyRing, olds.location, env.region).parent |
| 752 | : undefined); |
| 753 | const nextParent = resolveParent( |
| 754 | output?.project ?? "", |
| 755 | news.keyRing, |
| 756 | news.location ?? output?.location, |
| 757 | env.region, |
| 758 | ).parent; |
| 759 | const parentChanged = |
| 760 | previousParent !== undefined && previousParent !== nextParent; |
| 761 | |
| 762 | const previousPurpose = |
| 763 | olds?.purpose ?? output?.purpose ?? DEFAULT_PURPOSE; |
| 764 | const nextPurpose = news.purpose ?? DEFAULT_PURPOSE; |
| 765 | const previousProtection = normalizeProtection( |
| 766 | olds?.versionTemplate?.protectionLevel ?? |
| 767 | output?.versionTemplate?.protectionLevel, |
| 768 | ); |
| 769 | const nextProtection = normalizeProtection( |
| 770 | news.versionTemplate?.protectionLevel, |
| 771 | ); |
| 772 | const previousImportOnly = |
| 773 | olds?.importOnly ?? output?.importOnly ?? false; |
| 774 | const nextImportOnly = news.importOnly === true; |
| 775 | const previousDuration = |
| 776 | olds?.destroyScheduledDuration ?? output?.destroyScheduledDuration; |
| 777 | const nextDuration = news.destroyScheduledDuration; |
| 778 | const previousBackend = |
| 779 | olds?.cryptoKeyBackend ?? output?.cryptoKeyBackend; |
| 780 | const nextBackend = news.cryptoKeyBackend; |
| 781 | |
| 782 | const replace = |
| 783 | idChanged || |
| 784 | parentChanged || |
| 785 | previousPurpose !== nextPurpose || |
| 786 | previousProtection !== nextProtection || |
| 787 | previousImportOnly !== nextImportOnly || |
| 788 | (previousDuration !== undefined && |
| 789 | nextDuration !== undefined && |
| 790 | previousDuration !== nextDuration) || |
| 791 | (previousBackend ?? "") !== (nextBackend ?? ""); |
| 792 | |
| 793 | if (!replace) return undefined; |
| 794 | return { |
| 795 | action: "replace" as const, |
| 796 | // A generated id folds every replacement trigger except the parent |
| 797 | // into its hash, and a parent change moves rings, so the new |
| 798 | // generation never collides with the old one. Only a fixed id |
| 799 | // reused in the same ring must release the old key first. |
| 800 | deleteFirst: |
| 801 | news.cryptoKeyId !== undefined && |
| 802 | previousId !== undefined && |
| 803 | nextId === previousId && |
| 804 | !parentChanged, |
| 805 | }; |
| 806 | }), |
| 807 | |
| 808 | read: Effect.fn(function* ({ id, olds, output }) { |
| 809 | const env = yield* GcpEnvironment.current; |
| 810 | const cryptoKeyId = yield* toId(id, olds, output?.cryptoKeyId); |
| 811 | const name = |
| 812 | output?.name ?? |
| 813 | (olds?.keyRing || output?.keyRing |
| 814 | ? resourceName( |
| 815 | resolveParent( |
| 816 | env.project, |
| 817 | olds?.keyRing ?? output?.keyRing ?? "", |
| 818 | olds?.location ?? output?.location, |
| 819 | env.region, |
| 820 | ).parent, |
| 821 | cryptoKeyId, |
| 822 | ) |
| 823 | : undefined); |
| 824 | if (name === undefined) return undefined; |
| 825 | const existing = yield* getByName(name); |
| 826 | if (existing === undefined) return undefined; |
| 827 | const attrs = toAttrs(existing, env.project, env.region); |
| 828 | return isReleased(existing) || |
| 829 | (yield* hasAlchemyLabels(id, tagRecord(existing.labels))) |
| 830 | ? attrs |
| 831 | : Unowned(attrs); |
| 832 | }), |
| 833 | |
| 834 | list: () => |
| 835 | Effect.gen(function* () { |
| 836 | const env = yield* GcpEnvironment.current; |
| 837 | const found: ReturnType<typeof toAttrs>[] = []; |
| 838 | let pageToken: string | undefined; |
| 839 | for (let page = 0; page < 10; page++) { |
| 840 | const response = yield* kms.listProjectsLocations({ |
| 841 | name: `projects/${env.project}`, |
| 842 | pageSize: 100, |
| 843 | pageToken, |
| 844 | }); |
| 845 | const parents = (response.locations ?? []) |
| 846 | .map((location) => location.name) |
| 847 | .filter((name): name is string => !!name); |
| 848 | const batches = yield* Effect.forEach( |
| 849 | parents, |
| 850 | (parent) => listCryptoKeysAt(parent), |
| 851 | { concurrency: 4 }, |
| 852 | ); |
| 853 | for (const keys of batches) { |
| 854 | for (const key of keys) { |
| 855 | found.push(toAttrs(key, env.project, env.region)); |
| 856 | } |
| 857 | } |
| 858 | pageToken = response.nextPageToken; |
| 859 | if (pageToken === undefined || pageToken === "") break; |
| 860 | } |
| 861 | return found; |
| 862 | }), |
| 863 | |
| 864 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 865 | const env = yield* GcpEnvironment.current; |
| 866 | const cryptoKeyId = yield* toId(id, news, output?.cryptoKeyId); |
| 867 | const parent = resolveParent( |
| 868 | env.project, |
| 869 | news.keyRing, |
| 870 | news.location ?? output?.location, |
| 871 | env.region, |
| 872 | ); |
| 873 | const name = resourceName(parent.parent, cryptoKeyId); |
| 874 | const purpose = news.purpose ?? DEFAULT_PURPOSE; |
| 875 | const algorithm = desiredAlgorithm(purpose, news.versionTemplate); |
| 876 | const protectionLevel = news.versionTemplate?.protectionLevel; |
| 877 | const desiredLabels = { |
| 878 | ...toLabels(news.labels), |
| 879 | ...(yield* createInternalLabels(id)), |
| 880 | }; |
| 881 | const rotationPeriod = news.rotationPeriod; |
| 882 | const nextRotationTime = rotationPeriod |
| 883 | ? (news.nextRotationTime ?? |
| 884 | (yield* nextRotationFromPeriod(rotationPeriod))) |
| 885 | : news.nextRotationTime; |
| 886 | |
| 887 | let current = yield* getByName(name); |
| 888 | |
| 889 | if (current === undefined) { |
| 890 | const created = yield* kms |
| 891 | .createProjectsLocationsKeyRingsCryptoKeys({ |
| 892 | parent: parent.parent, |
| 893 | cryptoKeyId, |
| 894 | skipInitialVersionCreation: news.skipInitialVersionCreation, |
| 895 | body: { |
| 896 | purpose, |
| 897 | labels: desiredLabels, |
| 898 | versionTemplate: { |
| 899 | algorithm, |
| 900 | protectionLevel, |
| 901 | }, |
| 902 | rotationPeriod, |
| 903 | nextRotationTime, |
| 904 | destroyScheduledDuration: news.destroyScheduledDuration, |
| 905 | importOnly: news.importOnly, |
| 906 | cryptoKeyBackend: news.cryptoKeyBackend, |
| 907 | }, |
| 908 | }) |
| 909 | .pipe(Effect.catchTag("Conflict", () => getByName(name))); |
| 910 | current = created ?? undefined; |
| 911 | if ( |
| 912 | current !== undefined && |
| 913 | news.skipInitialVersionCreation !== true && |
| 914 | current.primary?.state === "PENDING_GENERATION" |
| 915 | ) { |
| 916 | current = yield* waitPrimaryReady(name); |
| 917 | } |
| 918 | } |
| 919 | |
| 920 | if (current === undefined) { |
| 921 | return yield* new CryptoKeyNotResolved({ name }); |
| 922 | } |
| 923 | |
| 924 | const observedLabels = tagRecord(current.labels); |
| 925 | const { upsert, removed } = diffLabels(observedLabels, desiredLabels); |
| 926 | const labelsChanged = upsert.length > 0 || removed.length > 0; |
| 927 | const observedAlgorithm = current.versionTemplate?.algorithm; |
| 928 | const algorithmChanged = |
| 929 | algorithm !== undefined && observedAlgorithm !== algorithm; |
| 930 | const rotationChanged = |
| 931 | (current.rotationPeriod ?? "") !== (rotationPeriod ?? "") || |
| 932 | (news.nextRotationTime !== undefined && |
| 933 | (current.nextRotationTime ?? "") !== news.nextRotationTime); |
| 934 | |
| 935 | if (labelsChanged || algorithmChanged || rotationChanged) { |
| 936 | const updateMask = [ |
| 937 | labelsChanged ? "labels" : undefined, |
| 938 | algorithmChanged ? "versionTemplate.algorithm" : undefined, |
| 939 | rotationChanged ? "rotationPeriod" : undefined, |
| 940 | rotationChanged ? "nextRotationTime" : undefined, |
| 941 | ] |
| 942 | .filter((field): field is string => field !== undefined) |
| 943 | .join(","); |
| 944 | current = yield* kms.patchProjectsLocationsKeyRingsCryptoKeys({ |
| 945 | name, |
| 946 | updateMask, |
| 947 | body: { |
| 948 | labels: desiredLabels, |
| 949 | versionTemplate: algorithmChanged |
| 950 | ? { |
| 951 | algorithm, |
| 952 | protectionLevel: |
| 953 | protectionLevel ?? current.versionTemplate?.protectionLevel, |
| 954 | } |
| 955 | : undefined, |
| 956 | rotationPeriod, |
| 957 | nextRotationTime, |
| 958 | }, |
| 959 | }); |
| 960 | } |
| 961 | |
| 962 | if ( |
| 963 | news.skipInitialVersionCreation !== true && |
| 964 | news.importOnly !== true |
| 965 | ) { |
| 966 | current = yield* ensureUsableVersion(name, current, purpose); |
| 967 | } |
| 968 | |
| 969 | return toAttrs(current, env.project, env.region); |
| 970 | }), |
| 971 | |
| 972 | delete: Effect.fn(function* ({ output }) { |
| 973 | const name = output.name; |
| 974 | const current = yield* getByName(name); |
| 975 | if (current === undefined) return; |
| 976 | |
| 977 | yield* clearRotation(name, current); |
| 978 | yield* releaseKey(name, current); |
| 979 | |
| 980 | const versions = yield* listVersions(name); |
| 981 | yield* Effect.forEach( |
| 982 | versions, |
| 983 | (version) => { |
| 984 | const versionName = version.name; |
| 985 | if (versionName === undefined) return Effect.void; |
| 986 | const state = version.state ?? ""; |
| 987 | if (DESTROYABLE_VERSION_STATES.has(state)) { |
| 988 | return destroyVersion(versionName); |
| 989 | } |
| 990 | if (DELETABLE_VERSION_STATES.has(state)) { |
| 991 | return deleteVersion(versionName); |
| 992 | } |
| 993 | return Effect.void; |
| 994 | }, |
| 995 | { concurrency: 4 }, |
| 996 | ); |
| 997 | |
| 998 | // The key itself is never deleted: KMS retires a deleted key's name |
| 999 | // forever, which would make this id (fixed or the deterministic |
| 1000 | // default) undeployable. The released key is reclaimed on redeploy. |
| 1001 | }), |
| 1002 | }); |