| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Layer from "effect/Layer"; |
| 3 | import * as Namespace from "../../Namespace.ts"; |
| 4 | import { AWSEnvironment } from "../Environment.ts"; |
| 5 | import { |
| 6 | RotationEventSource as SecretsManagerRotationEventSource, |
| 7 | type RotationEventSourceProps, |
| 8 | type RotationEventSourceService, |
| 9 | type SecretRotationEvent, |
| 10 | } from "../SecretsManager/RotationEventSource.ts"; |
| 11 | import { RotationSchedule } from "../SecretsManager/RotationSchedule.ts"; |
| 12 | import type { Secret } from "../SecretsManager/Secret.ts"; |
| 13 | import * as Lambda from "./Function.ts"; |
| 14 | import { 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 | */ |
| 26 | export 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 | */ |
| 60 | export 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 | ); |