1import * as IAM from "@/AWS/IAM";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schedule from "effect/Schedule";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13export class IamTestFunction extends Lambda.Function<Lambda.Function>()(
14 "IamTestFunction",
15) {}
16
17/**
18 * A permissive-but-harmless custom policy used by the simulator and
19 * context-key routes. The `aws:username` condition guarantees
20 * GetContextKeysForCustomPolicy returns a non-empty key list.
21 */
22const CUSTOM_POLICY = JSON.stringify({
23 Version: "2012-10-17",
24 Statement: [
25 {
26 Effect: "Allow",
27 Action: ["s3:ListAllMyBuckets"],
28 Resource: "*",
29 Condition: { StringLike: { "aws:username": "*" } },
30 },
31 ],
32});
33
34export default IamTestFunction.make(
35 {
36 main,
37 functionUrl: true,
38 // Credential-report + access-advisor routes poll (bounded) for report
39 // generation; the AWS default 3s Lambda timeout is far too tight.
40 timeout: Duration.seconds(30),
41 },
42 Effect.gen(function* () {
43 // The IAM entity the principal-scoped routes interrogate. The inline
44 // policy grants s3:ListAllMyBuckets so the access-advisor job lists the
45 // s3 namespace and the principal simulation evaluates to "allowed".
46 const user = yield* IAM.User("BindingsUser", {
47 inlinePolicies: {
48 AllowListBuckets: {
49 Version: "2012-10-17",
50 Statement: [
51 {
52 Effect: "Allow",
53 Action: ["s3:ListAllMyBuckets"],
54 Resource: ["*"],
55 },
56 ],
57 },
58 },
59 tags: { fixture: "iam-bindings" },
60 });
61
62 const accessKey = yield* IAM.AccessKey("BindingsUserKey", {
63 userName: user.userName,
64 });
65
66 // Output accessors: yielding at init returns an *accessor* effect that
67 // must be yielded again inside the runtime route to get the value.
68 const userArn = yield* user.userArn;
69 const userName = yield* user.userName;
70
71 // --- account-audit bindings ---
72 const getAccountSummary = yield* IAM.GetAccountSummary();
73 const getAccountAuthorizationDetails =
74 yield* IAM.GetAccountAuthorizationDetails();
75
76 // --- credential report ---
77 const generateCredentialReport = yield* IAM.GenerateCredentialReport();
78 const getCredentialReport = yield* IAM.GetCredentialReport();
79
80 // --- access advisor ---
81 const generateServiceLastAccessedDetails =
82 yield* IAM.GenerateServiceLastAccessedDetails();
83 const getServiceLastAccessedDetails =
84 yield* IAM.GetServiceLastAccessedDetails();
85 const getServiceLastAccessedDetailsWithEntities =
86 yield* IAM.GetServiceLastAccessedDetailsWithEntities();
87 const listPoliciesGrantingServiceAccess =
88 yield* IAM.ListPoliciesGrantingServiceAccess();
89
90 // --- policy simulation ---
91 const simulateCustomPolicy = yield* IAM.SimulateCustomPolicy();
92 const simulatePrincipalPolicy = yield* IAM.SimulatePrincipalPolicy();
93 const getContextKeysForCustomPolicy =
94 yield* IAM.GetContextKeysForCustomPolicy();
95 const getContextKeysForPrincipalPolicy =
96 yield* IAM.GetContextKeysForPrincipalPolicy();
97
98 // --- access-key hygiene (scoped to the fixture's canonical AccessKey) ---
99 const getAccessKeyLastUsed = yield* IAM.GetAccessKeyLastUsed(accessKey);
100
101 const bound = {
102 getAccountSummary,
103 getAccountAuthorizationDetails,
104 generateCredentialReport,
105 getCredentialReport,
106 generateServiceLastAccessedDetails,
107 getServiceLastAccessedDetails,
108 getServiceLastAccessedDetailsWithEntities,
109 listPoliciesGrantingServiceAccess,
110 simulateCustomPolicy,
111 simulatePrincipalPolicy,
112 getContextKeysForCustomPolicy,
113 getContextKeysForPrincipalPolicy,
114 getAccessKeyLastUsed,
115 };
116
117 return {
118 fetch: Effect.gen(function* () {
119 const request = yield* HttpServerRequest;
120 const url = new URL(request.originalUrl);
121 const pathname = url.pathname;
122
123 if (request.method === "GET" && pathname === "/bindings") {
124 return yield* HttpServerResponse.json({
125 bound: Object.keys(bound),
126 });
127 }
128
129 if (request.method === "GET" && pathname === "/account-summary") {
130 const { SummaryMap } = yield* getAccountSummary();
131 return yield* HttpServerResponse.json({
132 keys: Object.keys(SummaryMap ?? {}).length,
133 users: SummaryMap?.Users ?? null,
134 });
135 }
136
137 if (request.method === "GET" && pathname === "/authorization-details") {
138 const { UserDetailList } = yield* getAccountAuthorizationDetails({
139 Filter: ["User"],
140 MaxItems: 100,
141 });
142 return yield* HttpServerResponse.json({
143 users: (UserDetailList ?? []).length,
144 });
145 }
146
147 if (request.method === "GET" && pathname === "/credential-report") {
148 // Kick off generation (idempotent — returns STARTED/INPROGRESS/
149 // COMPLETE), then poll the report with a bounded retry on the two
150 // typed not-ready tags. A genuine IAM gap surfaces as
151 // AccessDeniedException and 500s the route instead.
152 yield* generateCredentialReport();
153 const result = yield* getCredentialReport().pipe(
154 Effect.retry({
155 while: (e): boolean =>
156 e._tag === "CredentialReportNotPresentException" ||
157 e._tag === "CredentialReportNotReadyException",
158 schedule: Schedule.spaced("2 seconds"),
159 times: 8,
160 }),
161 Effect.map((r) => ({
162 tag: "Ok",
163 bytes: r.Content?.length ?? 0,
164 format: r.ReportFormat ?? null,
165 })),
166 Effect.catchTag(
167 [
168 "CredentialReportNotPresentException",
169 "CredentialReportNotReadyException",
170 ],
171 (e) => Effect.succeed({ tag: e._tag, bytes: 0, format: null }),
172 ),
173 );
174 return yield* HttpServerResponse.json(result);
175 }
176
177 if (request.method === "GET" && pathname === "/service-last-accessed") {
178 // Access-advisor round trip against the fixture user: generate the
179 // job, briefly poll until it leaves IN_PROGRESS, then drill into
180 // the s3 namespace (guaranteed present — the user's inline policy
181 // allows s3). Keep this well below the Lambda timeout: under
182 // parallel test load the IAM calls themselves can be slow, and the
183 // caller already accepts IN_PROGRESS as a valid asynchronous state.
184 const { JobId } = yield* generateServiceLastAccessedDetails({
185 Arn: yield* userArn,
186 });
187 const details = yield* getServiceLastAccessedDetails({
188 JobId: JobId!,
189 }).pipe(
190 Effect.repeat({
191 schedule: Schedule.spaced("1 second"),
192 until: (r): boolean => r.JobStatus !== "IN_PROGRESS",
193 times: 3,
194 }),
195 );
196 if (details.JobStatus !== "COMPLETED") {
197 return yield* HttpServerResponse.json({
198 status: details.JobStatus,
199 services: 0,
200 entities: null,
201 });
202 }
203 const withEntities = yield* getServiceLastAccessedDetailsWithEntities(
204 {
205 JobId: JobId!,
206 ServiceNamespace: "s3",
207 },
208 );
209 return yield* HttpServerResponse.json({
210 status: details.JobStatus,
211 services: (details.ServicesLastAccessed ?? []).length,
212 entities: (withEntities.EntityDetailsList ?? []).length,
213 });
214 }
215
216 if (
217 request.method === "GET" &&
218 pathname === "/policies-granting-access"
219 ) {
220 const { PoliciesGrantingServiceAccess } =
221 yield* listPoliciesGrantingServiceAccess({
222 Arn: yield* userArn,
223 ServiceNamespaces: ["s3"],
224 });
225 const s3 = (PoliciesGrantingServiceAccess ?? []).find(
226 (entry) => entry.ServiceNamespace === "s3",
227 );
228 return yield* HttpServerResponse.json({
229 policies: (s3?.Policies ?? []).length,
230 });
231 }
232
233 if (request.method === "GET" && pathname === "/simulate-custom") {
234 const { EvaluationResults } = yield* simulateCustomPolicy({
235 PolicyInputList: [CUSTOM_POLICY],
236 ActionNames: ["s3:ListAllMyBuckets"],
237 ContextEntries: [
238 {
239 ContextKeyName: "aws:username",
240 ContextKeyType: "string",
241 ContextKeyValues: ["alchemy-test"],
242 },
243 ],
244 });
245 return yield* HttpServerResponse.json({
246 decision: EvaluationResults?.[0]?.EvalDecision ?? null,
247 });
248 }
249
250 if (request.method === "GET" && pathname === "/simulate-principal") {
251 const { EvaluationResults } = yield* simulatePrincipalPolicy({
252 PolicySourceArn: yield* userArn,
253 ActionNames: ["s3:ListAllMyBuckets"],
254 });
255 return yield* HttpServerResponse.json({
256 decision: EvaluationResults?.[0]?.EvalDecision ?? null,
257 });
258 }
259
260 if (request.method === "GET" && pathname === "/context-keys-custom") {
261 const { ContextKeyNames } = yield* getContextKeysForCustomPolicy({
262 PolicyInputList: [CUSTOM_POLICY],
263 });
264 return yield* HttpServerResponse.json({
265 contextKeys: ContextKeyNames ?? [],
266 });
267 }
268
269 if (
270 request.method === "GET" &&
271 pathname === "/context-keys-principal"
272 ) {
273 const { ContextKeyNames } = yield* getContextKeysForPrincipalPolicy({
274 PolicySourceArn: yield* userArn,
275 });
276 return yield* HttpServerResponse.json({
277 contextKeys: ContextKeyNames ?? [],
278 });
279 }
280
281 if (request.method === "GET" && pathname === "/access-key-last-used") {
282 // The binding injects the fixture key's AccessKeyId; a fresh key
283 // has no last-used data but the owning user name is always echoed.
284 const { UserName, AccessKeyLastUsed } = yield* getAccessKeyLastUsed();
285 return yield* HttpServerResponse.json({
286 userName: UserName ?? null,
287 expectedUserName: yield* userName,
288 lastUsedService: AccessKeyLastUsed?.ServiceName ?? null,
289 });
290 }
291
292 return yield* HttpServerResponse.json(
293 { error: "Not found", method: request.method, pathname },
294 { status: 404 },
295 );
296 }).pipe(Effect.orDie),
297 };
298 }).pipe(
299 Effect.provide(
300 Layer.mergeAll(
301 IAM.GetAccountSummaryHttp,
302 IAM.GetAccountAuthorizationDetailsHttp,
303 IAM.GenerateCredentialReportHttp,
304 IAM.GetCredentialReportHttp,
305 IAM.GenerateServiceLastAccessedDetailsHttp,
306 IAM.GetServiceLastAccessedDetailsHttp,
307 IAM.GetServiceLastAccessedDetailsWithEntitiesHttp,
308 IAM.ListPoliciesGrantingServiceAccessHttp,
309 IAM.SimulateCustomPolicyHttp,
310 IAM.SimulatePrincipalPolicyHttp,
311 IAM.GetContextKeysForCustomPolicyHttp,
312 IAM.GetContextKeysForPrincipalPolicyHttp,
313 IAM.GetAccessKeyLastUsedHttp,
314 ),
315 ),
316 ),
317);