1import { adopt } from "@/AdoptPolicy";
2import * as AWS from "@/AWS";
3import { Role } from "@/AWS/IAM";
4import * as Provider from "@/Provider";
5import { State } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import * as IAM from "@distilled.cloud/aws/iam";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const assumeRolePolicy = {
14 Version: "2012-10-17" as const,
15 Statement: [
16 {
17 Effect: "Allow" as const,
18 Principal: {
19 Service: "lambda.amazonaws.com",
20 },
21 Action: ["sts:AssumeRole"],
22 },
23 ],
24};
25
26test.provider(
27 "create, update, and delete role",
28 (stack) =>
29 Effect.gen(function* () {
30 yield* stack.destroy();
31
32 const role = yield* stack.deploy(
33 Effect.gen(function* () {
34 return yield* Role("IamRole", {
35 assumeRolePolicyDocument: assumeRolePolicy,
36 maxSessionDuration: "2 hours",
37 managedPolicyArns: [
38 "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
39 ],
40 inlinePolicies: {
41 AllowLogs: {
42 Version: "2012-10-17",
43 Statement: [
44 {
45 Effect: "Allow",
46 Action: ["logs:CreateLogGroup"],
47 Resource: "*",
48 },
49 ],
50 },
51 },
52 tags: {
53 env: "test",
54 },
55 });
56 }),
57 );
58
59 const created = yield* IAM.getRole({
60 RoleName: role.roleName,
61 });
62 expect(created.Role.RoleName).toBe(role.roleName);
63 // Duration.Input prop converts to whole wire seconds.
64 expect(created.Role.MaxSessionDuration).toBe(7200);
65
66 yield* stack.deploy(
67 Effect.gen(function* () {
68 return yield* Role("IamRole", {
69 assumeRolePolicyDocument: assumeRolePolicy,
70 maxSessionDuration: "1 hour",
71 managedPolicyArns: [],
72 inlinePolicies: {
73 AllowLogs: {
74 Version: "2012-10-17",
75 Statement: [
76 {
77 Effect: "Allow",
78 Action: ["logs:CreateLogStream"],
79 Resource: "*",
80 },
81 ],
82 },
83 },
84 tags: {
85 env: "prod",
86 },
87 });
88 }),
89 );
90
91 const updatedTags = yield* IAM.listRoleTags({
92 RoleName: role.roleName,
93 });
94 expect(
95 Object.fromEntries(
96 (updatedTags.Tags ?? []).map((tag) => [tag.Key, tag.Value]),
97 ),
98 ).toMatchObject({
99 env: "prod",
100 });
101
102 // The reconcile sync path applies the changed Duration prop.
103 const updated = yield* IAM.getRole({ RoleName: role.roleName });
104 expect(updated.Role.MaxSessionDuration).toBe(3600);
105
106 yield* stack.destroy();
107
108 const deleted = yield* IAM.getRole({
109 RoleName: role.roleName,
110 }).pipe(Effect.option);
111 expect(deleted._tag).toBe("None");
112 }),
113 { tags: ["provider:aws", "provider:aws:iam", "live"] },
114);
115
116test.provider(
117 "folds binding-supplied policy statements into an inline policy",
118 (stack) =>
119 Effect.gen(function* () {
120 yield* stack.destroy();
121
122 // Phase 1: a consumer binds policy statements to the role; the provider
123 // folds them into the synthesized `alchemy-bindings` inline policy.
124 const role = yield* stack.deploy(
125 Effect.gen(function* () {
126 const role = yield* Role("BoundRole", {
127 assumeRolePolicyDocument: assumeRolePolicy,
128 });
129 yield* role.bind`Allow(test, s3:GetObject)`({
130 policyStatements: [
131 {
132 Effect: "Allow",
133 Action: ["s3:GetObject"],
134 Resource: ["arn:aws:s3:::example-bucket/*"],
135 },
136 ],
137 });
138 return role;
139 }),
140 );
141
142 const bound = yield* IAM.getRolePolicy({
143 RoleName: role.roleName,
144 PolicyName: "alchemy-bindings",
145 });
146 const boundDoc = JSON.parse(decodeURIComponent(bound.PolicyDocument)) as {
147 Statement: Array<{ Action: string[]; Resource: string[] }>;
148 };
149 expect(boundDoc.Statement).toEqual(
150 expect.arrayContaining([
151 expect.objectContaining({
152 Action: ["s3:GetObject"],
153 Resource: ["arn:aws:s3:::example-bucket/*"],
154 }),
155 ]),
156 );
157
158 // Phase 2: re-deploy without the binding; the inline policy is removed.
159 yield* stack.deploy(
160 Effect.gen(function* () {
161 return yield* Role("BoundRole", {
162 assumeRolePolicyDocument: assumeRolePolicy,
163 });
164 }),
165 );
166
167 const afterUnbind = yield* IAM.getRolePolicy({
168 RoleName: role.roleName,
169 PolicyName: "alchemy-bindings",
170 }).pipe(Effect.option);
171 expect(afterUnbind._tag).toBe("None");
172
173 yield* stack.destroy();
174 }),
175 { tags: ["provider:aws", "provider:aws:iam", "live"] },
176);
177
178test.provider(
179 "bindings can supply the role's trust policy",
180 (stack) =>
181 Effect.gen(function* () {
182 yield* stack.destroy();
183
184 // A bare role with no `assumeRolePolicyDocument`; the binding supplies the
185 // trust statement instead.
186 const role = yield* stack.deploy(
187 Effect.gen(function* () {
188 const role = yield* Role("TrustBoundRole", {});
189 yield* role.bind`Trust(test, lambda)`({
190 assumeRolePolicyStatements: [
191 {
192 Effect: "Allow",
193 Principal: { Service: "lambda.amazonaws.com" },
194 Action: ["sts:AssumeRole"],
195 },
196 ],
197 });
198 return role;
199 }),
200 );
201
202 const live = yield* IAM.getRole({ RoleName: role.roleName });
203 const trust = JSON.parse(
204 decodeURIComponent(live.Role.AssumeRolePolicyDocument!),
205 ) as { Statement: Array<{ Principal?: { Service?: string } }> };
206 expect(trust.Statement).toEqual(
207 expect.arrayContaining([
208 expect.objectContaining({
209 Principal: { Service: "lambda.amazonaws.com" },
210 }),
211 ]),
212 );
213
214 yield* stack.destroy();
215 }),
216 { tags: ["provider:aws", "provider:aws:iam", "live"] },
217);
218
219test.provider(
220 "list enumerates the deployed role",
221 (stack) =>
222 Effect.gen(function* () {
223 yield* stack.destroy();
224
225 const role = yield* stack.deploy(
226 Effect.gen(function* () {
227 return yield* Role("ListRole", {
228 assumeRolePolicyDocument: assumeRolePolicy,
229 tags: {
230 env: "test",
231 },
232 });
233 }),
234 );
235
236 const provider = yield* Provider.findProvider(Role);
237 const all = yield* provider.list();
238
239 const found = all.find((r) => r.roleName === role.roleName);
240 expect(found).toBeDefined();
241 expect(found?.roleArn).toBe(role.roleArn);
242
243 yield* stack.destroy();
244
245 const deleted = yield* IAM.getRole({
246 RoleName: role.roleName,
247 }).pipe(Effect.option);
248 expect(deleted._tag).toBe("None");
249 }),
250 { tags: ["provider:aws", "provider:aws:iam", "live"] },
251);
252
253// Engine-level adoption tests for IAM Role. Note: the user must supply an
254// explicit `roleName` for adoption to work across a state-store wipe —
255// without one, `createPhysicalName` derives a fresh name from a per-deploy
256// random `instanceId`, so the cold-start `read` lookup would never find
257// the original role.
258//
259// These tests wipe the state store mid-test, so the framework's automatic
260// scratch-stack teardown cannot always see the role. Each test therefore
261// uses a DETERMINISTIC role name, pre-cleans any leftover from a previously
262// killed run, and guarantees an idempotent out-of-band delete via
263// `Effect.ensuring` so a pass (or interruption) never orphans the role.
264const deleteRoleIfExists = (roleName: string) =>
265 IAM.deleteRole({ RoleName: roleName }).pipe(
266 Effect.catchTag("NoSuchEntityException", () => Effect.void),
267 Effect.asVoid,
268 // Any non-not-found failure is a real cleanup bug — surface it loudly.
269 Effect.orDie,
270 );
271
272test.provider(
273 "owned role (matching alchemy tags) is silently adopted without --adopt",
274 (stack) =>
275 Effect.gen(function* () {
276 yield* stack.destroy();
277
278 const roleName = "alchemy-test-role-adopt";
279
280 // Reclaim a leftover from a previously killed run.
281 yield* deleteRoleIfExists(roleName);
282
283 const initial = yield* stack.deploy(
284 Effect.gen(function* () {
285 return yield* Role("AdoptableRole", {
286 roleName,
287 assumeRolePolicyDocument: assumeRolePolicy,
288 });
289 }),
290 );
291
292 // Wipe state — the role stays in IAM.
293 yield* Effect.gen(function* () {
294 const state = yield* yield* State;
295 yield* state.delete({
296 stack: stack.name,
297 stage: stack.stage,
298 fqn: "AdoptableRole",
299 });
300 }).pipe(Effect.provide(stack.state));
301
302 const adopted = yield* stack.deploy(
303 Effect.gen(function* () {
304 return yield* Role("AdoptableRole", {
305 roleName,
306 assumeRolePolicyDocument: assumeRolePolicy,
307 });
308 }),
309 );
310
311 expect(adopted.roleArn).toEqual(initial.roleArn);
312 expect(adopted.roleName).toEqual(roleName);
313
314 yield* stack.destroy();
315
316 const deleted = yield* IAM.getRole({ RoleName: roleName }).pipe(
317 Effect.option,
318 );
319 expect(deleted._tag).toBe("None");
320 }).pipe(Effect.ensuring(deleteRoleIfExists("alchemy-test-role-adopt"))),
321 { tags: ["provider:aws", "provider:aws:iam", "live"] },
322);
323
324test.provider(
325 "foreign-tagged role requires adopt(true) to take over",
326 (stack) =>
327 Effect.gen(function* () {
328 yield* stack.destroy();
329
330 // Use a deterministic shared physical name for both deploys.
331 const sharedRoleName = "alchemy-test-role-takeover";
332
333 // Reclaim a leftover from a previously killed run.
334 yield* deleteRoleIfExists(sharedRoleName);
335
336 const original = yield* stack.deploy(
337 Effect.gen(function* () {
338 return yield* Role("Original", {
339 roleName: sharedRoleName,
340 assumeRolePolicyDocument: assumeRolePolicy,
341 });
342 }),
343 );
344
345 yield* Effect.gen(function* () {
346 const state = yield* yield* State;
347 yield* state.delete({
348 stack: stack.name,
349 stage: stack.stage,
350 fqn: "Original",
351 });
352 }).pipe(Effect.provide(stack.state));
353
354 const takenOver = yield* stack
355 .deploy(
356 Effect.gen(function* () {
357 return yield* Role("Different", {
358 roleName: sharedRoleName,
359 assumeRolePolicyDocument: assumeRolePolicy,
360 });
361 }),
362 )
363 .pipe(adopt(true));
364
365 expect(takenOver.roleArn).toEqual(original.roleArn);
366
367 // After adoption, tags should now identify this stack/stage/id.
368 const tagsResp = yield* IAM.listRoleTags({ RoleName: sharedRoleName });
369 const tagMap = Object.fromEntries(
370 (tagsResp.Tags ?? []).map((t) => [t.Key, t.Value]),
371 );
372 expect(tagMap["alchemy::id"]).toEqual("Different");
373
374 yield* stack.destroy();
375
376 const deleted = yield* IAM.getRole({ RoleName: sharedRoleName }).pipe(
377 Effect.option,
378 );
379 expect(deleted._tag).toBe("None");
380 }).pipe(Effect.ensuring(deleteRoleIfExists("alchemy-test-role-takeover"))),
381 { tags: ["provider:aws", "provider:aws:iam", "live"] },
382);