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 new file mode 100644 index 000000000..1a929fdd8 --- /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..4bfa02117 --- /dev/null +++ b/packages/alchemy/src/AWS/KMS/Key.ts @@ -0,0 +1,591 @@ +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 { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.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"; + +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* ({ 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; + 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; + + // 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 }) + : undefined; + + 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 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("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..54c473629 --- /dev/null +++ b/packages/alchemy/test/AWS/KMS/Key.test.ts @@ -0,0 +1,463 @@ +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"; +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( + "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", { + description: "alchemy kms smoke v1", + deletionWindowInDays: 7, + enableKeyRotation: true, + tags: { + Environment: "test", + Owner: "alice", + }, + }); + 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 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); + + 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 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", { + deletionWindowInDays: 7, + description: "alchemy kms smoke v2", + enableKeyRotation: false, + enabled: false, + bypassPolicyLockoutSafetyCheck: true, + policy, + tags: { + Environment: "prod", + Team: "platform", + }, + }); + const alias = yield* Alias("ManagedAlias", { + targetKeyId: key.keyId, + }); + 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, + keyId: updated.key.keyId, + }); + yield* assertKeyTags({ + keyId: updated.key.keyId, + tags: { + Environment: "prod", + Team: "platform", + }, + }); + + // 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); + }), + { 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 }, + ); + + 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])); + }); +});