1import * as secretsmanager from "@distilled.cloud/aws/secrets-manager";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { toWireDays, toWireHours } from "../../Util/Duration.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface RotationRules {
13 /**
14 * Rotate automatically after this interval (e.g. `"30 days"`). Rounded to
15 * whole days on the wire (`AutomaticallyAfterDays`).
16 */
17 automaticallyAfter?: Duration.Input;
18 /**
19 * Length of the rotation window (e.g. `"3 hours"`). Rounded to whole
20 * hours on the wire (`Duration`, `"3h"`).
21 */
22 window?: Duration.Input;
23 /**
24 * A `rate()` or `cron()` schedule expression, e.g. `"rate(4 hours)"` or
25 * `"cron(0 8 1 * ? *)"`. Mutually exclusive with `automaticallyAfter`.
26 */
27 scheduleExpression?: string;
28}
29
30/** Convert the typed {@link RotationRules} to the wire shape. */
31export const toWireRotationRules = (
32 rules: RotationRules | undefined,
33): secretsmanager.RotationRulesType | undefined => {
34 if (rules === undefined) {
35 return undefined;
36 }
37 const hours = toWireHours(rules.window);
38 return {
39 AutomaticallyAfterDays: toWireDays(rules.automaticallyAfter),
40 Duration: hours === undefined ? undefined : `${hours}h`,
41 ScheduleExpression: rules.scheduleExpression,
42 };
43};
44
45export interface RotationScheduleProps {
46 /**
47 * ARN (preferred) or name of the secret whose rotation is being
48 * configured.
49 */
50 secretId: string;
51 /**
52 * ARN of the Lambda function that implements the rotation protocol
53 * (`createSecret`/`setSecret`/`testSecret`/`finishSecret`).
54 */
55 rotationLambdaArn?: string;
56 /**
57 * When the rotation runs. Provide `scheduleExpression` or
58 * `automaticallyAfter`.
59 */
60 rotationRules?: RotationRules;
61 /**
62 * Rotate the secret immediately when the schedule is created/updated. If
63 * `false`, Secrets Manager only tests the rotation configuration by
64 * running the `testSecret` step of the rotation function.
65 * @default false
66 */
67 rotateImmediately?: boolean;
68}
69
70export interface RotationSchedule extends Resource<
71 "AWS.SecretsManager.RotationSchedule",
72 RotationScheduleProps,
73 {
74 /**
75 * ARN of the secret the rotation is configured on.
76 */
77 secretArn: string;
78 /**
79 * Name of the secret.
80 */
81 secretName: string;
82 /**
83 * ARN of the rotation Lambda function.
84 */
85 rotationLambdaArn: string | undefined;
86 /**
87 * Whether automatic rotation is enabled on the secret.
88 */
89 rotationEnabled: boolean;
90 },
91 never,
92 Providers
93> {}
94
95/**
96 * Configures automatic rotation on a Secrets Manager secret
97 * (`RotateSecret` with a rotation Lambda + rules; `CancelRotateSecret` on
98 * delete).
99 *
100 * Usually created for you by {@link onSecretRotation}, which also wires the
101 * invoke permission and the runtime handler — reach for the resource
102 * directly only when the rotation function is managed outside the current
103 * stack.
104 * ### Scheduling Rotation
105 * **Example:** Rotate Every 30 Days
106 * ```typescript
107 * const schedule = yield* RotationSchedule("DbSecretRotation", {
108 * secretId: secret.secretArn,
109 * rotationLambdaArn: rotationFunctionArn,
110 * rotationRules: { automaticallyAfter: "30 days" },
111 * });
112 * ```
113 *
114 * **Example:** Cron Schedule with a Rotation Window
115 * ```typescript
116 * const schedule = yield* RotationSchedule("DbSecretRotation", {
117 * secretId: secret.secretArn,
118 * rotationLambdaArn: rotationFunctionArn,
119 * rotationRules: {
120 * scheduleExpression: "cron(0 8 1 * ? *)",
121 * window: "3 hours",
122 * },
123 * });
124 * ```
125 *
126 * @resource
127 */
128export const RotationSchedule = Resource<RotationSchedule>(
129 "AWS.SecretsManager.RotationSchedule",
130);
131
132/**
133 * Bounded retry while Secrets Manager can't yet invoke the rotation Lambda
134 * (`InvalidRequestException` — the `lambda:InvokeFunction` permission for
135 * principal `secretsmanager.amazonaws.com` propagates asynchronously right
136 * after it is created).
137 *
138 * Explicitly-typed helper: inlining `Effect.retry` here leaves
139 * `Retry.Return`'s conditional type unresolved in the provider's inferred
140 * layer type, which declaration emit widens to an `unknown` R — poisoning
141 * `AWS.providers()` for every consumer (same shape as in `Secret.ts`).
142 */
143const retryWhileInvokePermissionPropagates = <A, E extends { _tag: string }, R>(
144 self: Effect.Effect<A, E, R>,
145): Effect.Effect<A, E, R> =>
146 Effect.retry(self, {
147 while: (e) =>
148 e._tag === "InvalidRequestException" &&
149 ((e as { message?: string }).message?.includes("Lambda") ?? false),
150 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
151 });
152
153export const RotationScheduleProvider = () =>
154 Provider.effect(
155 RotationSchedule,
156 Effect.gen(function* () {
157 const readRotation = Effect.fn(function* (secretId: string) {
158 return yield* secretsmanager
159 .describeSecret({ SecretId: secretId })
160 .pipe(
161 Effect.catchTag("ResourceNotFoundException", () =>
162 Effect.succeed(undefined),
163 ),
164 );
165 });
166
167 return {
168 stables: ["secretArn", "secretName"],
169 diff: Effect.fn(function* ({ olds, news }) {
170 if (!isResolved(news)) return undefined;
171 if (olds && olds.secretId !== news.secretId) {
172 return { action: "replace" } as const;
173 }
174 }),
175 read: Effect.fn(function* ({ olds, output }) {
176 const secretId = output?.secretArn ?? olds?.secretId;
177 if (secretId === undefined) {
178 return undefined;
179 }
180 const described = yield* readRotation(secretId);
181 if (
182 !described?.ARN ||
183 !described.Name ||
184 !described.RotationEnabled
185 ) {
186 return undefined;
187 }
188 return {
189 secretArn: described.ARN,
190 secretName: described.Name,
191 rotationLambdaArn: described.RotationLambdaARN,
192 rotationEnabled: described.RotationEnabled === true,
193 };
194 }),
195 reconcile: Effect.fn(function* ({ news, output, session }) {
196 const secretId = output?.secretArn ?? news.secretId;
197
198 // Configure (or reconverge) rotation. `rotateSecret` is a true
199 // upsert of the rotation configuration; with
200 // `RotateImmediately: false` Secrets Manager only validates the
201 // config by invoking the rotation function's `testSecret` step.
202 // Right after the invoke permission is created Secrets Manager may
203 // not see it yet — retry through that propagation window.
204 const rotated = yield* retryWhileInvokePermissionPropagates(
205 secretsmanager.rotateSecret({
206 SecretId: secretId,
207 RotationLambdaARN: news.rotationLambdaArn,
208 RotationRules: toWireRotationRules(news.rotationRules),
209 RotateImmediately: news.rotateImmediately ?? false,
210 }),
211 );
212
213 const secretArn = rotated.ARN ?? secretId;
214 const described = yield* readRotation(secretArn);
215 yield* session.note(secretArn);
216 return {
217 secretArn: described?.ARN ?? secretArn,
218 secretName: described?.Name ?? rotated.Name ?? secretId,
219 rotationLambdaArn:
220 described?.RotationLambdaARN ?? news.rotationLambdaArn,
221 rotationEnabled: described?.RotationEnabled === true,
222 };
223 }),
224 delete: Effect.fn(function* ({ output }) {
225 // `CancelRotateSecret` turns off automatic rotation (and cancels
226 // any rotation in progress). Idempotent: missing secret or
227 // rotation-not-configured are treated as already-deleted.
228 yield* secretsmanager
229 .cancelRotateSecret({ SecretId: output.secretArn })
230 .pipe(
231 Effect.catchTag(
232 ["ResourceNotFoundException", "InvalidRequestException"],
233 () => Effect.void,
234 ),
235 );
236 }),
237 // A rotation schedule is configuration on a secret, not a separately
238 // enumerable object — `listSecrets` surfaces the rotation state
239 // (`RotationEnabled`, `RotationLambdaARN`) inline, so hydrate the
240 // exact `read` Attributes shape for every secret that has rotation
241 // enabled.
242 list: () =>
243 secretsmanager.listSecrets.pages({}).pipe(
244 Stream.runCollect,
245 Effect.map((chunk) =>
246 Array.from(chunk).flatMap((page) =>
247 (page.SecretList ?? [])
248 .filter(
249 (
250 entry,
251 ): entry is secretsmanager.SecretListEntry & {
252 ARN: string;
253 Name: string;
254 } =>
255 entry.ARN != null &&
256 entry.Name != null &&
257 entry.RotationEnabled === true,
258 )
259 .map((entry) => ({
260 secretArn: entry.ARN,
261 secretName: entry.Name,
262 rotationLambdaArn: entry.RotationLambdaARN,
263 rotationEnabled: true,
264 })),
265 ),
266 ),
267 ),
268 };
269 }),
270 );