| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Role } from "@/AWS/IAM/Role.ts"; |
| 3 | import { Crl, Profile, TrustAnchor } from "@/AWS/RolesAnywhere"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as rolesanywhere from "@distilled.cloud/aws/rolesanywhere"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import type * as Duration from "effect/Duration"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import { |
| 11 | CA1_CERTIFICATE_PEM, |
| 12 | CA2_CERTIFICATE_PEM, |
| 13 | CRL1_PEM, |
| 14 | CRL2_PEM, |
| 15 | } from "./fixtures/certs.ts"; |
| 16 | |
| 17 | const { test } = Test.make({ providers: AWS.providers() }); |
| 18 | |
| 19 | // Ungated typed-error probe: prove the distilled error union carries the |
| 20 | // not-found tag this provider's read/delete paths depend on. |
| 21 | test.provider( |
| 22 | "getTrustAnchor on a bogus UUID fails with ResourceNotFoundException", |
| 23 | () => |
| 24 | Effect.gen(function* () { |
| 25 | const error = yield* Effect.flip( |
| 26 | rolesanywhere.getTrustAnchor({ |
| 27 | trustAnchorId: "00000000-0000-0000-0000-000000000000", |
| 28 | }), |
| 29 | ); |
| 30 | expect(error._tag).toBe("ResourceNotFoundException"); |
| 31 | }), |
| 32 | { tags: ["provider:aws", "provider:aws:rolesanywhere", "live"] }, |
| 33 | ); |
| 34 | |
| 35 | const trustPolicy: AWS.IAM.PolicyDocument = { |
| 36 | Version: "2012-10-17", |
| 37 | Statement: [ |
| 38 | { |
| 39 | Effect: "Allow" as const, |
| 40 | Principal: { Service: "rolesanywhere.amazonaws.com" }, |
| 41 | Action: ["sts:AssumeRole", "sts:TagSession", "sts:SetSourceIdentity"], |
| 42 | }, |
| 43 | ], |
| 44 | }; |
| 45 | |
| 46 | const sessionPolicy = JSON.stringify({ |
| 47 | Version: "2012-10-17", |
| 48 | Statement: [{ Effect: "Deny", Action: "s3:DeleteObject", Resource: "*" }], |
| 49 | }); |
| 50 | |
| 51 | /** Assert a resource is gone, retrying briefly through eventual consistency. */ |
| 52 | const untilGone = <A, E, R>( |
| 53 | get: Effect.Effect<A | undefined, E, R>, |
| 54 | ): Effect.Effect<void, E | Error, R> => |
| 55 | Effect.gen(function* () { |
| 56 | const found = yield* get; |
| 57 | if (found !== undefined) { |
| 58 | return yield* Effect.fail(new Error("resource still exists")); |
| 59 | } |
| 60 | }).pipe( |
| 61 | Effect.retry({ |
| 62 | schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(8)]), |
| 63 | }), |
| 64 | ); |
| 65 | |
| 66 | test.provider( |
| 67 | "trust anchor + profile + crl full lifecycle (create, update, replace, destroy)", |
| 68 | (stack) => |
| 69 | Effect.gen(function* () { |
| 70 | yield* stack.destroy(); |
| 71 | |
| 72 | const stackProgram = (props: { |
| 73 | anchorABundle: string; |
| 74 | anchorAEnabled: boolean; |
| 75 | anchorANotificationThreshold?: Duration.Input; |
| 76 | crlData: string; |
| 77 | crlEnabled: boolean; |
| 78 | crlOnAnchorB: boolean; |
| 79 | duration: Duration.Input; |
| 80 | sessionPolicy?: string; |
| 81 | subjectSpecifiers?: string[]; |
| 82 | }) => |
| 83 | Effect.gen(function* () { |
| 84 | const role = yield* Role("RolesAnywhereRole", { |
| 85 | assumeRolePolicyDocument: trustPolicy, |
| 86 | }); |
| 87 | const anchorA = yield* TrustAnchor("AnchorA", { |
| 88 | certificateBundle: props.anchorABundle, |
| 89 | enabled: props.anchorAEnabled, |
| 90 | notificationSettings: |
| 91 | props.anchorANotificationThreshold === undefined |
| 92 | ? undefined |
| 93 | : [ |
| 94 | { |
| 95 | event: "CA_CERTIFICATE_EXPIRY", |
| 96 | threshold: props.anchorANotificationThreshold, |
| 97 | }, |
| 98 | ], |
| 99 | tags: { fixture: "rolesanywhere" }, |
| 100 | }); |
| 101 | const anchorB = yield* TrustAnchor("AnchorB", { |
| 102 | certificateBundle: CA2_CERTIFICATE_PEM, |
| 103 | }); |
| 104 | const profile = yield* Profile("Profile", { |
| 105 | roleArns: [role.roleArn], |
| 106 | duration: props.duration, |
| 107 | sessionPolicy: props.sessionPolicy, |
| 108 | attributeMappings: |
| 109 | props.subjectSpecifiers === undefined |
| 110 | ? undefined |
| 111 | : [ |
| 112 | { |
| 113 | certificateField: "x509Subject", |
| 114 | mappingRules: props.subjectSpecifiers.map( |
| 115 | (specifier) => ({ specifier }), |
| 116 | ), |
| 117 | }, |
| 118 | ], |
| 119 | tags: { fixture: "rolesanywhere" }, |
| 120 | }); |
| 121 | const crl = yield* Crl("Crl", { |
| 122 | crlData: props.crlData, |
| 123 | trustAnchorArn: props.crlOnAnchorB |
| 124 | ? anchorB.trustAnchorArn |
| 125 | : anchorA.trustAnchorArn, |
| 126 | enabled: props.crlEnabled, |
| 127 | tags: { fixture: "rolesanywhere" }, |
| 128 | }); |
| 129 | return { role, anchorA, anchorB, profile, crl }; |
| 130 | }); |
| 131 | |
| 132 | // ── Step 1: greenfield create ───────────────────────────────────── |
| 133 | const first = yield* stack.deploy( |
| 134 | stackProgram({ |
| 135 | anchorABundle: CA1_CERTIFICATE_PEM, |
| 136 | anchorAEnabled: true, |
| 137 | anchorANotificationThreshold: "40 days", |
| 138 | crlData: CRL1_PEM, |
| 139 | crlEnabled: true, |
| 140 | crlOnAnchorB: false, |
| 141 | duration: "1 hour", |
| 142 | subjectSpecifiers: ["CN"], |
| 143 | }), |
| 144 | ); |
| 145 | |
| 146 | expect(first.anchorA.trustAnchorArn).toContain(":trust-anchor/"); |
| 147 | expect(first.anchorA.enabled).toBe(true); |
| 148 | expect(first.profile.profileArn).toContain(":profile/"); |
| 149 | expect(first.profile.enabled).toBe(true); |
| 150 | expect(first.profile.roleArns).toEqual([first.role.roleArn]); |
| 151 | expect(first.crl.crlArn).toContain(":crl/"); |
| 152 | expect(first.crl.trustAnchorArn).toBe(first.anchorA.trustAnchorArn); |
| 153 | expect(first.crl.enabled).toBe(true); |
| 154 | |
| 155 | // Out-of-band verification via distilled. |
| 156 | const observedAnchor = yield* rolesanywhere.getTrustAnchor({ |
| 157 | trustAnchorId: first.anchorA.trustAnchorId, |
| 158 | }); |
| 159 | expect(observedAnchor.trustAnchor.source?.sourceType).toBe( |
| 160 | "CERTIFICATE_BUNDLE", |
| 161 | ); |
| 162 | expect( |
| 163 | observedAnchor.trustAnchor.source?.sourceData !== undefined && |
| 164 | "x509CertificateData" in observedAnchor.trustAnchor.source.sourceData |
| 165 | ? observedAnchor.trustAnchor.source.sourceData.x509CertificateData?.trim() |
| 166 | : undefined, |
| 167 | ).toBe(CA1_CERTIFICATE_PEM.trim()); |
| 168 | const observedProfile = yield* rolesanywhere.getProfile({ |
| 169 | profileId: first.profile.profileId, |
| 170 | }); |
| 171 | expect(observedProfile.profile?.durationSeconds).toBe(3600); |
| 172 | expect(observedProfile.profile?.roleArns).toEqual([first.role.roleArn]); |
| 173 | // The custom attribute mapping landed (create-time put). |
| 174 | const firstSubjectMapping = |
| 175 | observedProfile.profile?.attributeMappings?.find( |
| 176 | (mapping) => mapping.certificateField === "x509Subject", |
| 177 | ); |
| 178 | expect( |
| 179 | firstSubjectMapping?.mappingRules?.map((rule) => rule.specifier), |
| 180 | ).toEqual(["CN"]); |
| 181 | // The custom notification setting landed (create-time settings). |
| 182 | const firstNotification = |
| 183 | observedAnchor.trustAnchor.notificationSettings?.find( |
| 184 | (setting) => setting.event === "CA_CERTIFICATE_EXPIRY", |
| 185 | ); |
| 186 | expect(firstNotification?.threshold).toBe(40); |
| 187 | |
| 188 | // ── Step 2: in-place updates on every mutable aspect ────────────── |
| 189 | const second = yield* stack.deploy( |
| 190 | stackProgram({ |
| 191 | anchorABundle: CA2_CERTIFICATE_PEM, // updateTrustAnchor source |
| 192 | anchorAEnabled: false, // disableTrustAnchor |
| 193 | anchorANotificationThreshold: "30 days", // putNotificationSettings |
| 194 | crlData: CRL2_PEM, // updateCrl data |
| 195 | crlEnabled: false, // disableCrl |
| 196 | crlOnAnchorB: false, |
| 197 | duration: "2 hours", // updateProfile |
| 198 | sessionPolicy, // updateProfile |
| 199 | subjectSpecifiers: ["CN", "OU"], // putAttributeMapping |
| 200 | }), |
| 201 | ); |
| 202 | |
| 203 | // Stable identifiers survived the update. |
| 204 | expect(second.anchorA.trustAnchorId).toBe(first.anchorA.trustAnchorId); |
| 205 | expect(second.profile.profileId).toBe(first.profile.profileId); |
| 206 | expect(second.crl.crlId).toBe(first.crl.crlId); |
| 207 | expect(second.anchorA.enabled).toBe(false); |
| 208 | expect(second.crl.enabled).toBe(false); |
| 209 | |
| 210 | const updatedAnchor = yield* rolesanywhere.getTrustAnchor({ |
| 211 | trustAnchorId: second.anchorA.trustAnchorId, |
| 212 | }); |
| 213 | expect(updatedAnchor.trustAnchor.enabled).toBe(false); |
| 214 | // putNotificationSettings converged the threshold. |
| 215 | const updatedNotification = |
| 216 | updatedAnchor.trustAnchor.notificationSettings?.find( |
| 217 | (setting) => setting.event === "CA_CERTIFICATE_EXPIRY", |
| 218 | ); |
| 219 | expect(updatedNotification?.threshold).toBe(30); |
| 220 | expect( |
| 221 | updatedAnchor.trustAnchor.source?.sourceData !== undefined && |
| 222 | "x509CertificateData" in updatedAnchor.trustAnchor.source.sourceData |
| 223 | ? updatedAnchor.trustAnchor.source.sourceData.x509CertificateData?.trim() |
| 224 | : undefined, |
| 225 | ).toBe(CA2_CERTIFICATE_PEM.trim()); |
| 226 | const updatedProfile = yield* rolesanywhere.getProfile({ |
| 227 | profileId: second.profile.profileId, |
| 228 | }); |
| 229 | expect(updatedProfile.profile?.durationSeconds).toBe(7200); |
| 230 | expect(updatedProfile.profile?.sessionPolicy).toBe(sessionPolicy); |
| 231 | // putAttributeMapping converged the mapping rules. |
| 232 | const updatedSubjectMapping = |
| 233 | updatedProfile.profile?.attributeMappings?.find( |
| 234 | (mapping) => mapping.certificateField === "x509Subject", |
| 235 | ); |
| 236 | expect( |
| 237 | updatedSubjectMapping?.mappingRules |
| 238 | ?.map((rule) => rule.specifier) |
| 239 | .sort(), |
| 240 | ).toEqual(["CN", "OU"]); |
| 241 | const updatedCrl = yield* rolesanywhere.getCrl({ |
| 242 | crlId: second.crl.crlId, |
| 243 | }); |
| 244 | expect(updatedCrl.crl.enabled).toBe(false); |
| 245 | expect( |
| 246 | new TextDecoder() |
| 247 | .decode(updatedCrl.crl.crlData ?? new Uint8Array()) |
| 248 | .trim(), |
| 249 | ).toBe(CRL2_PEM.trim()); |
| 250 | |
| 251 | // ── Step 3: moving the CRL to another trust anchor replaces it ──── |
| 252 | // (both anchors stay deployed across the replacement step). Dropping |
| 253 | // the attribute mapping / notification setting exercises |
| 254 | // deleteAttributeMapping and resetNotificationSettings. |
| 255 | const third = yield* stack.deploy( |
| 256 | stackProgram({ |
| 257 | anchorABundle: CA2_CERTIFICATE_PEM, |
| 258 | anchorAEnabled: false, |
| 259 | crlData: CRL2_PEM, |
| 260 | crlEnabled: false, |
| 261 | crlOnAnchorB: true, |
| 262 | duration: "2 hours", |
| 263 | sessionPolicy, |
| 264 | }), |
| 265 | ); |
| 266 | |
| 267 | expect(third.crl.crlId).not.toBe(second.crl.crlId); |
| 268 | expect(third.crl.trustAnchorArn).toBe(third.anchorB.trustAnchorArn); |
| 269 | |
| 270 | // The previously managed attribute mapping was deleted. |
| 271 | const finalProfile = yield* rolesanywhere.getProfile({ |
| 272 | profileId: third.profile.profileId, |
| 273 | }); |
| 274 | const finalSubjectMapping = finalProfile.profile?.attributeMappings?.find( |
| 275 | (mapping) => mapping.certificateField === "x509Subject", |
| 276 | ); |
| 277 | expect( |
| 278 | finalSubjectMapping?.mappingRules?.map((rule) => rule.specifier) ?? [], |
| 279 | ).not.toContain("OU"); |
| 280 | // The previously managed notification setting was reset to defaults. |
| 281 | const finalAnchor = yield* rolesanywhere.getTrustAnchor({ |
| 282 | trustAnchorId: third.anchorA.trustAnchorId, |
| 283 | }); |
| 284 | const finalNotification = |
| 285 | finalAnchor.trustAnchor.notificationSettings?.find( |
| 286 | (setting) => setting.event === "CA_CERTIFICATE_EXPIRY", |
| 287 | ); |
| 288 | expect(finalNotification?.threshold).not.toBe(30); |
| 289 | // The replaced CRL was deleted. |
| 290 | yield* untilGone( |
| 291 | rolesanywhere.getCrl({ crlId: second.crl.crlId }).pipe( |
| 292 | Effect.map((r) => r.crl), |
| 293 | Effect.catchTag("ResourceNotFoundException", () => |
| 294 | Effect.succeed(undefined), |
| 295 | ), |
| 296 | ), |
| 297 | ); |
| 298 | |
| 299 | // ── Destroy and verify everything is gone ───────────────────────── |
| 300 | yield* stack.destroy(); |
| 301 | yield* untilGone( |
| 302 | rolesanywhere |
| 303 | .getTrustAnchor({ trustAnchorId: third.anchorA.trustAnchorId }) |
| 304 | .pipe( |
| 305 | Effect.map((r) => r.trustAnchor), |
| 306 | Effect.catchTag("ResourceNotFoundException", () => |
| 307 | Effect.succeed(undefined), |
| 308 | ), |
| 309 | ), |
| 310 | ); |
| 311 | yield* untilGone( |
| 312 | rolesanywhere.getProfile({ profileId: third.profile.profileId }).pipe( |
| 313 | Effect.map((r) => r.profile), |
| 314 | Effect.catchTag("ResourceNotFoundException", () => |
| 315 | Effect.succeed(undefined), |
| 316 | ), |
| 317 | ), |
| 318 | ); |
| 319 | yield* untilGone( |
| 320 | rolesanywhere.getCrl({ crlId: third.crl.crlId }).pipe( |
| 321 | Effect.map((r) => r.crl), |
| 322 | Effect.catchTag("ResourceNotFoundException", () => |
| 323 | Effect.succeed(undefined), |
| 324 | ), |
| 325 | ), |
| 326 | ); |
| 327 | }), |
| 328 | { |
| 329 | tags: [ |
| 330 | "provider:aws", |
| 331 | "provider:aws:iam", |
| 332 | "provider:aws:rolesanywhere", |
| 333 | "live", |
| 334 | ], |
| 335 | timeout: 180_000, |
| 336 | }, |
| 337 | ); |