| 1 | import * as AWS from "@/AWS"; |
| 2 | import { OpenIDConnectProvider, Role, SAMLProvider } from "@/AWS/IAM"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as IAM from "@distilled.cloud/aws/iam"; |
| 5 | import { describe, expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import { |
| 8 | testOidcGithubHost, |
| 9 | testOidcGithubUrl, |
| 10 | testOidcThumbprintA, |
| 11 | testOidcThumbprintB, |
| 12 | testOidcUrl, |
| 13 | testSamlMetadataDocument, |
| 14 | testSamlMetadataDocumentUpdated, |
| 15 | } from "./fixtures.ts"; |
| 16 | |
| 17 | const { test } = Test.make({ providers: AWS.providers() }); |
| 18 | |
| 19 | describe( |
| 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 | ); |