1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import { Alias, Key } from "@/AWS/KMS";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as KMS from "@distilled.cloud/aws/kms";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15describe(
16 "AWS.KMS.Key",
17 { tags: ["provider:aws", "provider:aws:kms", "live"] },
18 () => {
19 test.provider(
20 "reconciles mutable key settings across updates without replacement",
21 (stack) =>
22 Effect.gen(function* () {
23 yield* stack.destroy();
24
25 const { accountId } = yield* AWSEnvironment.current;
26
27 const initial = yield* stack.deploy(
28 Effect.gen(function* () {
29 const key = yield* Key("ManagedKey", {
30 description: "alchemy kms smoke v1",
31 // Duration.Input props: the provider converts these to whole
32 // wire days (7 and 90) — asserted out-of-band below.
33 deletionWindow: "7 days",
34 enableKeyRotation: true,
35 rotationPeriod: "90 days",
36 tags: {
37 Environment: "test",
38 Owner: "alice",
39 },
40 });
41 const alias = yield* Alias("ManagedAlias", {
42 targetKeyId: key.keyId,
43 });
44 return { alias, key };
45 }),
46 );
47
48 const described = yield* KMS.describeKey({
49 KeyId: initial.key.keyId,
50 });
51 expect(described.KeyMetadata!.KeyUsage).toEqual("ENCRYPT_DECRYPT");
52 expect(described.KeyMetadata!.KeySpec).toEqual("SYMMETRIC_DEFAULT");
53 expect(described.KeyMetadata!.Enabled).toEqual(true);
54
55 const rotation = yield* KMS.getKeyRotationStatus({
56 KeyId: initial.key.keyId,
57 });
58 expect(rotation.KeyRotationEnabled).toEqual(true);
59 // `rotationPeriod: "90 days"` (Duration.Input) must reach the
60 // wire as the whole number 90.
61 expect(rotation.RotationPeriodInDays).toEqual(90);
62 expect(initial.key.rotationPeriodInDays).toEqual(90);
63
64 const initialTags = yield* listTags(initial.key.keyId);
65 expect(initialTags.Environment).toEqual("test");
66 expect(initialTags.Owner).toEqual("alice");
67
68 const aliasState = yield* getAlias(initial.alias.aliasName);
69 expect(aliasState?.TargetKeyId).toEqual(initial.key.keyId);
70
71 const keyProvider = yield* Provider.findProvider(Key);
72 const aliasProvider = yield* Provider.findProvider(Alias);
73 yield* assertProvidersListResources({
74 aliasName: initial.alias.aliasName,
75 aliasProvider,
76 keyId: initial.key.keyId,
77 keyProvider,
78 });
79
80 // Typed PolicyDocument (not a JSON string) — proves the structured
81 // form deploys and, below, re-deploys as a no-op.
82 const policy: AWS.IAM.PolicyDocument = {
83 Version: "2012-10-17",
84 Statement: [
85 {
86 Sid: "EnableRootPermissions",
87 Effect: "Allow",
88 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
89 Action: ["kms:*"],
90 Resource: "*",
91 },
92 ],
93 };
94
95 const updated = yield* stack.deploy(
96 Effect.gen(function* () {
97 const key = yield* Key("ManagedKey", {
98 deletionWindow: "7 days",
99 description: "alchemy kms smoke v2",
100 enableKeyRotation: false,
101 enabled: false,
102 bypassPolicyLockoutSafetyCheck: true,
103 policy,
104 tags: {
105 Environment: "prod",
106 Team: "platform",
107 },
108 });
109 const alias = yield* Alias("ManagedAlias", {
110 targetKeyId: key.keyId,
111 });
112 return { alias, key };
113 }),
114 );
115
116 // No replacement: a settings-only update keeps the same physical key.
117 expect(updated.key.keyId).toEqual(initial.key.keyId);
118
119 yield* assertKeyMetadata({
120 description: "alchemy kms smoke v2",
121 enabled: false,
122 keyId: updated.key.keyId,
123 });
124 yield* assertKeyTags({
125 keyId: updated.key.keyId,
126 tags: {
127 Environment: "prod",
128 Team: "platform",
129 },
130 });
131
132 // The removed `Owner` tag must no longer be present (untag path).
133 const updatedTags = yield* listTags(updated.key.keyId);
134 expect(updatedTags.Owner).toBeUndefined();
135
136 // Rotation must be disabled after the update.
137 const updatedRotation = yield* KMS.getKeyRotationStatus({
138 KeyId: updated.key.keyId,
139 });
140 expect(updatedRotation.KeyRotationEnabled).toEqual(false);
141
142 // The inline (PolicyDocument-valued) policy must have been applied.
143 const appliedPolicy = yield* KMS.getKeyPolicy({
144 KeyId: updated.key.keyId,
145 PolicyName: "default",
146 });
147 expect(appliedPolicy.Policy).toContain("EnableRootPermissions");
148
149 // Re-deploying the identical PolicyDocument is a clean no-op: same
150 // physical key, policy still in place (the provider's normalized
151 // drift comparison must treat AWS's pretty-printed policy JSON as
152 // equal to the synthesized document).
153 const noop = yield* stack.deploy(
154 Effect.gen(function* () {
155 const key = yield* Key("ManagedKey", {
156 deletionWindow: "7 days",
157 description: "alchemy kms smoke v2",
158 enableKeyRotation: false,
159 enabled: false,
160 bypassPolicyLockoutSafetyCheck: true,
161 policy,
162 tags: {
163 Environment: "prod",
164 Team: "platform",
165 },
166 });
167 const alias = yield* Alias("ManagedAlias", {
168 targetKeyId: key.keyId,
169 });
170 return { alias, key };
171 }),
172 );
173 expect(noop.key.keyId).toEqual(updated.key.keyId);
174 const noopPolicy = yield* KMS.getKeyPolicy({
175 KeyId: noop.key.keyId,
176 PolicyName: "default",
177 });
178 expect(noopPolicy.Policy).toContain("EnableRootPermissions");
179
180 yield* stack.destroy();
181
182 yield* assertAliasDeleted(updated.alias.aliasName);
183 yield* assertKeyPendingDeletion(updated.key.keyId);
184 }),
185 { timeout: 120_000 },
186 );
187
188 test.provider(
189 "replaces the key when keySpec changes",
190 (stack) =>
191 Effect.gen(function* () {
192 yield* stack.destroy();
193
194 // Set keySpec explicitly so the diff against the next deploy is
195 // well-defined. The physical key may be adopted from a prior run's
196 // pending-deletion key (KMS keys can't be hard-deleted), so we don't
197 // assert its keySpec here — only the replacement behavior below.
198 const initial = yield* stack.deploy(
199 Effect.gen(function* () {
200 const key = yield* Key("ReplaceKey", {
201 description: "alchemy kms replace v1",
202 deletionWindow: "7 days",
203 keySpec: "SYMMETRIC_DEFAULT",
204 keyUsage: "ENCRYPT_DECRYPT",
205 });
206 return { key };
207 }),
208 );
209
210 const replaced = yield* stack.deploy(
211 Effect.gen(function* () {
212 const key = yield* Key("ReplaceKey", {
213 description: "alchemy kms replace v2",
214 deletionWindow: "7 days",
215 keySpec: "RSA_2048",
216 keyUsage: "ENCRYPT_DECRYPT",
217 });
218 return { key };
219 }),
220 );
221
222 // A keySpec change forces a replacement: a brand-new physical key.
223 expect(replaced.key.keyId).not.toEqual(initial.key.keyId);
224
225 const replacedDescribe = yield* KMS.describeKey({
226 KeyId: replaced.key.keyId,
227 });
228 expect(replacedDescribe.KeyMetadata!.KeySpec).toEqual("RSA_2048");
229
230 // The old key must have been scheduled for deletion by the replacement.
231 yield* assertKeyPendingDeletion(initial.key.keyId);
232
233 yield* stack.destroy();
234
235 yield* assertKeyPendingDeletion(replaced.key.keyId);
236 }),
237 { timeout: 120_000 },
238 );
239
240 const aliasNameA = "alias/alchemy-test-kms-rename-a" as const;
241 const aliasNameB = "alias/alchemy-test-kms-rename-b" as const;
242
243 test.provider(
244 "retargets an alias in place, then replaces it on rename",
245 (stack) =>
246 Effect.gen(function* () {
247 yield* stack.destroy();
248
249 const initial = yield* stack.deploy(
250 Effect.gen(function* () {
251 const keyA = yield* Key("AliasKeyA", {
252 description: "alchemy kms alias target A",
253 deletionWindow: "7 days",
254 });
255 const keyB = yield* Key("AliasKeyB", {
256 description: "alchemy kms alias target B",
257 deletionWindow: "7 days",
258 });
259 const alias = yield* Alias("RenamableAlias", {
260 aliasName: aliasNameA,
261 targetKeyId: keyA.keyId,
262 });
263 return { alias, keyA, keyB };
264 }),
265 );
266
267 expect(initial.alias.aliasName).toEqual(aliasNameA);
268 yield* assertAliasTarget({
269 aliasName: aliasNameA,
270 targetKeyId: initial.keyA.keyId,
271 });
272
273 // Retarget the alias to key B. Same alias name => updateAlias, no replace.
274 const retargeted = yield* stack.deploy(
275 Effect.gen(function* () {
276 const keyA = yield* Key("AliasKeyA", {
277 description: "alchemy kms alias target A",
278 deletionWindow: "7 days",
279 });
280 const keyB = yield* Key("AliasKeyB", {
281 description: "alchemy kms alias target B",
282 deletionWindow: "7 days",
283 });
284 const alias = yield* Alias("RenamableAlias", {
285 aliasName: aliasNameA,
286 targetKeyId: keyB.keyId,
287 });
288 return { alias, keyA, keyB };
289 }),
290 );
291
292 expect(retargeted.alias.aliasName).toEqual(aliasNameA);
293 yield* assertAliasTarget({
294 aliasName: aliasNameA,
295 targetKeyId: retargeted.keyB.keyId,
296 });
297
298 // Rename the alias. A name change forces a replacement: a new alias is
299 // created and the old one is deleted.
300 const renamed = yield* stack.deploy(
301 Effect.gen(function* () {
302 const keyA = yield* Key("AliasKeyA", {
303 description: "alchemy kms alias target A",
304 deletionWindow: "7 days",
305 });
306 const keyB = yield* Key("AliasKeyB", {
307 description: "alchemy kms alias target B",
308 deletionWindow: "7 days",
309 });
310 const alias = yield* Alias("RenamableAlias", {
311 aliasName: aliasNameB,
312 targetKeyId: keyB.keyId,
313 });
314 return { alias, keyA, keyB };
315 }),
316 );
317
318 expect(renamed.alias.aliasName).toEqual(aliasNameB);
319 yield* assertAliasTarget({
320 aliasName: aliasNameB,
321 targetKeyId: renamed.keyB.keyId,
322 });
323 yield* assertAliasDeleted(aliasNameA);
324
325 yield* stack.destroy();
326
327 yield* assertAliasDeleted(aliasNameB);
328 }),
329 { timeout: 120_000 },
330 );
331
332 class AliasStillExists extends Data.TaggedError("AliasStillExists") {}
333 class KeyNotPendingDeletion extends Data.TaggedError(
334 "KeyNotPendingDeletion",
335 ) {}
336 class ProviderListNotConverged extends Data.TaggedError(
337 "ProviderListNotConverged",
338 ) {}
339 class KeyMetadataNotConverged extends Data.TaggedError(
340 "KeyMetadataNotConverged",
341 ) {}
342 class KeyTagsNotConverged extends Data.TaggedError("KeyTagsNotConverged") {}
343 class AliasTargetNotConverged extends Data.TaggedError(
344 "AliasTargetNotConverged",
345 ) {}
346
347 const assertKeyMetadata = Effect.fn(function* ({
348 description,
349 enabled,
350 keyId,
351 }: {
352 description: string;
353 enabled: boolean;
354 keyId: string;
355 }) {
356 yield* Effect.gen(function* () {
357 const key = yield* KMS.describeKey({ KeyId: keyId });
358 if (
359 key.KeyMetadata!.Description !== description ||
360 key.KeyMetadata!.Enabled !== enabled
361 ) {
362 return yield* Effect.fail(new KeyMetadataNotConverged());
363 }
364 }).pipe(
365 Effect.retry({
366 while: (error) => error._tag === "KeyMetadataNotConverged",
367 schedule: Schedule.max([
368 Schedule.exponential(100),
369 Schedule.recurs(8),
370 ]),
371 }),
372 );
373 });
374
375 const assertKeyTags = Effect.fn(function* ({
376 keyId,
377 tags,
378 }: {
379 keyId: string;
380 tags: Record<string, string>;
381 }) {
382 yield* Effect.gen(function* () {
383 const observed = yield* listTags(keyId);
384 if (
385 !Object.entries(tags).every(
386 ([name, value]) => observed[name] === value,
387 )
388 ) {
389 return yield* Effect.fail(new KeyTagsNotConverged());
390 }
391 }).pipe(
392 Effect.retry({
393 while: (error) => error._tag === "KeyTagsNotConverged",
394 schedule: Schedule.max([
395 Schedule.exponential(100),
396 Schedule.recurs(8),
397 ]),
398 }),
399 );
400 });
401
402 const assertAliasTarget = Effect.fn(function* ({
403 aliasName,
404 targetKeyId,
405 }: {
406 aliasName: string;
407 targetKeyId: string;
408 }) {
409 yield* Effect.gen(function* () {
410 const alias = yield* getAlias(aliasName);
411 if (alias?.TargetKeyId !== targetKeyId) {
412 return yield* Effect.fail(new AliasTargetNotConverged());
413 }
414 }).pipe(
415 Effect.retry({
416 while: (error) => error._tag === "AliasTargetNotConverged",
417 schedule: Schedule.max([
418 Schedule.exponential(100),
419 Schedule.recurs(8),
420 ]),
421 }),
422 );
423 });
424
425 const assertProvidersListResources = Effect.fn(function* ({
426 aliasName,
427 aliasProvider,
428 keyId,
429 keyProvider,
430 }: {
431 aliasName: string;
432 aliasProvider: Provider.ProviderService<Alias>;
433 keyId: string;
434 keyProvider: Provider.ProviderService<Key>;
435 }) {
436 yield* Effect.gen(function* () {
437 const [listedKeys, listedAliases] = yield* Effect.all(
438 [keyProvider.list(), aliasProvider.list()],
439 { concurrency: "unbounded" },
440 );
441 if (!listedKeys.some((key) => key.keyId === keyId)) {
442 return yield* Effect.fail(new ProviderListNotConverged());
443 }
444 if (!listedAliases.some((alias) => alias.aliasName === aliasName)) {
445 return yield* Effect.fail(new ProviderListNotConverged());
446 }
447 }).pipe(
448 Effect.retry({
449 while: (error) => error._tag === "ProviderListNotConverged",
450 schedule: Schedule.max([
451 Schedule.exponential(100),
452 Schedule.recurs(8),
453 ]),
454 }),
455 );
456 });
457
458 const assertAliasDeleted = Effect.fn(function* (aliasName: string) {
459 yield* Effect.gen(function* () {
460 const alias = yield* getAlias(aliasName);
461 if (alias !== undefined) {
462 return yield* Effect.fail(new AliasStillExists());
463 }
464 }).pipe(
465 Effect.retry({
466 while: (error) => error._tag === "AliasStillExists",
467 schedule: Schedule.max([
468 Schedule.exponential(100),
469 Schedule.recurs(8),
470 ]),
471 }),
472 );
473 });
474
475 const assertKeyPendingDeletion = Effect.fn(function* (keyId: string) {
476 const metadata = yield* KMS.describeKey({ KeyId: keyId }).pipe(
477 Effect.flatMap((response) =>
478 response.KeyMetadata!.KeyState === "PendingDeletion"
479 ? Effect.succeed(response.KeyMetadata!)
480 : Effect.fail(new KeyNotPendingDeletion()),
481 ),
482 Effect.retry({
483 while: (error) => error._tag === "KeyNotPendingDeletion",
484 schedule: Schedule.max([
485 Schedule.exponential(100),
486 Schedule.recurs(8),
487 ]),
488 }),
489 );
490 // Every key in this suite uses `deletionWindow: "7 days"`
491 // (Duration.Input) — the scheduled DeletionDate must land ~7 wire days
492 // out, proving the Duration→days conversion round-trips through
493 // scheduleKeyDeletion.
494 const now = yield* Effect.sync(() => Date.now());
495 const windowDays =
496 (metadata.DeletionDate!.getTime() - now) / (24 * 60 * 60 * 1000);
497 expect(windowDays).toBeGreaterThan(6);
498 expect(windowDays).toBeLessThanOrEqual(7.1);
499 });
500
501 const getAlias = Effect.fn(function* (aliasName: string) {
502 const aliases = yield* KMS.listAliases.pages({}).pipe(
503 Stream.runCollect,
504 Effect.map((chunk) =>
505 Array.from(chunk).flatMap((page) => page.Aliases ?? []),
506 ),
507 );
508
509 return aliases.find((alias) => alias.AliasName === aliasName);
510 });
511
512 const listTags = Effect.fn(function* (keyId: string) {
513 const tags = yield* KMS.listResourceTags.pages({ KeyId: keyId }).pipe(
514 Stream.runCollect,
515 Effect.map((chunk) =>
516 Array.from(chunk).flatMap((page) => page.Tags ?? []),
517 ),
518 );
519
520 return Object.fromEntries(tags.map((tag) => [tag.TagKey, tag.TagValue]));
521 });
522 },
523);