1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Namespace from "../../Namespace.ts";
4import { AWSEnvironment } from "../Environment.ts";
5import {
6 RotationEventSource as SecretsManagerRotationEventSource,
7 type RotationEventSourceProps,
8 type RotationEventSourceService,
9 type SecretRotationEvent,
10} from "../SecretsManager/RotationEventSource.ts";
11import { RotationSchedule } from "../SecretsManager/RotationSchedule.ts";
12import type { Secret } from "../SecretsManager/Secret.ts";
13import * as Lambda from "./Function.ts";
14import { Permission as LambdaPermission } from "./Permission.ts";
15
16/**
17 * Narrow an arbitrary Lambda invocation payload to a Secrets Manager
18 * rotation event (`createSecret`/`setSecret`/`testSecret`/`finishSecret`).
19 *
20 * `ClientRequestToken` is deliberately NOT required: the validation
21 * invocation Secrets Manager performs when a rotation schedule is
22 * configured with `RotateImmediately: false` can arrive without one, and an
23 * unmatched event fails the whole invocation ("No event handler found"),
24 * which Secrets Manager records as a failed rotation attempt.
25 */
26export const isSecretRotationEvent = (
27 event: any,
28): event is SecretRotationEvent =>
29 typeof event?.Step === "string" && typeof event?.SecretId === "string";
30
31/**
32 * Lambda runtime implementation for
33 * `AWS.SecretsManager.onSecretRotation(...)`.
34 *
35 * This layer does three things at deploy time:
36 *
37 * 1. Grants `secretsmanager.amazonaws.com` permission to invoke the current
38 * function (scoped to this account via `aws:SourceAccount`).
39 * 2. Attaches the rotation-protocol IAM actions for the bound secret
40 * (`DescribeSecret`, `GetSecretValue`, `PutSecretValue`,
41 * `UpdateSecretVersionStage` on the secret + `GetRandomPassword`).
42 * 3. Provisions the {@link RotationSchedule} configuring the secret's
43 * rotation to invoke this function — threaded through the Permission so
44 * Secrets Manager's invoke-permission validation passes.
45 *
46 * At runtime it narrows incoming invocations to rotation events for the
47 * bound secret and forwards them to the supplied handler.
48 * ### Rotating Secrets
49 * **Example:** Handle Rotation Steps
50 * ```typescript
51 * yield* SecretsManager.onSecretRotation(
52 * secret,
53 * { rotationRules: { automaticallyAfter: "30 days" } },
54 * (event) => rotate(event).pipe(Effect.orDie),
55 * );
56 * ```
57 *
58 * @binding
59 */
60export const SecretRotationEventSource = Layer.effect(
61 SecretsManagerRotationEventSource,
62 // The impl resolves plan-time services (Permission, RotationSchedule)
63 // whereas RotationEventSourceService erases the requirement channel to
64 // `never`.
65 // @effect-diagnostics-next-line missingEffectContext:off
66 Effect.gen(function* () {
67 // this layer can only be used in a Lambda Function
68 const host = yield* Lambda.Function;
69 const Permission = yield* LambdaPermission;
70 const Rotation = yield* RotationSchedule;
71
72 return Effect.fn(function* <Req = never>(
73 secret: Secret,
74 props: RotationEventSourceProps,
75 process: (event: SecretRotationEvent) => Effect.Effect<void, never, Req>,
76 ) {
77 // Resolving the ARN also registers it on the host environment;
78 // re-yield per invocation inside the listener below.
79 const SecretArn = yield* secret.secretArn;
80
81 // Deploy-time: grant the invoke permission, the rotation-protocol IAM
82 // actions, and provision the rotation schedule. Skipped once running
83 // inside the deployed Function (the global guard), where the only work
84 // is registering the runtime handler below. Namespaced under the host
85 // so the sub-resources' logical identity is stable per host function.
86 if (!globalThis.__ALCHEMY_RUNTIME__) {
87 yield* Namespace.push(
88 host.LogicalId,
89 Effect.gen(function* () {
90 yield* host.bind`Allow(${host}, AWS.SecretsManager.RotationEventSource(${secret}))`(
91 {
92 policyStatements: [
93 {
94 Effect: "Allow",
95 Action: [
96 "secretsmanager:DescribeSecret",
97 "secretsmanager:GetSecretValue",
98 "secretsmanager:PutSecretValue",
99 "secretsmanager:UpdateSecretVersionStage",
100 ],
101 Resource: [secret.secretArn],
102 },
103 {
104 Effect: "Allow",
105 Action: ["secretsmanager:GetRandomPassword"],
106 Resource: ["*"],
107 },
108 ],
109 },
110 );
111
112 // Secrets Manager VALIDATES it can invoke the rotation function
113 // when `RotateSecret` configures the schedule, so the Permission
114 // must exist BEFORE the RotationSchedule. Rotation invocations
115 // carry no SourceArn — scope by account per the Secrets Manager
116 // confused-deputy guidance.
117 const { accountId } =
118 yield* AWSEnvironment.current as unknown as Effect.Effect<{
119 accountId: string;
120 }>;
121 const permission = yield* Permission(
122 `${secret.LogicalId}-Rotation-Permission`,
123 {
124 action: "lambda:InvokeFunction",
125 functionName: host.functionArn,
126 principal: "secretsmanager.amazonaws.com",
127 sourceAccount: accountId,
128 },
129 );
130
131 // The Permission echoes the `functionName` prop (the function
132 // ARN) as an attribute — threading it as the rotation Lambda ARN
133 // makes the schedule reconcile only AFTER the invoke permission
134 // exists.
135 yield* Rotation(`${secret.LogicalId}-RotationSchedule`, {
136 secretId: secret.secretArn,
137 rotationLambdaArn: permission.functionName,
138 rotationRules: props.rotationRules,
139 rotateImmediately: props.rotateImmediately,
140 });
141 }),
142 );
143 }
144
145 yield* host.listen(
146 Effect.gen(function* () {
147 const secretArn = yield* SecretArn;
148
149 return (event: any) => {
150 if (isSecretRotationEvent(event) && event.SecretId === secretArn) {
151 return process(event).pipe(Effect.orDie);
152 }
153 };
154 }),
155 );
156 }) as RotationEventSourceService;
157 }),
158);