| 1 | import type * as lambda from "@distilled.cloud/aws/lambda"; |
| 2 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | export type { FunctionUrlAuthType } from "@distilled.cloud/aws/lambda"; |
| 13 | |
| 14 | export interface PermissionProps { |
| 15 | /** |
| 16 | * The action that the principal can use on the function. |
| 17 | * For example, `lambda:InvokeFunction` or `lambda:GetFunction`. |
| 18 | */ |
| 19 | action: string; |
| 20 | |
| 21 | /** |
| 22 | * The name or ARN of the Lambda function, version, or alias. |
| 23 | */ |
| 24 | functionName: string; |
| 25 | |
| 26 | /** |
| 27 | * The AWS service, AWS account, IAM user, or IAM role that invokes the function. |
| 28 | * If you specify a service, use `sourceArn` or `sourceAccount` to limit who can |
| 29 | * invoke the function through that service. |
| 30 | */ |
| 31 | principal: string; |
| 32 | |
| 33 | /** |
| 34 | * For Alexa Smart Home functions, a token that the invoker must supply. |
| 35 | */ |
| 36 | eventSourceToken?: string; |
| 37 | |
| 38 | /** |
| 39 | * The type of authentication that your function URL uses. |
| 40 | * Set to `AWS_IAM` to restrict access to authenticated users only. |
| 41 | * Set to `NONE` to bypass IAM authentication to create a public endpoint. |
| 42 | */ |
| 43 | functionUrlAuthType?: lambda.FunctionUrlAuthType; |
| 44 | |
| 45 | /** |
| 46 | * Indicates whether the permission applies when the function is invoked |
| 47 | * through a function URL. |
| 48 | */ |
| 49 | invokedViaFunctionUrl?: boolean; |
| 50 | |
| 51 | /** |
| 52 | * The identifier for your organization in AWS Organizations. |
| 53 | * Use this to grant permissions to all the AWS accounts under this organization. |
| 54 | */ |
| 55 | principalOrgID?: string; |
| 56 | |
| 57 | /** |
| 58 | * For AWS services, the ID of the AWS account that owns the resource. |
| 59 | * Use this together with `sourceArn` to ensure that the specified account owns the resource. |
| 60 | */ |
| 61 | sourceAccount?: string; |
| 62 | |
| 63 | /** |
| 64 | * For AWS services, the ARN of the AWS resource that invokes the function. |
| 65 | * For example, an Amazon S3 bucket or Amazon SNS topic. |
| 66 | */ |
| 67 | sourceArn?: string; |
| 68 | } |
| 69 | |
| 70 | export interface Permission extends Resource< |
| 71 | "AWS.Lambda.Permission", |
| 72 | PermissionProps, |
| 73 | { |
| 74 | /** The statement ID of the permission. */ |
| 75 | statementId: string; |
| 76 | /** The function name or ARN the permission is attached to. */ |
| 77 | functionName: string; |
| 78 | }, |
| 79 | never, |
| 80 | Providers |
| 81 | > {} |
| 82 | |
| 83 | /** |
| 84 | * A Lambda permission that grants an AWS service or another account permission to |
| 85 | * invoke a function. |
| 86 | * ### Granting Permissions |
| 87 | * **Example:** S3 Notification Permission |
| 88 | * ```typescript |
| 89 | * const perm = yield* Permission("S3Invoke", { |
| 90 | * action: "lambda:InvokeFunction", |
| 91 | * functionName: yield* fn.functionArn(), |
| 92 | * principal: "s3.amazonaws.com", |
| 93 | * sourceArn: yield* bucket.bucketArn, |
| 94 | * sourceAccount: (yield* AWSEnvironment.current).accountId, |
| 95 | * }); |
| 96 | * ``` |
| 97 | * |
| 98 | * **Example:** Cross Account Invoke |
| 99 | * ```typescript |
| 100 | * const perm = yield* Permission("CrossAccount", { |
| 101 | * action: "lambda:InvokeFunction", |
| 102 | * functionName: yield* fn.functionArn(), |
| 103 | * principal: "123456789012", |
| 104 | * }); |
| 105 | * ``` |
| 106 | * |
| 107 | * **Example:** Public Function URL |
| 108 | * ```typescript |
| 109 | * const perm = yield* Permission("PublicUrl", { |
| 110 | * action: "lambda:InvokeFunctionUrl", |
| 111 | * functionName: yield* fn.functionArn(), |
| 112 | * principal: "*", |
| 113 | * functionUrlAuthType: "NONE", |
| 114 | * }); |
| 115 | * ``` |
| 116 | * |
| 117 | * @resource |
| 118 | */ |
| 119 | export const Permission = Resource<Permission>("AWS.Lambda.Permission"); |
| 120 | |
| 121 | export const PermissionProvider = () => |
| 122 | Provider.effect( |
| 123 | Permission, |
| 124 | Effect.gen(function* () { |
| 125 | const createStatementId = (id: string) => |
| 126 | createPhysicalName({ |
| 127 | id, |
| 128 | maxLength: 100, |
| 129 | delimiter: "-", |
| 130 | }); |
| 131 | |
| 132 | type PermissionAttrs = { statementId: string; functionName: string }; |
| 133 | |
| 134 | // Lambda serializes resource-policy mutations per function. Parallel |
| 135 | // Permission resources (for example several API Gateway routes) can |
| 136 | // therefore receive a transient ResourceConflictException even when |
| 137 | // their statement ids are distinct. Keep the retry bounded well below |
| 138 | // the provider/test timeout. |
| 139 | type PolicyMutationError = |
| 140 | | Lambda.AddPermissionError |
| 141 | | Lambda.RemovePermissionError; |
| 142 | const retryPolicyMutation = { |
| 143 | while: (error: PolicyMutationError) => |
| 144 | error._tag === "ResourceConflictException", |
| 145 | schedule: Schedule.spaced("500 millis"), |
| 146 | times: 8, |
| 147 | } as const; |
| 148 | |
| 149 | const addPermission = (request: Lambda.AddPermissionRequest) => |
| 150 | Lambda.addPermission(request).pipe(Effect.retry(retryPolicyMutation)); |
| 151 | |
| 152 | const removePermission = (request: Lambda.RemovePermissionRequest) => |
| 153 | Lambda.removePermission(request).pipe( |
| 154 | Effect.retry(retryPolicyMutation), |
| 155 | ); |
| 156 | |
| 157 | const hasStatement = Effect.fn(function* ( |
| 158 | functionName: string, |
| 159 | statementId: string, |
| 160 | ) { |
| 161 | const { Policy } = yield* Lambda.getPolicy({ |
| 162 | FunctionName: functionName, |
| 163 | }).pipe( |
| 164 | // A function without a resource policy is reported as not found. |
| 165 | Effect.catchTag("ResourceNotFoundException", () => |
| 166 | Effect.succeed({ Policy: undefined }), |
| 167 | ), |
| 168 | ); |
| 169 | if (!Policy) return false; |
| 170 | return yield* Effect.try({ |
| 171 | try: () => { |
| 172 | const policy = JSON.parse(Policy) as { |
| 173 | Statement?: { Sid?: string } | { Sid?: string }[]; |
| 174 | }; |
| 175 | const statements = Array.isArray(policy.Statement) |
| 176 | ? policy.Statement |
| 177 | : policy.Statement |
| 178 | ? [policy.Statement] |
| 179 | : []; |
| 180 | return statements.some( |
| 181 | (statement) => statement.Sid === statementId, |
| 182 | ); |
| 183 | }, |
| 184 | catch: (cause) => |
| 185 | new Error("invalid Lambda resource policy", { cause }), |
| 186 | }); |
| 187 | }); |
| 188 | |
| 189 | return { |
| 190 | stables: ["statementId", "functionName"], |
| 191 | list: () => |
| 192 | Effect.gen(function* () { |
| 193 | // Lambda has no list-permissions API. A Permission is a single |
| 194 | // statement (Sid) inside a function's resource policy, so we fan |
| 195 | // out: enumerate every function in the ambient account/region |
| 196 | // (paginated listFunctions), getPolicy per function, parse the |
| 197 | // policy JSON, and emit one Attributes per statement Sid. |
| 198 | const functionNames = yield* Lambda.listFunctions.pages({}).pipe( |
| 199 | Stream.runCollect, |
| 200 | Effect.map((chunk) => |
| 201 | Array.from(chunk).flatMap((page) => |
| 202 | (page.Functions ?? []) |
| 203 | .map((fn) => fn.FunctionName) |
| 204 | .filter((name): name is string => name != null), |
| 205 | ), |
| 206 | ), |
| 207 | ); |
| 208 | |
| 209 | const perFunction = yield* Effect.forEach( |
| 210 | functionNames, |
| 211 | (functionName) => |
| 212 | Effect.gen(function* () { |
| 213 | const { Policy } = yield* Lambda.getPolicy({ |
| 214 | FunctionName: functionName, |
| 215 | }); |
| 216 | if (!Policy) return [] as PermissionAttrs[]; |
| 217 | const policy = yield* Effect.try({ |
| 218 | try: () => |
| 219 | JSON.parse(Policy) as { |
| 220 | Statement?: { Sid?: string } | { Sid?: string }[]; |
| 221 | }, |
| 222 | catch: (cause) => new Error("invalid policy", { cause }), |
| 223 | }).pipe( |
| 224 | // A malformed/non-JSON policy yields no permissions |
| 225 | // rather than failing the whole enumeration. |
| 226 | Effect.orElseSucceed(() => ({ |
| 227 | Statement: [] as { Sid?: string }[], |
| 228 | })), |
| 229 | ); |
| 230 | const statements = Array.isArray(policy.Statement) |
| 231 | ? policy.Statement |
| 232 | : policy.Statement |
| 233 | ? [policy.Statement] |
| 234 | : []; |
| 235 | return statements |
| 236 | .filter( |
| 237 | (s): s is { Sid: string } => typeof s.Sid === "string", |
| 238 | ) |
| 239 | .map((s): PermissionAttrs => ({ |
| 240 | statementId: s.Sid, |
| 241 | functionName, |
| 242 | })); |
| 243 | }).pipe( |
| 244 | // Functions with no resource policy / removed out of band |
| 245 | // between list and getPolicy — skip them. |
| 246 | Effect.catchTag("ResourceNotFoundException", () => |
| 247 | Effect.succeed([] as PermissionAttrs[]), |
| 248 | ), |
| 249 | ), |
| 250 | { concurrency: 10 }, |
| 251 | ); |
| 252 | return perFunction.flat(); |
| 253 | }), |
| 254 | diff: Effect.fn(function* ({ news, olds }) { |
| 255 | if (!isResolved(news)) return; |
| 256 | if (news.functionName !== olds.functionName) { |
| 257 | return { action: "replace" } as const; |
| 258 | } |
| 259 | }), |
| 260 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 261 | // Observe — derive identity. The statementId is deterministic from |
| 262 | // the logical id, so we always use it whether this is a first |
| 263 | // reconciliation, an adoption, or a re-run after a partial create. |
| 264 | const statementId = |
| 265 | output?.statementId ?? (yield* createStatementId(id)); |
| 266 | |
| 267 | // Observe the deterministic Sid before mutating. An existing Sid is |
| 268 | // a true create/update collision and must be replaced; a |
| 269 | // ResourceConflictException from an absent Sid is instead Lambda's |
| 270 | // transient per-function policy mutation lock and is retried. |
| 271 | if (yield* hasStatement(news.functionName, statementId)) { |
| 272 | yield* removePermission({ |
| 273 | FunctionName: news.functionName, |
| 274 | StatementId: statementId, |
| 275 | }).pipe( |
| 276 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | yield* addPermission({ |
| 281 | FunctionName: news.functionName, |
| 282 | StatementId: statementId, |
| 283 | Action: news.action, |
| 284 | Principal: news.principal, |
| 285 | SourceArn: news.sourceArn, |
| 286 | SourceAccount: news.sourceAccount, |
| 287 | EventSourceToken: news.eventSourceToken, |
| 288 | FunctionUrlAuthType: news.functionUrlAuthType, |
| 289 | InvokedViaFunctionUrl: news.invokedViaFunctionUrl, |
| 290 | PrincipalOrgID: news.principalOrgID, |
| 291 | }); |
| 292 | |
| 293 | yield* session.note( |
| 294 | `Permission ${statementId} on ${news.functionName}`, |
| 295 | ); |
| 296 | |
| 297 | return { |
| 298 | statementId, |
| 299 | functionName: news.functionName, |
| 300 | }; |
| 301 | }), |
| 302 | delete: Effect.fn(function* ({ output }) { |
| 303 | yield* removePermission({ |
| 304 | FunctionName: output.functionName, |
| 305 | StatementId: output.statementId, |
| 306 | }).pipe( |
| 307 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 308 | ); |
| 309 | }), |
| 310 | }; |
| 311 | }), |
| 312 | ); |