1import * as AWS from "@/AWS";
2import { Role } from "@/AWS/IAM/Role.ts";
3import { Crl, Profile, TrustAnchor } from "@/AWS/RolesAnywhere";
4import * as Test from "@/Test/Alchemy";
5import * as rolesanywhere from "@distilled.cloud/aws/rolesanywhere";
6import { expect } from "alchemy-test";
7import type * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import {
11 CA1_CERTIFICATE_PEM,
12 CA2_CERTIFICATE_PEM,
13 CRL1_PEM,
14 CRL2_PEM,
15} from "./fixtures/certs.ts";
16
17const { 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.
21test.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
35const 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
46const 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. */
52const 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
66test.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);