From 68ebaf346be4c2539b9c83d46c3575ef21a6dbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Tiburcio=20Ribeiro=20Netto?= Date: Wed, 17 Jun 2026 22:08:19 -0300 Subject: [PATCH 1/2] feat(aws/kms): add key and alias resources Tests: - bun --check packages/alchemy/src/AWS/KMS/Key.ts packages/alchemy/src/AWS/KMS/Alias.ts packages/alchemy/src/AWS/KMS/index.ts packages/alchemy/src/AWS/index.ts packages/alchemy/src/AWS/Providers.ts packages/alchemy/test/AWS/KMS/Key.test.ts - bunx oxfmt packages/alchemy/src/AWS/KMS/Key.ts packages/alchemy/src/AWS/KMS/Alias.ts packages/alchemy/src/AWS/KMS/index.ts packages/alchemy/src/AWS/index.ts packages/alchemy/src/AWS/Providers.ts packages/alchemy/test/AWS/KMS/Key.test.ts --check - bunx oxlint packages/alchemy/src/AWS/KMS/Key.ts packages/alchemy/src/AWS/KMS/Alias.ts packages/alchemy/src/AWS/KMS/index.ts packages/alchemy/src/AWS/index.ts packages/alchemy/src/AWS/Providers.ts packages/alchemy/test/AWS/KMS/Key.test.ts - cd packages/alchemy && bun vitest run test/AWS/KMS/Key.test.ts --reporter=verbose - Manual smoke: verified key/alias create, update, retarget, destroy, CloudWatch Logs encryption, and Secrets Manager encryption --- packages/alchemy/src/AWS/KMS/Alias.ts | 218 ++++++++ packages/alchemy/src/AWS/KMS/Key.ts | 627 ++++++++++++++++++++++ packages/alchemy/src/AWS/KMS/index.ts | 15 + packages/alchemy/src/AWS/Providers.ts | 5 + packages/alchemy/src/AWS/index.ts | 1 + packages/alchemy/test/AWS/KMS/Key.test.ts | 287 ++++++++++ 6 files changed, 1153 insertions(+) create mode 100644 packages/alchemy/src/AWS/KMS/Alias.ts create mode 100644 packages/alchemy/src/AWS/KMS/Key.ts create mode 100644 packages/alchemy/src/AWS/KMS/index.ts create mode 100644 packages/alchemy/test/AWS/KMS/Key.test.ts diff --git a/packages/alchemy/src/AWS/KMS/Alias.ts b/packages/alchemy/src/AWS/KMS/Alias.ts new file mode 100644 index 000000000..5e5dc94f5 --- /dev/null +++ b/packages/alchemy/src/AWS/KMS/Alias.ts @@ -0,0 +1,218 @@ +import * as kms from "@distilled.cloud/aws/kms"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { AWSEnvironment, type AccountID } from "../Environment.ts"; +import type { Providers } from "../Providers.ts"; +import type { RegionID } from "../Region.ts"; +import type { KeyArn, KeyId } from "./Key.ts"; + +export type AliasName = `alias/${string}`; +export type AliasArn = `arn:aws:kms:${RegionID}:${AccountID}:${AliasName}`; + +export interface AliasProps { + /** + * Alias name. Must begin with `alias/`. If omitted, Alchemy generates one. + */ + aliasName?: AliasName; + /** + * Target KMS key ID or ARN for this alias. + */ + targetKeyId: KeyId | KeyArn; +} + +export interface Alias extends Resource< + "AWS.KMS.Alias", + AliasProps, + { + aliasName: AliasName; + aliasArn: AliasArn; + targetKeyId: KeyId; + }, + never, + Providers +> {} + +/** + * An AWS KMS alias that points to a customer managed key. + * + * @section Creating Aliases + * @example Alias for a Key + * ```typescript + * import * as KMS from "alchemy/AWS/KMS"; + * + * const key = yield* KMS.Key("AppKey"); + * const alias = yield* KMS.Alias("AppAlias", { + * aliasName: "alias/app", + * targetKeyId: key.keyId, + * }); + * ``` + */ +export const Alias = Resource("AWS.KMS.Alias"); + +export const AliasProvider = () => + Provider.succeed(Alias, { + stables: ["aliasName", "aliasArn"], + list: () => + Effect.gen(function* () { + const aliases = yield* kms.listAliases.pages({}).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => page.Aliases ?? []), + ), + ); + + return aliases + .filter( + ( + alias, + ): alias is kms.AliasListEntry & { + AliasName: AliasName; + AliasArn: AliasArn; + TargetKeyId: KeyId; + } => + isCustomerAlias(alias.AliasName) && + alias.AliasArn !== undefined && + alias.TargetKeyId !== undefined, + ) + .map((alias) => ({ + aliasName: alias.AliasName, + aliasArn: alias.AliasArn, + targetKeyId: alias.TargetKeyId, + })); + }), + read: Effect.fn(function* ({ id, olds, output }) { + const aliasName = + output?.aliasName ?? (yield* toAliasName(id, olds ?? {})); + const state = yield* readAlias(aliasName); + if (!state) return undefined; + return output ? state : Unowned(state); + }), + diff: Effect.fn(function* ({ id, news, olds = {} }) { + if (!isResolved(news)) return; + if ((yield* toAliasName(id, news)) !== (yield* toAliasName(id, olds))) { + return { action: "replace" } as const; + } + }), + reconcile: Effect.fn(function* ({ id, news, output, session }) { + const aliasName = output?.aliasName ?? (yield* toAliasName(id, news)); + const targetKeyId = yield* resolveTargetKeyId(news.targetKeyId); + let state = yield* readAlias(aliasName); + + if (!state) { + yield* kms + .createAlias({ + AliasName: aliasName, + TargetKeyId: targetKeyId, + }) + .pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + Effect.catchTag("AlreadyExistsException", () => Effect.void), + ); + state = yield* readAlias(aliasName); + } + + if (state?.targetKeyId !== targetKeyId) { + yield* kms + .updateAlias({ + AliasName: aliasName, + TargetKeyId: targetKeyId, + }) + .pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + state = yield* readAlias(aliasName); + } + + if (!state) { + return yield* Effect.die( + new Error(`failed to read KMS alias ${aliasName}`), + ); + } + + yield* session.note(`KMS alias ${aliasName}`); + return state; + }), + delete: Effect.fn(function* ({ output, session }) { + yield* kms + .deleteAlias({ + AliasName: output.aliasName, + }) + .pipe(Effect.catchTag("NotFoundException", () => Effect.void)); + yield* session.note(`Deleted KMS alias ${output.aliasName}`); + }), + }); + +const toAliasName = Effect.fn(function* ( + id: string, + props: { aliasName?: AliasName }, +) { + if (props.aliasName) { + return props.aliasName; + } + + return `alias/${yield* createPhysicalName({ + id, + maxLength: 256 - "alias/".length, + })}` as AliasName; +}); + +const readAlias = Effect.fn(function* (aliasName: AliasName) { + const alias = yield* findAlias(aliasName); + if (!alias?.AliasName || !alias.TargetKeyId) return undefined; + + return { + aliasName: alias.AliasName as AliasName, + aliasArn: (alias.AliasArn ?? (yield* aliasArn(aliasName))) as AliasArn, + targetKeyId: alias.TargetKeyId, + }; +}); + +const findAlias = Effect.fn(function* (aliasName: AliasName) { + const aliases = yield* kms.listAliases.pages({}).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => page.Aliases ?? []), + ), + ); + + return aliases.find((alias) => alias.AliasName === aliasName); +}); + +const aliasArn = Effect.fn(function* (aliasName: AliasName) { + const { accountId, region } = yield* AWSEnvironment.current; + return `arn:aws:kms:${region}:${accountId}:${aliasName}` as AliasArn; +}); + +const resolveTargetKeyId = Effect.fn(function* (targetKeyId: string) { + const described = yield* kms.describeKey({ KeyId: targetKeyId }); + return described.KeyMetadata.KeyId; +}); + +const isCustomerAlias = ( + aliasName: string | undefined, +): aliasName is AliasName => + aliasName !== undefined && + aliasName.startsWith("alias/") && + !aliasName.startsWith("alias/aws/"); + +const isKmsEventuallyConsistent = (error: { _tag: string }) => + error._tag === "DependencyTimeoutException" || + error._tag === "KMSInternalException" || + error._tag === "KMSInvalidStateException" || + error._tag === "NotFoundException"; + +const kmsRetrySchedule = Schedule.exponential(250).pipe( + Schedule.both(Schedule.recurs(7)), +); diff --git a/packages/alchemy/src/AWS/KMS/Key.ts b/packages/alchemy/src/AWS/KMS/Key.ts new file mode 100644 index 000000000..a9d47e196 --- /dev/null +++ b/packages/alchemy/src/AWS/KMS/Key.ts @@ -0,0 +1,627 @@ +import * as kms from "@distilled.cloud/aws/kms"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; +import type { AccountID } from "../Environment.ts"; +import type { Providers } from "../Providers.ts"; +import type { RegionID } from "../Region.ts"; + +export type KeyId = string; +export type KeyArn = `arn:aws:kms:${RegionID}:${AccountID}:key/${KeyId}`; + +export type { KeySpec, KeyState, KeyUsageType } from "@distilled.cloud/aws/kms"; + +export interface KeyProps { + /** + * Description for the KMS key. + */ + description?: string; + /** + * Cryptographic operations that the key supports. + * @default "ENCRYPT_DECRYPT" + */ + keyUsage?: kms.KeyUsageType; + /** + * Type of key material for the KMS key. + * @default "SYMMETRIC_DEFAULT" + */ + keySpec?: kms.KeySpec; + /** + * Key policy JSON. If omitted, AWS creates and manages the default key policy. + */ + policy?: string; + /** + * Whether to bypass KMS policy lockout safety checks when creating or updating + * the key policy. + * @default false + */ + bypassPolicyLockoutSafetyCheck?: boolean; + /** + * Whether the KMS key is enabled. + * @default true + */ + enabled?: boolean; + /** + * Whether automatic key rotation is enabled. + * @default false + */ + enableKeyRotation?: boolean; + /** + * Rotation period in days when automatic key rotation is enabled. + */ + rotationPeriodInDays?: number; + /** + * Whether to create a multi-region primary key. + * @default false + */ + multiRegion?: boolean; + /** + * Waiting period, in days, before AWS permanently deletes the key after + * destroy schedules deletion. + * @default 30 + */ + deletionWindowInDays?: number; + /** + * User-defined tags to apply to the key. + */ + tags?: Record; +} + +export interface Key extends Resource< + "AWS.KMS.Key", + KeyProps, + { + keyId: KeyId; + keyArn: KeyArn; + description: string | undefined; + keyUsage: kms.KeyUsageType; + keySpec: kms.KeySpec; + keyState: kms.KeyState | undefined; + enabled: boolean; + keyRotationEnabled: boolean | undefined; + rotationPeriodInDays: number | undefined; + multiRegion: boolean; + policy: string | undefined; + deletionWindowInDays: number; + tags: Record; + }, + never, + Providers +> {} + +/** + * A customer managed AWS KMS key. + * + * @section Creating Keys + * @example Symmetric Encryption Key + * ```typescript + * import * as KMS from "alchemy/AWS/KMS"; + * + * const key = yield* KMS.Key("AppKey", { + * description: "Application encryption key", + * enableKeyRotation: true, + * deletionWindowInDays: 7, + * }); + * ``` + * + * @section Key Policies + * @example Key with Inline Policy + * ```typescript + * const key = yield* KMS.Key("PolicyKey", { + * policy: JSON.stringify({ + * Version: "2012-10-17", + * Statement: [{ + * Effect: "Allow", + * Principal: { AWS: "arn:aws:iam::123456789012:root" }, + * Action: "kms:*", + * Resource: "*", + * }], + * }), + * }); + * ``` + */ +export const Key = Resource("AWS.KMS.Key"); + +const defaultKeyUsage = "ENCRYPT_DECRYPT" as const; +const defaultKeySpec = "SYMMETRIC_DEFAULT" as const; +const defaultDeletionWindowInDays = 30; + +export const KeyProvider = () => + Provider.succeed(Key, { + stables: ["keyId", "keyArn"], + list: () => + Effect.gen(function* () { + const keys = yield* kms.listKeys.pages({}).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.Keys ?? []) + .map((key) => key.KeyId) + .filter((keyId): keyId is string => keyId !== undefined), + ), + ), + ); + + const hydrated = yield* Effect.forEach( + keys, + (keyId) => + readKey({ + keyId, + deletionWindowInDays: defaultDeletionWindowInDays, + }), + { concurrency: 5 }, + ); + + return hydrated.filter( + (key): key is Key["Attributes"] => + key !== undefined && key.keyState !== "PendingDeletion", + ); + }), + read: Effect.fn(function* ({ id, olds = {}, output }) { + const state = output?.keyId + ? yield* readKey({ + keyId: output.keyId, + deletionWindowInDays: + output.deletionWindowInDays ?? + olds.deletionWindowInDays ?? + defaultDeletionWindowInDays, + }) + : yield* findKeyByAlchemyTags({ + id, + deletionWindowInDays: + olds.deletionWindowInDays ?? defaultDeletionWindowInDays, + }); + + if (!state) return undefined; + return (yield* hasAlchemyTags(id, state.tags)) ? state : Unowned(state); + }), + diff: Effect.fn(function* ({ news = {}, olds = {} }) { + if (!isResolved(news)) return; + if ( + (news.keyUsage ?? defaultKeyUsage) !== + (olds.keyUsage ?? defaultKeyUsage) || + (news.keySpec ?? defaultKeySpec) !== (olds.keySpec ?? defaultKeySpec) || + (news.multiRegion ?? false) !== (olds.multiRegion ?? false) + ) { + return { action: "replace" } as const; + } + }), + reconcile: Effect.fn(function* ({ id, news = {}, output, session }) { + const internalTags = yield* createInternalTags(id); + const desiredTags = { ...internalTags, ...news.tags }; + const keyUsage = news.keyUsage ?? defaultKeyUsage; + const keySpec = news.keySpec ?? defaultKeySpec; + const enabled = news.enabled ?? true; + const enableKeyRotation = news.enableKeyRotation ?? false; + const multiRegion = news.multiRegion ?? false; + const deletionWindowInDays = + news.deletionWindowInDays ?? defaultDeletionWindowInDays; + + let state = output?.keyId + ? yield* readKey({ keyId: output.keyId, deletionWindowInDays }) + : yield* findKeyByAlchemyTags({ id, deletionWindowInDays }); + + if (state?.keyState === "PendingDeletion") { + yield* kms + .cancelKeyDeletion({ KeyId: state.keyId }) + .pipe(Effect.catchTag("NotFoundException", () => Effect.void)); + state = yield* readKey({ keyId: state.keyId, deletionWindowInDays }); + } + + if (state === undefined) { + const created = yield* kms.createKey({ + Description: news.description, + KeyUsage: keyUsage, + KeySpec: keySpec, + Policy: news.policy, + BypassPolicyLockoutSafetyCheck: news.bypassPolicyLockoutSafetyCheck, + Tags: toKmsTags(desiredTags), + MultiRegion: multiRegion, + }); + const keyId = created.KeyMetadata?.KeyId; + if (!keyId) { + return yield* Effect.die(new Error("createKey returned no key ID")); + } + state = yield* readKey({ keyId, deletionWindowInDays }); + if (!state) { + return yield* Effect.die( + new Error(`failed to read created KMS key ${keyId}`), + ); + } + } + + const desiredDescription = news.description ?? ""; + if ((state.description ?? "") !== desiredDescription) { + yield* kms + .updateKeyDescription({ + KeyId: state.keyId, + Description: desiredDescription, + }) + .pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + } + + if (enabled && !state.enabled) { + yield* kms.enableKey({ KeyId: state.keyId }).pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + } + + if (news.policy !== undefined && !sameJson(state.policy, news.policy)) { + yield* kms + .putKeyPolicy({ + KeyId: state.keyId, + PolicyName: "default", + Policy: news.policy, + BypassPolicyLockoutSafetyCheck: news.bypassPolicyLockoutSafetyCheck, + }) + .pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + } + + if ( + enableKeyRotation && + (state.keyRotationEnabled !== true || + (news.rotationPeriodInDays !== undefined && + state.rotationPeriodInDays !== news.rotationPeriodInDays)) + ) { + yield* kms + .enableKeyRotation({ + KeyId: state.keyId, + RotationPeriodInDays: news.rotationPeriodInDays, + }) + .pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + } else if (!enableKeyRotation && state.keyRotationEnabled) { + yield* kms.disableKeyRotation({ KeyId: state.keyId }).pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + } + + if (!enabled && state.enabled) { + yield* kms.disableKey({ KeyId: state.keyId }).pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + } + + const observedTags = yield* listKeyTags(state.keyId); + const { removed, upsert } = diffTags(observedTags, desiredTags); + if (upsert.length > 0) { + yield* kms + .tagResource({ + KeyId: state.keyId, + Tags: toKmsTags( + Object.fromEntries(upsert.map((tag) => [tag.Key, tag.Value])), + ), + }) + .pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + } + if (removed.length > 0) { + yield* kms + .untagResource({ + KeyId: state.keyId, + TagKeys: removed, + }) + .pipe( + Effect.retry({ + while: isKmsEventuallyConsistent, + schedule: kmsRetrySchedule, + }), + ); + } + + const updated = yield* readConvergedKey({ + keyId: state.keyId, + deletionWindowInDays, + desiredDescription, + desiredEnabled: enabled, + desiredKeyRotationEnabled: enableKeyRotation, + desiredPolicy: news.policy, + desiredRotationPeriodInDays: news.rotationPeriodInDays, + desiredTags, + }); + + yield* session.note(updated.keyArn); + return updated; + }), + delete: Effect.fn(function* ({ output, session }) { + yield* kms + .scheduleKeyDeletion({ + KeyId: output.keyId, + PendingWindowInDays: output.deletionWindowInDays, + }) + .pipe( + Effect.catchTag("NotFoundException", () => Effect.void), + Effect.catchTag("KMSInvalidStateException", () => Effect.void), + ); + yield* session.note(`Scheduled KMS key deletion: ${output.keyId}`); + }), + }); + +const readKey = Effect.fn(function* ({ + keyId, + deletionWindowInDays, +}: { + keyId: string; + deletionWindowInDays: number; +}) { + const described = yield* kms + .describeKey({ KeyId: keyId }) + .pipe( + Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)), + ); + + const metadata = described?.KeyMetadata; + if (!metadata?.Arn) return undefined; + if (metadata.KeyManager !== undefined && metadata.KeyManager !== "CUSTOMER") { + return undefined; + } + + const [tags, rotation, policy] = yield* Effect.all( + [ + listKeyTags(metadata.KeyId), + readKeyRotation(metadata.KeyId), + readKeyPolicy(metadata.KeyId), + ], + { concurrency: "unbounded" }, + ); + + return toAttrs({ + metadata, + tags, + rotation, + policy, + deletionWindowInDays, + }); +}); + +const readConvergedKey = Effect.fn(function* ({ + keyId, + deletionWindowInDays, + desiredDescription, + desiredEnabled, + desiredKeyRotationEnabled, + desiredPolicy, + desiredRotationPeriodInDays, + desiredTags, +}: { + keyId: string; + deletionWindowInDays: number; + desiredDescription: string; + desiredEnabled: boolean; + desiredKeyRotationEnabled: boolean; + desiredPolicy: string | undefined; + desiredRotationPeriodInDays: number | undefined; + desiredTags: Record; +}) { + return yield* Effect.gen(function* () { + const key = yield* readKey({ keyId, deletionWindowInDays }); + if (!key) { + return yield* Effect.fail(new KmsKeyNotConverged()); + } + if ((key.description ?? "") !== desiredDescription) { + return yield* Effect.fail(new KmsKeyNotConverged()); + } + if (key.enabled !== desiredEnabled) { + return yield* Effect.fail(new KmsKeyNotConverged()); + } + if ( + !Object.entries(desiredTags).every( + ([name, value]) => key.tags[name] === value, + ) || + !Object.keys(key.tags).every( + (name) => desiredTags[name] === key.tags[name], + ) + ) { + return yield* Effect.fail(new KmsKeyNotConverged()); + } + if (desiredPolicy !== undefined && !sameJson(key.policy, desiredPolicy)) { + return yield* Effect.fail(new KmsKeyNotConverged()); + } + if (desiredEnabled) { + if (desiredKeyRotationEnabled && key.keyRotationEnabled !== true) { + return yield* Effect.fail(new KmsKeyNotConverged()); + } + if (!desiredKeyRotationEnabled && key.keyRotationEnabled === true) { + return yield* Effect.fail(new KmsKeyNotConverged()); + } + if ( + desiredKeyRotationEnabled && + desiredRotationPeriodInDays !== undefined && + key.rotationPeriodInDays !== desiredRotationPeriodInDays + ) { + return yield* Effect.fail(new KmsKeyNotConverged()); + } + } + return key; + }).pipe( + Effect.retry({ + while: (error: { _tag: string }) => + error._tag === "KmsKeyNotConverged" || isKmsEventuallyConsistent(error), + schedule: kmsRetrySchedule, + }), + ); +}); + +const findKeyByAlchemyTags = Effect.fn(function* ({ + id, + deletionWindowInDays, +}: { + id: string; + deletionWindowInDays: number; +}) { + const keys = yield* kms.listKeys.pages({}).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.Keys ?? []) + .map((key) => key.KeyId) + .filter((keyId): keyId is string => keyId !== undefined), + ), + ), + ); + + const candidates = yield* Effect.forEach( + keys, + (keyId) => readKey({ keyId, deletionWindowInDays }), + { concurrency: 5 }, + ); + + for (const candidate of candidates) { + if (candidate && (yield* hasAlchemyTags(id, candidate.tags))) { + return candidate; + } + } + + return undefined; +}); + +const listKeyTags = Effect.fn(function* (keyId: string) { + const pages = yield* kms.listResourceTags.pages({ KeyId: keyId }).pipe( + Stream.runCollect, + Effect.catchTag("NotFoundException", () => Effect.succeed([])), + ); + + return toTagRecord(Array.from(pages).flatMap((page) => page.Tags ?? [])); +}); + +const readKeyRotation = Effect.fn(function* (keyId: string) { + return yield* kms.getKeyRotationStatus({ KeyId: keyId }).pipe( + Effect.map((response) => ({ + enabled: response.KeyRotationEnabled, + periodInDays: response.RotationPeriodInDays, + })), + Effect.catchTag("UnsupportedOperationException", () => + Effect.succeed(undefined), + ), + Effect.catchTag("KMSInvalidStateException", () => + Effect.succeed(undefined), + ), + Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)), + ); +}); + +const readKeyPolicy = Effect.fn(function* (keyId: string) { + return yield* kms.getKeyPolicy({ KeyId: keyId, PolicyName: "default" }).pipe( + Effect.map((response) => response.Policy), + Effect.catchTag("UnsupportedOperationException", () => + Effect.succeed(undefined), + ), + Effect.catchTag("KMSInvalidStateException", () => + Effect.succeed(undefined), + ), + Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)), + ); +}); + +const toAttrs = ({ + metadata, + tags, + rotation, + policy, + deletionWindowInDays, +}: { + metadata: kms.KeyMetadata; + tags: Record; + rotation: { enabled?: boolean; periodInDays?: number } | undefined; + policy: string | undefined; + deletionWindowInDays: number; +}): Key["Attributes"] => ({ + keyId: metadata.KeyId, + keyArn: metadata.Arn as KeyArn, + description: metadata.Description, + keyUsage: metadata.KeyUsage ?? defaultKeyUsage, + keySpec: metadata.KeySpec ?? defaultKeySpec, + keyState: metadata.KeyState, + enabled: metadata.Enabled ?? false, + keyRotationEnabled: rotation?.enabled, + rotationPeriodInDays: rotation?.periodInDays, + multiRegion: metadata.MultiRegion ?? false, + policy, + deletionWindowInDays, + tags, +}); + +const toTagRecord = (tags: kms.Tag[] | undefined): Record => + Object.fromEntries( + (tags ?? []) + .filter( + (tag): tag is kms.Tag => + typeof tag.TagKey === "string" && typeof tag.TagValue === "string", + ) + .map((tag) => [tag.TagKey, tag.TagValue]), + ); + +const toKmsTags = (tags: Record): kms.Tag[] => + Object.entries(tags).map(([TagKey, TagValue]) => ({ TagKey, TagValue })); + +const sameJson = (left: string | undefined, right: string | undefined) => { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + try { + return ( + JSON.stringify(canonicalizeJson(JSON.parse(left))) === + JSON.stringify(canonicalizeJson(JSON.parse(right))) + ); + } catch { + return false; + } +}; + +const canonicalizeJson = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(canonicalizeJson); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalizeJson(item)]), + ); + } + return value; +}; + +const isKmsEventuallyConsistent = (error: { _tag: string }) => + error._tag === "DependencyTimeoutException" || + error._tag === "KMSInternalException" || + error._tag === "KMSInvalidStateException"; + +class KmsKeyNotConverged extends Error { + readonly _tag = "KmsKeyNotConverged"; +} + +const kmsRetrySchedule = Schedule.exponential(250).pipe( + Schedule.both(Schedule.recurs(7)), +); diff --git a/packages/alchemy/src/AWS/KMS/index.ts b/packages/alchemy/src/AWS/KMS/index.ts new file mode 100644 index 000000000..1f689d424 --- /dev/null +++ b/packages/alchemy/src/AWS/KMS/index.ts @@ -0,0 +1,15 @@ +export { + Alias, + AliasProvider, + type AliasArn, + type AliasName, +} from "./Alias.ts"; +export { + Key, + KeyProvider, + type KeyArn, + type KeyId, + type KeySpec, + type KeyState, + type KeyUsageType, +} from "./Key.ts"; diff --git a/packages/alchemy/src/AWS/Providers.ts b/packages/alchemy/src/AWS/Providers.ts index fa8cc332e..543e91ab5 100644 --- a/packages/alchemy/src/AWS/Providers.ts +++ b/packages/alchemy/src/AWS/Providers.ts @@ -40,6 +40,7 @@ import { Default as DefaultEnvironment } from "./Environment.ts"; import * as EventBridge from "./EventBridge/index.ts"; import * as IAM from "./IAM/index.ts"; import * as IdentityCenter from "./IdentityCenter/index.ts"; +import * as KMS from "./KMS/index.ts"; import * as Kinesis from "./Kinesis/index.ts"; import * as Lambda from "./Lambda/index.ts"; import * as Logs from "./Logs/index.ts"; @@ -215,6 +216,8 @@ export const providers = () => IdentityCenter.Group, IdentityCenter.Instance, IdentityCenter.PermissionSet, + KMS.Alias, + KMS.Key, Kinesis.DescribeAccountSettingsPolicy, Kinesis.DescribeLimitsPolicy, Kinesis.DescribeStreamConsumerPolicy, @@ -473,6 +476,8 @@ export const providers = () => IdentityCenter.GroupProvider(), IdentityCenter.InstanceProvider(), IdentityCenter.PermissionSetProvider(), + KMS.AliasProvider(), + KMS.KeyProvider(), Kinesis.DescribeAccountSettingsPolicyLive, Kinesis.DescribeLimitsPolicyLive, Kinesis.DescribeStreamConsumerPolicyLive, diff --git a/packages/alchemy/src/AWS/index.ts b/packages/alchemy/src/AWS/index.ts index 478b50a53..cf215fe10 100644 --- a/packages/alchemy/src/AWS/index.ts +++ b/packages/alchemy/src/AWS/index.ts @@ -21,6 +21,7 @@ export * as ELBv2 from "./ELBv2/index.ts"; export * as EventBridge from "./EventBridge/index.ts"; export * as IAM from "./IAM/index.ts"; export * as IdentityCenter from "./IdentityCenter/index.ts"; +export * as KMS from "./KMS/index.ts"; export * as Kinesis from "./Kinesis/index.ts"; export * as Lambda from "./Lambda/index.ts"; export * as Logs from "./Logs/index.ts"; diff --git a/packages/alchemy/test/AWS/KMS/Key.test.ts b/packages/alchemy/test/AWS/KMS/Key.test.ts new file mode 100644 index 000000000..674a33ef8 --- /dev/null +++ b/packages/alchemy/test/AWS/KMS/Key.test.ts @@ -0,0 +1,287 @@ +import * as AWS from "@/AWS"; +import { Alias, Key } from "@/AWS/KMS"; +import * as Provider from "@/Provider"; +import * as Test from "@/Test/Vitest"; +import * as KMS from "@distilled.cloud/aws/kms"; +import { describe, expect } from "@effect/vitest"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; + +const { test } = Test.make({ providers: AWS.providers() }); + +describe("AWS.KMS.Key", () => { + test.provider( + "creates, updates, aliases, and schedules deletion", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const key = yield* Key("ManagedKey", { + description: "alchemy kms smoke v1", + deletionWindowInDays: 7, + enableKeyRotation: true, + tags: { + Environment: "test", + }, + }); + const alias = yield* Alias("ManagedAlias", { + targetKeyId: key.keyId, + }); + return { alias, key }; + }), + ); + + const described = yield* KMS.describeKey({ + KeyId: initial.key.keyId, + }); + expect(described.KeyMetadata.KeyUsage).toEqual("ENCRYPT_DECRYPT"); + expect(described.KeyMetadata.KeySpec).toEqual("SYMMETRIC_DEFAULT"); + expect(described.KeyMetadata.Enabled).toEqual(true); + + const rotation = yield* KMS.getKeyRotationStatus({ + KeyId: initial.key.keyId, + }); + expect(rotation.KeyRotationEnabled).toEqual(true); + + const tags = yield* listTags(initial.key.keyId); + expect(tags.Environment).toEqual("test"); + + const aliasState = yield* getAlias(initial.alias.aliasName); + expect(aliasState?.TargetKeyId).toEqual(initial.key.keyId); + + const keyProvider = yield* Provider.findProvider(Key); + const aliasProvider = yield* Provider.findProvider(Alias); + yield* assertProvidersListResources({ + aliasName: initial.alias.aliasName, + aliasProvider, + keyId: initial.key.keyId, + keyProvider, + }); + + const updated = yield* stack.deploy( + Effect.gen(function* () { + const key = yield* Key("ManagedKey", { + deletionWindowInDays: 7, + description: "alchemy kms smoke v2", + enableKeyRotation: false, + enabled: false, + tags: { + Environment: "prod", + Team: "platform", + }, + }); + const replacementKey = yield* Key("ReplacementKey", { + deletionWindowInDays: 7, + description: "alchemy kms replacement", + }); + const alias = yield* Alias("ManagedAlias", { + targetKeyId: replacementKey.keyArn, + }); + return { alias, key, replacementKey }; + }), + ); + + yield* assertKeyMetadata({ + description: "alchemy kms smoke v2", + enabled: false, + keyId: updated.key.keyId, + }); + yield* assertKeyTags({ + keyId: updated.key.keyId, + tags: { + Environment: "prod", + Team: "platform", + }, + }); + yield* assertAliasTarget({ + aliasName: updated.alias.aliasName, + targetKeyId: updated.replacementKey.keyId, + }); + + yield* stack.destroy(); + + yield* assertAliasDeleted(updated.alias.aliasName); + yield* assertKeyPendingDeletion(updated.key.keyId); + yield* assertKeyPendingDeletion(updated.replacementKey.keyId); + }), + { timeout: 180_000 }, + ); + + class AliasStillExists extends Data.TaggedError("AliasStillExists") {} + class KeyNotPendingDeletion extends Data.TaggedError( + "KeyNotPendingDeletion", + ) {} + class ProviderListNotConverged extends Data.TaggedError( + "ProviderListNotConverged", + ) {} + class KeyMetadataNotConverged extends Data.TaggedError( + "KeyMetadataNotConverged", + ) {} + class KeyTagsNotConverged extends Data.TaggedError("KeyTagsNotConverged") {} + class AliasTargetNotConverged extends Data.TaggedError( + "AliasTargetNotConverged", + ) {} + + const assertKeyMetadata = Effect.fn(function* ({ + description, + enabled, + keyId, + }: { + description: string; + enabled: boolean; + keyId: string; + }) { + yield* Effect.gen(function* () { + const key = yield* KMS.describeKey({ KeyId: keyId }); + if ( + key.KeyMetadata.Description !== description || + key.KeyMetadata.Enabled !== enabled + ) { + return yield* Effect.fail(new KeyMetadataNotConverged()); + } + }).pipe( + Effect.retry({ + while: (error) => error._tag === "KeyMetadataNotConverged", + schedule: Schedule.exponential(100).pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + ); + }); + + const assertKeyTags = Effect.fn(function* ({ + keyId, + tags, + }: { + keyId: string; + tags: Record; + }) { + yield* Effect.gen(function* () { + const observed = yield* listTags(keyId); + if ( + !Object.entries(tags).every(([name, value]) => observed[name] === value) + ) { + return yield* Effect.fail(new KeyTagsNotConverged()); + } + }).pipe( + Effect.retry({ + while: (error) => error._tag === "KeyTagsNotConverged", + schedule: Schedule.exponential(100).pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + ); + }); + + const assertAliasTarget = Effect.fn(function* ({ + aliasName, + targetKeyId, + }: { + aliasName: string; + targetKeyId: string; + }) { + yield* Effect.gen(function* () { + const alias = yield* getAlias(aliasName); + if (alias?.TargetKeyId !== targetKeyId) { + return yield* Effect.fail(new AliasTargetNotConverged()); + } + }).pipe( + Effect.retry({ + while: (error) => error._tag === "AliasTargetNotConverged", + schedule: Schedule.exponential(100).pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + ); + }); + + const assertProvidersListResources = Effect.fn(function* ({ + aliasName, + aliasProvider, + keyId, + keyProvider, + }: { + aliasName: string; + aliasProvider: Provider.ProviderService; + keyId: string; + keyProvider: Provider.ProviderService; + }) { + yield* Effect.gen(function* () { + const [listedKeys, listedAliases] = yield* Effect.all( + [keyProvider.list(), aliasProvider.list()], + { concurrency: "unbounded" }, + ); + if (!listedKeys.some((key) => key.keyId === keyId)) { + return yield* Effect.fail(new ProviderListNotConverged()); + } + if (!listedAliases.some((alias) => alias.aliasName === aliasName)) { + return yield* Effect.fail(new ProviderListNotConverged()); + } + }).pipe( + Effect.retry({ + while: (error) => error._tag === "ProviderListNotConverged", + schedule: Schedule.exponential(100).pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + ); + }); + + const assertAliasDeleted = Effect.fn(function* (aliasName: string) { + yield* Effect.gen(function* () { + const alias = yield* getAlias(aliasName); + if (alias !== undefined) { + return yield* Effect.fail(new AliasStillExists()); + } + }).pipe( + Effect.retry({ + while: (error) => error._tag === "AliasStillExists", + schedule: Schedule.exponential(100).pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + ); + }); + + const assertKeyPendingDeletion = Effect.fn(function* (keyId: string) { + yield* KMS.describeKey({ KeyId: keyId }).pipe( + Effect.flatMap((response) => + response.KeyMetadata.KeyState === "PendingDeletion" + ? Effect.void + : Effect.fail(new KeyNotPendingDeletion()), + ), + Effect.retry({ + while: (error) => error._tag === "KeyNotPendingDeletion", + schedule: Schedule.exponential(100).pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + ); + }); + + const getAlias = Effect.fn(function* (aliasName: string) { + const aliases = yield* KMS.listAliases.pages({}).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => page.Aliases ?? []), + ), + ); + + return aliases.find((alias) => alias.AliasName === aliasName); + }); + + const listTags = Effect.fn(function* (keyId: string) { + const tags = yield* KMS.listResourceTags.pages({ KeyId: keyId }).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => page.Tags ?? []), + ), + ); + + return Object.fromEntries(tags.map((tag) => [tag.TagKey, tag.TagValue])); + }); +}); From 4e41234297e62e32e398047b96bebdef8f1c186b Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Wed, 17 Jun 2026 22:43:39 -0700 Subject: [PATCH 2/2] improve tests and adoption --- .vscode/settings.json | 3 +- packages/alchemy/src/AWS/KMS/Alias.ts | 2 +- packages/alchemy/src/AWS/KMS/Key.ts | 76 ++------ packages/alchemy/test/AWS/KMS/Key.test.ts | 214 ++++++++++++++++++++-- 4 files changed, 217 insertions(+), 78 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4b6937db0..1fb40712b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,6 @@ { "diffEditor.renderSideBySide": false, "js/ts.experimental.useTsgo": true, - "typescript.experimental.useTsgo": false, "typescript.enablePromptUseWorkspaceTsdk": true, "typescript.preferences.importModuleSpecifier": "relative", "typescript.preferences.importModuleSpecifierEnding": "js", @@ -66,4 +65,4 @@ "editor.defaultFormatter": "oxc.oxc-vscode" }, "oxc.enable.oxfmt": true -} +} \ No newline at end of file diff --git a/packages/alchemy/src/AWS/KMS/Alias.ts b/packages/alchemy/src/AWS/KMS/Alias.ts index 5e5dc94f5..1a929fdd8 100644 --- a/packages/alchemy/src/AWS/KMS/Alias.ts +++ b/packages/alchemy/src/AWS/KMS/Alias.ts @@ -197,7 +197,7 @@ const aliasArn = Effect.fn(function* (aliasName: AliasName) { const resolveTargetKeyId = Effect.fn(function* (targetKeyId: string) { const described = yield* kms.describeKey({ KeyId: targetKeyId }); - return described.KeyMetadata.KeyId; + return described.KeyMetadata?.KeyId!; }); const isCustomerAlias = ( diff --git a/packages/alchemy/src/AWS/KMS/Key.ts b/packages/alchemy/src/AWS/KMS/Key.ts index a9d47e196..4bfa02117 100644 --- a/packages/alchemy/src/AWS/KMS/Key.ts +++ b/packages/alchemy/src/AWS/KMS/Key.ts @@ -2,11 +2,10 @@ import * as kms from "@distilled.cloud/aws/kms"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; -import { Unowned } from "../../AdoptPolicy.ts"; import { isResolved } from "../../Diff.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; -import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; +import { createInternalTags, diffTags } from "../../Tags.ts"; import type { AccountID } from "../Environment.ts"; import type { Providers } from "../Providers.ts"; import type { RegionID } from "../Region.ts"; @@ -162,23 +161,19 @@ export const KeyProvider = () => key !== undefined && key.keyState !== "PendingDeletion", ); }), - read: Effect.fn(function* ({ id, olds = {}, output }) { - const state = output?.keyId - ? yield* readKey({ - keyId: output.keyId, - deletionWindowInDays: - output.deletionWindowInDays ?? - olds.deletionWindowInDays ?? - defaultDeletionWindowInDays, - }) - : yield* findKeyByAlchemyTags({ - id, - deletionWindowInDays: - olds.deletionWindowInDays ?? defaultDeletionWindowInDays, - }); - - if (!state) return undefined; - return (yield* hasAlchemyTags(id, state.tags)) ? state : Unowned(state); + read: Effect.fn(function* ({ olds = {}, output }) { + // KMS keys have no stable user-assignable identity — only a + // cloud-generated keyId — so there is nothing to adopt. We only refresh + // a key we already own (via output.keyId); with no prior output the + // engine treats this as a greenfield create and mints a fresh key. + if (!output?.keyId) return undefined; + return yield* readKey({ + keyId: output.keyId, + deletionWindowInDays: + output.deletionWindowInDays ?? + olds.deletionWindowInDays ?? + defaultDeletionWindowInDays, + }); }), diff: Effect.fn(function* ({ news = {}, olds = {} }) { if (!isResolved(news)) return; @@ -202,9 +197,14 @@ export const KeyProvider = () => const deletionWindowInDays = news.deletionWindowInDays ?? defaultDeletionWindowInDays; + // Observe via the cached identifier only — no tag-based discovery. On a + // create or replacement the engine calls reconcile with + // `output === undefined`, so we fall through to `createKey` and mint a + // fresh key rather than adopting an unrelated one (which would collapse a + // replacement into a no-op). let state = output?.keyId ? yield* readKey({ keyId: output.keyId, deletionWindowInDays }) - : yield* findKeyByAlchemyTags({ id, deletionWindowInDays }); + : undefined; if (state?.keyState === "PendingDeletion") { yield* kms @@ -474,39 +474,6 @@ const readConvergedKey = Effect.fn(function* ({ ); }); -const findKeyByAlchemyTags = Effect.fn(function* ({ - id, - deletionWindowInDays, -}: { - id: string; - deletionWindowInDays: number; -}) { - const keys = yield* kms.listKeys.pages({}).pipe( - Stream.runCollect, - Effect.map((chunk) => - Array.from(chunk).flatMap((page) => - (page.Keys ?? []) - .map((key) => key.KeyId) - .filter((keyId): keyId is string => keyId !== undefined), - ), - ), - ); - - const candidates = yield* Effect.forEach( - keys, - (keyId) => readKey({ keyId, deletionWindowInDays }), - { concurrency: 5 }, - ); - - for (const candidate of candidates) { - if (candidate && (yield* hasAlchemyTags(id, candidate.tags))) { - return candidate; - } - } - - return undefined; -}); - const listKeyTags = Effect.fn(function* (keyId: string) { const pages = yield* kms.listResourceTags.pages({ KeyId: keyId }).pipe( Stream.runCollect, @@ -535,9 +502,6 @@ const readKeyRotation = Effect.fn(function* (keyId: string) { const readKeyPolicy = Effect.fn(function* (keyId: string) { return yield* kms.getKeyPolicy({ KeyId: keyId, PolicyName: "default" }).pipe( Effect.map((response) => response.Policy), - Effect.catchTag("UnsupportedOperationException", () => - Effect.succeed(undefined), - ), Effect.catchTag("KMSInvalidStateException", () => Effect.succeed(undefined), ), diff --git a/packages/alchemy/test/AWS/KMS/Key.test.ts b/packages/alchemy/test/AWS/KMS/Key.test.ts index 674a33ef8..54c473629 100644 --- a/packages/alchemy/test/AWS/KMS/Key.test.ts +++ b/packages/alchemy/test/AWS/KMS/Key.test.ts @@ -1,4 +1,5 @@ import * as AWS from "@/AWS"; +import { AWSEnvironment } from "@/AWS/Environment.ts"; import { Alias, Key } from "@/AWS/KMS"; import * as Provider from "@/Provider"; import * as Test from "@/Test/Vitest"; @@ -13,11 +14,13 @@ const { test } = Test.make({ providers: AWS.providers() }); describe("AWS.KMS.Key", () => { test.provider( - "creates, updates, aliases, and schedules deletion", + "reconciles mutable key settings across updates without replacement", (stack) => Effect.gen(function* () { yield* stack.destroy(); + const { accountId } = yield* AWSEnvironment.current; + const initial = yield* stack.deploy( Effect.gen(function* () { const key = yield* Key("ManagedKey", { @@ -26,6 +29,7 @@ describe("AWS.KMS.Key", () => { enableKeyRotation: true, tags: { Environment: "test", + Owner: "alice", }, }); const alias = yield* Alias("ManagedAlias", { @@ -38,17 +42,18 @@ describe("AWS.KMS.Key", () => { const described = yield* KMS.describeKey({ KeyId: initial.key.keyId, }); - expect(described.KeyMetadata.KeyUsage).toEqual("ENCRYPT_DECRYPT"); - expect(described.KeyMetadata.KeySpec).toEqual("SYMMETRIC_DEFAULT"); - expect(described.KeyMetadata.Enabled).toEqual(true); + expect(described.KeyMetadata!.KeyUsage).toEqual("ENCRYPT_DECRYPT"); + expect(described.KeyMetadata!.KeySpec).toEqual("SYMMETRIC_DEFAULT"); + expect(described.KeyMetadata!.Enabled).toEqual(true); const rotation = yield* KMS.getKeyRotationStatus({ KeyId: initial.key.keyId, }); expect(rotation.KeyRotationEnabled).toEqual(true); - const tags = yield* listTags(initial.key.keyId); - expect(tags.Environment).toEqual("test"); + const initialTags = yield* listTags(initial.key.keyId); + expect(initialTags.Environment).toEqual("test"); + expect(initialTags.Owner).toEqual("alice"); const aliasState = yield* getAlias(initial.alias.aliasName); expect(aliasState?.TargetKeyId).toEqual(initial.key.keyId); @@ -62,6 +67,20 @@ describe("AWS.KMS.Key", () => { keyProvider, }); + const policy = JSON.stringify({ + Version: "2012-10-17", + Id: "alchemy-test-policy", + Statement: [ + { + Sid: "EnableRootPermissions", + Effect: "Allow", + Principal: { AWS: `arn:aws:iam::${accountId}:root` }, + Action: "kms:*", + Resource: "*", + }, + ], + }); + const updated = yield* stack.deploy( Effect.gen(function* () { const key = yield* Key("ManagedKey", { @@ -69,22 +88,23 @@ describe("AWS.KMS.Key", () => { description: "alchemy kms smoke v2", enableKeyRotation: false, enabled: false, + bypassPolicyLockoutSafetyCheck: true, + policy, tags: { Environment: "prod", Team: "platform", }, }); - const replacementKey = yield* Key("ReplacementKey", { - deletionWindowInDays: 7, - description: "alchemy kms replacement", - }); const alias = yield* Alias("ManagedAlias", { - targetKeyId: replacementKey.keyArn, + targetKeyId: key.keyId, }); - return { alias, key, replacementKey }; + return { alias, key }; }), ); + // No replacement: a settings-only update keeps the same physical key. + expect(updated.key.keyId).toEqual(initial.key.keyId); + yield* assertKeyMetadata({ description: "alchemy kms smoke v2", enabled: false, @@ -97,16 +117,172 @@ describe("AWS.KMS.Key", () => { Team: "platform", }, }); - yield* assertAliasTarget({ - aliasName: updated.alias.aliasName, - targetKeyId: updated.replacementKey.keyId, + + // The removed `Owner` tag must no longer be present (untag path). + const updatedTags = yield* listTags(updated.key.keyId); + expect(updatedTags.Owner).toBeUndefined(); + + // Rotation must be disabled after the update. + const updatedRotation = yield* KMS.getKeyRotationStatus({ + KeyId: updated.key.keyId, }); + expect(updatedRotation.KeyRotationEnabled).toEqual(false); + + // The inline policy must have been applied. + const appliedPolicy = yield* KMS.getKeyPolicy({ + KeyId: updated.key.keyId, + PolicyName: "default", + }); + expect(appliedPolicy.Policy).toContain("alchemy-test-policy"); yield* stack.destroy(); yield* assertAliasDeleted(updated.alias.aliasName); yield* assertKeyPendingDeletion(updated.key.keyId); - yield* assertKeyPendingDeletion(updated.replacementKey.keyId); + }), + { timeout: 180_000 }, + ); + + test.provider( + "replaces the key when keySpec changes", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // Set keySpec explicitly so the diff against the next deploy is + // well-defined. The physical key may be adopted from a prior run's + // pending-deletion key (KMS keys can't be hard-deleted), so we don't + // assert its keySpec here — only the replacement behavior below. + const initial = yield* stack.deploy( + Effect.gen(function* () { + const key = yield* Key("ReplaceKey", { + description: "alchemy kms replace v1", + deletionWindowInDays: 7, + keySpec: "SYMMETRIC_DEFAULT", + keyUsage: "ENCRYPT_DECRYPT", + }); + return { key }; + }), + ); + + const replaced = yield* stack.deploy( + Effect.gen(function* () { + const key = yield* Key("ReplaceKey", { + description: "alchemy kms replace v2", + deletionWindowInDays: 7, + keySpec: "RSA_2048", + keyUsage: "ENCRYPT_DECRYPT", + }); + return { key }; + }), + ); + + // A keySpec change forces a replacement: a brand-new physical key. + expect(replaced.key.keyId).not.toEqual(initial.key.keyId); + + const replacedDescribe = yield* KMS.describeKey({ + KeyId: replaced.key.keyId, + }); + expect(replacedDescribe.KeyMetadata!.KeySpec).toEqual("RSA_2048"); + + // The old key must have been scheduled for deletion by the replacement. + yield* assertKeyPendingDeletion(initial.key.keyId); + + yield* stack.destroy(); + + yield* assertKeyPendingDeletion(replaced.key.keyId); + }), + { timeout: 180_000 }, + ); + + const aliasNameA = "alias/alchemy-test-kms-rename-a" as const; + const aliasNameB = "alias/alchemy-test-kms-rename-b" as const; + + test.provider( + "retargets an alias in place, then replaces it on rename", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const keyA = yield* Key("AliasKeyA", { + description: "alchemy kms alias target A", + deletionWindowInDays: 7, + }); + const keyB = yield* Key("AliasKeyB", { + description: "alchemy kms alias target B", + deletionWindowInDays: 7, + }); + const alias = yield* Alias("RenamableAlias", { + aliasName: aliasNameA, + targetKeyId: keyA.keyId, + }); + return { alias, keyA, keyB }; + }), + ); + + expect(initial.alias.aliasName).toEqual(aliasNameA); + yield* assertAliasTarget({ + aliasName: aliasNameA, + targetKeyId: initial.keyA.keyId, + }); + + // Retarget the alias to key B. Same alias name => updateAlias, no replace. + const retargeted = yield* stack.deploy( + Effect.gen(function* () { + const keyA = yield* Key("AliasKeyA", { + description: "alchemy kms alias target A", + deletionWindowInDays: 7, + }); + const keyB = yield* Key("AliasKeyB", { + description: "alchemy kms alias target B", + deletionWindowInDays: 7, + }); + const alias = yield* Alias("RenamableAlias", { + aliasName: aliasNameA, + targetKeyId: keyB.keyId, + }); + return { alias, keyA, keyB }; + }), + ); + + expect(retargeted.alias.aliasName).toEqual(aliasNameA); + yield* assertAliasTarget({ + aliasName: aliasNameA, + targetKeyId: retargeted.keyB.keyId, + }); + + // Rename the alias. A name change forces a replacement: a new alias is + // created and the old one is deleted. + const renamed = yield* stack.deploy( + Effect.gen(function* () { + const keyA = yield* Key("AliasKeyA", { + description: "alchemy kms alias target A", + deletionWindowInDays: 7, + }); + const keyB = yield* Key("AliasKeyB", { + description: "alchemy kms alias target B", + deletionWindowInDays: 7, + }); + const alias = yield* Alias("RenamableAlias", { + aliasName: aliasNameB, + targetKeyId: keyB.keyId, + }); + return { alias, keyA, keyB }; + }), + ); + + expect(renamed.alias.aliasName).toEqual(aliasNameB); + yield* assertAliasTarget({ + aliasName: aliasNameB, + targetKeyId: renamed.keyB.keyId, + }); + yield* assertAliasDeleted(aliasNameA); + + yield* stack.destroy(); + + yield* assertAliasDeleted(aliasNameB); }), { timeout: 180_000 }, ); @@ -138,8 +314,8 @@ describe("AWS.KMS.Key", () => { yield* Effect.gen(function* () { const key = yield* KMS.describeKey({ KeyId: keyId }); if ( - key.KeyMetadata.Description !== description || - key.KeyMetadata.Enabled !== enabled + key.KeyMetadata!.Description !== description || + key.KeyMetadata!.Enabled !== enabled ) { return yield* Effect.fail(new KeyMetadataNotConverged()); } @@ -250,7 +426,7 @@ describe("AWS.KMS.Key", () => { const assertKeyPendingDeletion = Effect.fn(function* (keyId: string) { yield* KMS.describeKey({ KeyId: keyId }).pipe( Effect.flatMap((response) => - response.KeyMetadata.KeyState === "PendingDeletion" + response.KeyMetadata!.KeyState === "PendingDeletion" ? Effect.void : Effect.fail(new KeyNotPendingDeletion()), ),