1import * as AccessAnalyzer from "@/AWS/AccessAnalyzer";
2import * as Lambda from "@/AWS/Lambda";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12// The fixture analyzer is ACCOUNT_UNUSED_ACCESS (not ACCOUNT) on purpose:
13// AWS allows only ONE external-access account analyzer per Region, and
14// Analyzer.test.ts exercises that singleton's lifecycle. Using the
15// unused-access type keeps the two suites quota-independent.
16export const FIXTURE_ANALYZER_NAME = "alchemy-test-bindings-unused-access";
17export const FIXTURE_RULE_NAME = "alchemy-test-bindings-rule";
18
19// Deterministic sample policies (checked-in constants, never generated at
20// test time).
21const IDENTITY_POLICY = JSON.stringify({
22 Version: "2012-10-17",
23 Statement: [
24 {
25 Effect: "Allow",
26 Action: ["s3:GetObject"],
27 Resource: "arn:aws:s3:::alchemy-test-bucket/*",
28 },
29 ],
30});
31
32// Superset of IDENTITY_POLICY — used as the "existing" policy so the new
33// policy (IDENTITY_POLICY) grants no new access.
34const WIDER_IDENTITY_POLICY = JSON.stringify({
35 Version: "2012-10-17",
36 Statement: [
37 {
38 Effect: "Allow",
39 Action: ["s3:GetObject", "s3:PutObject"],
40 Resource: "arn:aws:s3:::alchemy-test-bucket/*",
41 },
42 ],
43});
44
45// Non-public bucket policy: access limited to a single account principal.
46const PRIVATE_BUCKET_POLICY = JSON.stringify({
47 Version: "2012-10-17",
48 Statement: [
49 {
50 Effect: "Allow",
51 Principal: { AWS: "arn:aws:iam::111111111111:root" },
52 Action: "s3:GetObject",
53 Resource: "arn:aws:s3:::alchemy-test-bucket/*",
54 },
55 ],
56});
57
58export class AccessAnalyzerTestFunction extends Lambda.Function<Lambda.Function>()(
59 "AccessAnalyzerTestFunction",
60) {}
61
62export default AccessAnalyzerTestFunction.make(
63 {
64 main,
65 functionUrl: true,
66 },
67 Effect.gen(function* () {
68 const analyzer = yield* AccessAnalyzer.Analyzer("BindingsAnalyzer", {
69 analyzerName: FIXTURE_ANALYZER_NAME,
70 type: "ACCOUNT_UNUSED_ACCESS",
71 });
72 yield* AccessAnalyzer.ArchiveRule("BindingsArchiveRule", {
73 analyzerName: analyzer.analyzerName,
74 ruleName: FIXTURE_RULE_NAME,
75 filter: { findingType: { eq: ["UnusedIAMRole"] } },
76 });
77
78 // --- analyzer-scoped bindings ---
79 const listFindingsV2 = yield* AccessAnalyzer.ListFindingsV2(analyzer);
80 const getFindingsStatistics =
81 yield* AccessAnalyzer.GetFindingsStatistics(analyzer);
82 const applyArchiveRule = yield* AccessAnalyzer.ApplyArchiveRule(analyzer);
83 // Bound (compile + IAM coverage) but not routed: these need live
84 // findings, access previews, or an external-access analyzer to drive
85 // deterministically.
86 const listFindingsV1 = yield* AccessAnalyzer.ListFindings(analyzer);
87 const getFindingV2 = yield* AccessAnalyzer.GetFindingV2(analyzer);
88 const getFindingV1 = yield* AccessAnalyzer.GetFinding(analyzer);
89 const updateFindings = yield* AccessAnalyzer.UpdateFindings(analyzer);
90 const startResourceScan = yield* AccessAnalyzer.StartResourceScan(analyzer);
91 const getAnalyzedResource =
92 yield* AccessAnalyzer.GetAnalyzedResource(analyzer);
93 const listAnalyzedResources =
94 yield* AccessAnalyzer.ListAnalyzedResources(analyzer);
95 const createAccessPreview =
96 yield* AccessAnalyzer.CreateAccessPreview(analyzer);
97 const getAccessPreview = yield* AccessAnalyzer.GetAccessPreview(analyzer);
98 const listAccessPreviews =
99 yield* AccessAnalyzer.ListAccessPreviews(analyzer);
100 const listAccessPreviewFindings =
101 yield* AccessAnalyzer.ListAccessPreviewFindings(analyzer);
102 const generateFindingRecommendation =
103 yield* AccessAnalyzer.GenerateFindingRecommendation(analyzer);
104 const getFindingRecommendation =
105 yield* AccessAnalyzer.GetFindingRecommendation(analyzer);
106
107 // --- account-level bindings ---
108 const validatePolicy = yield* AccessAnalyzer.ValidatePolicy();
109 const checkAccessNotGranted = yield* AccessAnalyzer.CheckAccessNotGranted();
110 const checkNoNewAccess = yield* AccessAnalyzer.CheckNoNewAccess();
111 const checkNoPublicAccess = yield* AccessAnalyzer.CheckNoPublicAccess();
112 const startPolicyGeneration = yield* AccessAnalyzer.StartPolicyGeneration();
113 const getGeneratedPolicy = yield* AccessAnalyzer.GetGeneratedPolicy();
114 const listPolicyGenerations = yield* AccessAnalyzer.ListPolicyGenerations();
115 const cancelPolicyGeneration =
116 yield* AccessAnalyzer.CancelPolicyGeneration();
117
118 // --- event source ---
119 // Deploy-time: creates the EventBridge rule (default bus) targeting this
120 // Function. Runtime firing needs a real finding, so the test only
121 // verifies the subscription deploys cleanly.
122 yield* AccessAnalyzer.consumeFindings({ kind: "all" }, (events) =>
123 Stream.runForEach(events, (event) =>
124 Effect.log(`access-analyzer finding event: ${event.detail.id}`),
125 ),
126 );
127
128 const bound = {
129 listFindingsV2,
130 listFindingsV1,
131 getFindingV2,
132 getFindingV1,
133 getFindingsStatistics,
134 updateFindings,
135 applyArchiveRule,
136 startResourceScan,
137 getAnalyzedResource,
138 listAnalyzedResources,
139 createAccessPreview,
140 getAccessPreview,
141 listAccessPreviews,
142 listAccessPreviewFindings,
143 generateFindingRecommendation,
144 getFindingRecommendation,
145 validatePolicy,
146 checkAccessNotGranted,
147 checkNoNewAccess,
148 checkNoPublicAccess,
149 startPolicyGeneration,
150 getGeneratedPolicy,
151 listPolicyGenerations,
152 cancelPolicyGeneration,
153 };
154
155 return {
156 fetch: Effect.gen(function* () {
157 const request = yield* HttpServerRequest;
158 const url = new URL(request.originalUrl);
159 const pathname = url.pathname;
160
161 if (request.method === "GET" && pathname === "/bindings") {
162 return yield* HttpServerResponse.json({
163 bound: Object.keys(bound),
164 });
165 }
166
167 if (request.method === "GET" && pathname === "/list-findings") {
168 const result = yield* listFindingsV2({ maxResults: 25 });
169 return yield* HttpServerResponse.json({
170 count: result.findings.length,
171 nextToken: result.nextToken ?? null,
172 });
173 }
174
175 if (
176 request.method === "GET" &&
177 pathname === "/list-analyzed-resources"
178 ) {
179 const result = yield* listAnalyzedResources({ maxResults: 25 });
180 return yield* HttpServerResponse.json({
181 count: result.analyzedResources.length,
182 });
183 }
184
185 if (request.method === "GET" && pathname === "/get-finding-not-found") {
186 // Exercises analyzerArn injection + the typed not-found error path.
187 const result = yield* getFindingV2({
188 id: "00000000-0000-0000-0000-000000000000",
189 }).pipe(
190 Effect.map(() => ({ found: true })),
191 Effect.catchTag("ResourceNotFoundException", () =>
192 Effect.succeed({ found: false }),
193 ),
194 );
195 return yield* HttpServerResponse.json(result);
196 }
197
198 if (request.method === "GET" && pathname === "/findings-statistics") {
199 const result = yield* getFindingsStatistics();
200 return yield* HttpServerResponse.json({
201 statistics: result.findingsStatistics ?? [],
202 });
203 }
204
205 if (request.method === "POST" && pathname === "/apply-archive-rule") {
206 yield* applyArchiveRule({ ruleName: FIXTURE_RULE_NAME });
207 return yield* HttpServerResponse.json({ applied: true });
208 }
209
210 if (request.method === "POST" && pathname === "/validate-policy") {
211 const result = yield* validatePolicy({
212 policyDocument: IDENTITY_POLICY,
213 policyType: "IDENTITY_POLICY",
214 });
215 return yield* HttpServerResponse.json({
216 findingTypes: result.findings.map((f) => f.findingType),
217 });
218 }
219
220 if (request.method === "POST" && pathname === "/check-no-new-access") {
221 const result = yield* checkNoNewAccess({
222 existingPolicyDocument: WIDER_IDENTITY_POLICY,
223 newPolicyDocument: IDENTITY_POLICY,
224 policyType: "IDENTITY_POLICY",
225 });
226 return yield* HttpServerResponse.json({ result: result.result });
227 }
228
229 if (
230 request.method === "POST" &&
231 pathname === "/check-access-not-granted"
232 ) {
233 const result = yield* checkAccessNotGranted({
234 policyDocument: IDENTITY_POLICY,
235 policyType: "IDENTITY_POLICY",
236 access: [{ actions: ["s3:DeleteBucket"] }],
237 });
238 return yield* HttpServerResponse.json({ result: result.result });
239 }
240
241 if (
242 request.method === "POST" &&
243 pathname === "/check-no-public-access"
244 ) {
245 const result = yield* checkNoPublicAccess({
246 policyDocument: PRIVATE_BUCKET_POLICY,
247 resourceType: "AWS::S3::Bucket",
248 });
249 return yield* HttpServerResponse.json({ result: result.result });
250 }
251
252 if (
253 request.method === "GET" &&
254 pathname === "/list-policy-generations"
255 ) {
256 const result = yield* listPolicyGenerations();
257 return yield* HttpServerResponse.json({
258 count: result.policyGenerations.length,
259 });
260 }
261
262 if (request.method === "POST" && pathname === "/policy-generation") {
263 // The live API rejects StartPolicyGeneration without
264 // `cloudTrailDetails` ("Missing cloudTrailDetails"); a real
265 // generation needs a CloudTrail trail + an access role Access
266 // Analyzer can assume — external infra out of scope for a binding
267 // test. Drive all three bindings through their typed
268 // ValidationException paths instead: an IAM gap would surface
269 // AccessDeniedException, so a ValidationException proves the grant
270 // and the typed error union end-to-end.
271 const body = (yield* request.json) as unknown as {
272 principalArn: string;
273 };
274 const startTag = yield* startPolicyGeneration({
275 policyGenerationDetails: { principalArn: body.principalArn },
276 }).pipe(
277 // If AWS ever accepts trail-less generation again, cancel the
278 // job so nothing dangles.
279 Effect.flatMap(({ jobId }) =>
280 cancelPolicyGeneration({ jobId }).pipe(
281 Effect.catchTag("ValidationException", () => Effect.void),
282 Effect.as("Started"),
283 ),
284 ),
285 Effect.catchTag("ValidationException", (e) =>
286 Effect.succeed(e._tag),
287 ),
288 );
289 const bogusJob = { jobId: "00000000-0000-0000-0000-000000000000" };
290 const getTag = yield* getGeneratedPolicy(bogusJob).pipe(
291 Effect.map(() => "Found"),
292 Effect.catchTag("ValidationException", (e) =>
293 Effect.succeed(e._tag),
294 ),
295 );
296 const cancelTag = yield* cancelPolicyGeneration(bogusJob).pipe(
297 Effect.map(() => "Canceled"),
298 Effect.catchTag("ValidationException", (e) =>
299 Effect.succeed(e._tag),
300 ),
301 );
302 return yield* HttpServerResponse.json({
303 startTag,
304 getTag,
305 cancelTag,
306 });
307 }
308
309 return yield* HttpServerResponse.json(
310 { error: "Not found", method: request.method, pathname },
311 { status: 404 },
312 );
313 }).pipe(Effect.orDie),
314 };
315 }).pipe(
316 Effect.provide(
317 Layer.mergeAll(
318 Lambda.EventSource,
319 AccessAnalyzer.ListFindingsV2Http,
320 AccessAnalyzer.ListFindingsHttp,
321 AccessAnalyzer.GetFindingV2Http,
322 AccessAnalyzer.GetFindingHttp,
323 AccessAnalyzer.GetFindingsStatisticsHttp,
324 AccessAnalyzer.UpdateFindingsHttp,
325 AccessAnalyzer.ApplyArchiveRuleHttp,
326 AccessAnalyzer.StartResourceScanHttp,
327 AccessAnalyzer.GetAnalyzedResourceHttp,
328 AccessAnalyzer.ListAnalyzedResourcesHttp,
329 AccessAnalyzer.CreateAccessPreviewHttp,
330 AccessAnalyzer.GetAccessPreviewHttp,
331 AccessAnalyzer.ListAccessPreviewsHttp,
332 AccessAnalyzer.ListAccessPreviewFindingsHttp,
333 AccessAnalyzer.GenerateFindingRecommendationHttp,
334 AccessAnalyzer.GetFindingRecommendationHttp,
335 AccessAnalyzer.ValidatePolicyHttp,
336 AccessAnalyzer.CheckAccessNotGrantedHttp,
337 AccessAnalyzer.CheckNoNewAccessHttp,
338 AccessAnalyzer.CheckNoPublicAccessHttp,
339 AccessAnalyzer.StartPolicyGenerationHttp,
340 AccessAnalyzer.GetGeneratedPolicyHttp,
341 AccessAnalyzer.ListPolicyGenerationsHttp,
342 AccessAnalyzer.CancelPolicyGenerationHttp,
343 ),
344 ),
345 ),
346);