1import type * as lambda from "@distilled.cloud/aws/lambda";
2import * as Lambda from "@distilled.cloud/aws/lambda";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11
12export type { FunctionUrlAuthType } from "@distilled.cloud/aws/lambda";
13
14export 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
70export 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 */
119export const Permission = Resource<Permission>("AWS.Lambda.Permission");
120
121export 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 );