1import * as AWS from "@/AWS";
2import { OpenIDConnectProvider, Role, SAMLProvider } from "@/AWS/IAM";
3import * as Test from "@/Test/Alchemy";
4import * as IAM from "@distilled.cloud/aws/iam";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import {
8 testOidcGithubHost,
9 testOidcGithubUrl,
10 testOidcThumbprintA,
11 testOidcThumbprintB,
12 testOidcUrl,
13 testSamlMetadataDocument,
14 testSamlMetadataDocumentUpdated,
15} from "./fixtures.ts";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19describe(
20 "AWS.IAM federation resources",
21 { tags: ["provider:aws", "provider:aws:iam", "live"] },
22 () => {
23 test.provider(
24 "create, update, and delete an OpenID Connect provider",
25 (stack) =>
26 Effect.gen(function* () {
27 yield* stack.destroy();
28
29 const provider = yield* stack.deploy(
30 Effect.gen(function* () {
31 return yield* OpenIDConnectProvider("OidcProvider", {
32 url: testOidcUrl,
33 clientIDList: ["sts.amazonaws.com"],
34 thumbprintList: [testOidcThumbprintA],
35 tags: {
36 env: "test",
37 },
38 });
39 }),
40 );
41
42 const created = yield* IAM.getOpenIDConnectProvider({
43 OpenIDConnectProviderArn: provider.openIDConnectProviderArn,
44 });
45 expect(created.Url).toBe(testOidcUrl.replace(/^https?:\/\//, ""));
46 expect(created.ClientIDList ?? []).toContain("sts.amazonaws.com");
47
48 yield* stack.deploy(
49 Effect.gen(function* () {
50 return yield* OpenIDConnectProvider("OidcProvider", {
51 url: testOidcUrl,
52 clientIDList: ["sts.amazonaws.com", "alchemy-client"],
53 thumbprintList: [testOidcThumbprintB],
54 tags: {
55 env: "prod",
56 },
57 });
58 }),
59 );
60
61 const updated = yield* IAM.getOpenIDConnectProvider({
62 OpenIDConnectProviderArn: provider.openIDConnectProviderArn,
63 });
64 expect(updated.ClientIDList ?? []).toContain("alchemy-client");
65 expect(updated.ThumbprintList).toEqual([testOidcThumbprintB]);
66
67 const tags = yield* IAM.listOpenIDConnectProviderTags({
68 OpenIDConnectProviderArn: provider.openIDConnectProviderArn,
69 });
70 expect(
71 Object.fromEntries(
72 (tags.Tags ?? []).map((tag) => [tag.Key, tag.Value]),
73 ),
74 ).toMatchObject({
75 env: "prod",
76 });
77
78 yield* stack.destroy();
79
80 const deleted = yield* IAM.getOpenIDConnectProvider({
81 OpenIDConnectProviderArn: provider.openIDConnectProviderArn,
82 }).pipe(Effect.option);
83 expect(deleted._tag).toBe("None");
84 }),
85 );
86
87 // The GitHub Actions federation flagship: an OIDC provider plus a Role
88 // whose trust policy federates to it with a repo-scoped `sub` condition.
89 // The issuer is a stand-in (see fixtures.ts) — the account's real
90 // token.actions.githubusercontent.com provider belongs to CI and IAM
91 // allows only one provider per issuer URL — but the trust mechanics
92 // (Federated principal + AssumeRoleWithWebIdentity + aud/sub conditions)
93 // are identical.
94 test.provider(
95 "federates a role to a GitHub-Actions-style OIDC provider",
96 (stack) =>
97 Effect.gen(function* () {
98 yield* stack.destroy();
99
100 const outputs = yield* stack.deploy(
101 Effect.gen(function* () {
102 const oidc = yield* OpenIDConnectProvider("GithubStyleOidc", {
103 url: testOidcGithubUrl,
104 clientIDList: ["sts.amazonaws.com"],
105 thumbprintList: [testOidcThumbprintA],
106 });
107 const role = yield* Role("GithubDeployRole", {
108 assumeRolePolicyDocument: {
109 Version: "2012-10-17",
110 Statement: [
111 {
112 Effect: "Allow",
113 Principal: {
114 Federated: oidc.openIDConnectProviderArn,
115 },
116 Action: ["sts:AssumeRoleWithWebIdentity"],
117 Condition: {
118 StringEquals: {
119 [`${testOidcGithubHost}:aud`]: "sts.amazonaws.com",
120 },
121 StringLike: {
122 [`${testOidcGithubHost}:sub`]:
123 "repo:sam-goodwin/alchemy:*",
124 },
125 },
126 },
127 ],
128 },
129 });
130 return {
131 providerArn: oidc.openIDConnectProviderArn,
132 roleName: role.roleName,
133 roleArn: role.roleArn,
134 };
135 }),
136 );
137
138 // Assert the trust document out-of-band from the live role.
139 const live = yield* IAM.getRole({ RoleName: outputs.roleName });
140 const trust = JSON.parse(
141 decodeURIComponent(live.Role?.AssumeRolePolicyDocument ?? ""),
142 ) as {
143 Statement: [
144 {
145 Effect: string;
146 Principal: { Federated: string };
147 // IAM normalizes a single-element array to a bare string in
148 // the stored document.
149 Action: string | string[];
150 Condition: Record<string, Record<string, string>>;
151 },
152 ];
153 };
154 expect(trust.Statement).toHaveLength(1);
155 expect(trust.Statement[0].Effect).toBe("Allow");
156 expect(trust.Statement[0].Principal.Federated).toBe(
157 outputs.providerArn,
158 );
159 expect([trust.Statement[0].Action].flat()).toEqual([
160 "sts:AssumeRoleWithWebIdentity",
161 ]);
162 expect(
163 trust.Statement[0].Condition.StringEquals[
164 `${testOidcGithubHost}:aud`
165 ],
166 ).toBe("sts.amazonaws.com");
167 expect(
168 trust.Statement[0].Condition.StringLike[
169 `${testOidcGithubHost}:sub`
170 ],
171 ).toBe("repo:sam-goodwin/alchemy:*");
172
173 yield* stack.destroy();
174
175 const deletedRole = yield* IAM.getRole({
176 RoleName: outputs.roleName,
177 }).pipe(Effect.option);
178 expect(deletedRole._tag).toBe("None");
179 const deletedProvider = yield* IAM.getOpenIDConnectProvider({
180 OpenIDConnectProviderArn: outputs.providerArn,
181 }).pipe(Effect.option);
182 expect(deletedProvider._tag).toBe("None");
183 }),
184 { timeout: 120_000 },
185 );
186
187 test.provider("create, update, and delete a SAML provider", (stack) =>
188 Effect.gen(function* () {
189 yield* stack.destroy();
190
191 const provider = yield* stack.deploy(
192 Effect.gen(function* () {
193 return yield* SAMLProvider("SamlProvider", {
194 samlMetadataDocument: testSamlMetadataDocument,
195 tags: {
196 env: "test",
197 },
198 });
199 }),
200 );
201
202 const created = yield* IAM.getSAMLProvider({
203 SAMLProviderArn: provider.samlProviderArn,
204 });
205 expect(created.SAMLMetadataDocument).toContain("urn:alchemy:test:idp");
206
207 yield* stack.deploy(
208 Effect.gen(function* () {
209 return yield* SAMLProvider("SamlProvider", {
210 samlMetadataDocument: testSamlMetadataDocumentUpdated,
211 tags: {
212 env: "prod",
213 },
214 });
215 }),
216 );
217
218 const updated = yield* IAM.getSAMLProvider({
219 SAMLProviderArn: provider.samlProviderArn,
220 });
221 expect(updated.SAMLMetadataDocument).toContain(
222 "urn:alchemy:test:idp:updated",
223 );
224
225 const tags = yield* IAM.listSAMLProviderTags({
226 SAMLProviderArn: provider.samlProviderArn,
227 });
228 expect(
229 Object.fromEntries(
230 (tags.Tags ?? []).map((tag) => [tag.Key, tag.Value]),
231 ),
232 ).toMatchObject({
233 env: "prod",
234 });
235
236 yield* stack.destroy();
237
238 const deleted = yield* IAM.getSAMLProvider({
239 SAMLProviderArn: provider.samlProviderArn,
240 }).pipe(Effect.option);
241 expect(deleted._tag).toBe("None");
242 }),
243 );
244 },
245);