1import * as rolesanywhere from "@distilled.cloud/aws/rolesanywhere";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 readRolesAnywhereTags,
13 syncRolesAnywhereTags,
14 toWireTags,
15} from "./internal.ts";
16
17export interface CrlProps {
18 /**
19 * Name of the CRL. If omitted, a unique name is generated from the app,
20 * stage and logical ID. The name is updatable in place.
21 */
22 crlName?: string;
23 /**
24 * PEM-encoded certificate revocation list issued by the trust anchor's CA.
25 * IAM Roles Anywhere validates presented certificates against the CRL
26 * before issuing credentials.
27 */
28 crlData: string;
29 /**
30 * ARN of the trust anchor the CRL is associated with. Immutable after
31 * import — changing it replaces the CRL.
32 */
33 trustAnchorArn: string;
34 /**
35 * Whether the CRL is enabled. When enabled, certificates listed in the CRL
36 * are unauthorized to receive session credentials.
37 * @default true
38 */
39 enabled?: boolean;
40 /**
41 * User-defined tags for the CRL.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface Crl extends Resource<
47 "AWS.RolesAnywhere.Crl",
48 CrlProps,
49 {
50 /**
51 * Unique ID of the CRL.
52 */
53 crlId: string;
54 /**
55 * ARN of the CRL.
56 */
57 crlArn: string;
58 /**
59 * Name of the CRL.
60 */
61 crlName: string;
62 /**
63 * ARN of the trust anchor the CRL applies to.
64 */
65 trustAnchorArn: string;
66 /**
67 * Whether the CRL is enabled (revocation checks are enforced).
68 */
69 enabled: boolean;
70 },
71 never,
72 Providers
73> {}
74
75/**
76 * An IAM Roles Anywhere certificate revocation list (CRL). A CRL is a
77 * PEM-encoded list of certificates revoked by the trust anchor's certificate
78 * authority; IAM Roles Anywhere refuses to vend credentials for revoked
79 * certificates while the CRL is enabled.
80 * ### Importing a CRL
81 * **Example:** CRL for a Trust Anchor
82 * ```typescript
83 * const anchor = yield* RolesAnywhere.TrustAnchor("Anchor", {
84 * certificateBundle: CA_CERTIFICATE_PEM,
85 * });
86 * const crl = yield* RolesAnywhere.Crl("Crl", {
87 * crlData: CRL_PEM,
88 * trustAnchorArn: anchor.trustAnchorArn,
89 * });
90 * ```
91 *
92 * ### Rotating the CRL
93 * **Example:** Updated Revocation Data
94 * ```typescript
95 * const crl = yield* RolesAnywhere.Crl("Crl", {
96 * crlData: NEXT_CRL_PEM, // re-deploy with the CA's latest CRL
97 * trustAnchorArn: anchor.trustAnchorArn,
98 * });
99 * ```
100 *
101 * @resource
102 */
103export const Crl = Resource<Crl>("AWS.RolesAnywhere.Crl");
104
105const toAttrs = (detail: rolesanywhere.CrlDetail) => ({
106 crlId: detail.crlId!,
107 crlArn: detail.crlArn!,
108 crlName: detail.name!,
109 trustAnchorArn: detail.trustAnchorArn!,
110 enabled: detail.enabled ?? false,
111});
112
113const decodeCrlData = (data: Uint8Array | undefined): string | undefined =>
114 data === undefined ? undefined : new TextDecoder().decode(data);
115
116export const CrlProvider = () =>
117 Provider.effect(
118 Crl,
119 Effect.gen(function* () {
120 const createName = Effect.fn(function* (
121 id: string,
122 props: Partial<CrlProps>,
123 ) {
124 return (
125 props.crlName ?? (yield* createPhysicalName({ id, maxLength: 255 }))
126 );
127 });
128
129 /** Find a CRL by its user-facing name across all pages. */
130 const findByName = (name: string) =>
131 rolesanywhere.listCrls.items({}).pipe(
132 Stream.filter((detail) => detail.name === name),
133 Stream.runHead,
134 Effect.map((head) => (head._tag === "Some" ? head.value : undefined)),
135 );
136
137 const getById = (crlId: string) =>
138 rolesanywhere.getCrl({ crlId }).pipe(
139 Effect.map((r) => r.crl),
140 Effect.catchTag("ResourceNotFoundException", () =>
141 Effect.succeed(undefined),
142 ),
143 );
144
145 return {
146 stables: ["crlId", "crlArn"],
147
148 diff: Effect.fn(function* ({ olds, news }) {
149 if (!isResolved(news)) return undefined;
150 // updateCrl only accepts name + crlData — the trust anchor
151 // association is immutable, so a change forces a replacement.
152 if (
153 olds?.trustAnchorArn !== undefined &&
154 olds.trustAnchorArn !== news.trustAnchorArn
155 ) {
156 return { action: "replace" } as const;
157 }
158 }),
159
160 read: Effect.fn(function* ({ id, olds, output }) {
161 const found = output?.crlId
162 ? yield* getById(output.crlId)
163 : yield* findByName(yield* createName(id, olds ?? {}));
164 if (found === undefined) return undefined;
165 const attrs = toAttrs(found);
166 const tags = yield* readRolesAnywhereTags(attrs.crlArn);
167 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
168 }),
169
170 reconcile: Effect.fn(function* ({ id, news, output, session }) {
171 const name = output?.crlName ?? (yield* createName(id, news));
172 const internalTags = yield* createInternalTags(id);
173 const desiredTags = { ...internalTags, ...news.tags };
174 const desiredEnabled = news.enabled ?? true;
175 const desiredPem = news.crlData.trim();
176 const encodePem = Effect.sync(() =>
177 new TextEncoder().encode(desiredPem),
178 );
179
180 // 1. Observe — cloud state is authoritative; output caches the id.
181 let live = output?.crlId
182 ? yield* getById(output.crlId)
183 : yield* findByName(name);
184
185 // 2. Ensure — import if missing.
186 if (live === undefined) {
187 const imported = yield* rolesanywhere.importCrl({
188 name,
189 crlData: yield* encodePem,
190 trustAnchorArn: news.trustAnchorArn,
191 enabled: desiredEnabled,
192 tags: toWireTags(desiredTags),
193 });
194 live = imported.crl;
195 } else {
196 // 3. Sync — converge name and revocation data.
197 const desiredName = news.crlName ?? live.name!;
198 const observedPem = decodeCrlData(live.crlData)?.trim();
199 if (live.name !== desiredName || observedPem !== desiredPem) {
200 const updated = yield* rolesanywhere.updateCrl({
201 crlId: live.crlId!,
202 name: desiredName,
203 crlData: yield* encodePem,
204 });
205 live = updated.crl;
206 }
207 }
208
209 if ((live.enabled ?? false) !== desiredEnabled) {
210 // The enable/disable response can echo the pre-toggle state — the
211 // successful call itself is authoritative for the flag.
212 yield* desiredEnabled
213 ? rolesanywhere.enableCrl({ crlId: live.crlId! })
214 : rolesanywhere.disableCrl({ crlId: live.crlId! });
215 live = { ...live, enabled: desiredEnabled };
216 }
217
218 // 3b. Sync tags against observed cloud tags.
219 yield* syncRolesAnywhereTags(live.crlArn!, desiredTags);
220
221 yield* session.note(live.crlId!);
222 return toAttrs(live);
223 }),
224
225 delete: Effect.fn(function* ({ output }) {
226 yield* rolesanywhere
227 .deleteCrl({ crlId: output.crlId })
228 .pipe(
229 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
230 );
231 }),
232
233 list: () =>
234 rolesanywhere.listCrls.items({}).pipe(
235 Stream.map(toAttrs),
236 Stream.runCollect,
237 Effect.map((chunk) => Array.from(chunk)),
238 ),
239 };
240 }),
241 );