1import * as rolesanywhere from "@distilled.cloud/aws/rolesanywhere";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import { toWireDays } from "../../Util/Duration.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 readRolesAnywhereTags,
15 syncRolesAnywhereTags,
16 toWireTags,
17} from "./internal.ts";
18
19/**
20 * Raised before any AWS call when the trust anchor's source is misconfigured
21 * — exactly one of `certificateBundle` or `acmPcaArn` must be provided.
22 */
23export class TrustAnchorSourceConflict extends Data.TaggedError(
24 "TrustAnchorSourceConflict",
25)<{ readonly message: string }> {}
26
27/**
28 * A customized expiry notification for the trust anchor. AWS installs
29 * default notifications (45 days before CA and end-entity certificate
30 * expiry); declaring a setting for the same event/channel overrides the
31 * default, and removing it resets the event back to the AWS default.
32 */
33export interface TrustAnchorNotificationSetting {
34 /**
35 * The expiry event to notify on: `CA_CERTIFICATE_EXPIRY` or
36 * `END_ENTITY_CERTIFICATE_EXPIRY`.
37 */
38 event: string;
39 /**
40 * Whether the notification is enabled.
41 * @default true
42 */
43 enabled?: boolean;
44 /**
45 * How far ahead of the expiry event to notify, e.g. `"30 days"` (a bare
46 * number is milliseconds). Rounded to whole days on the wire.
47 * @default "45 days"
48 */
49 threshold?: Duration.Input;
50 /**
51 * The notification channel. `ALL` (the only channel today) sends through
52 * both AWS Health Dashboard and email.
53 * @default "ALL"
54 */
55 channel?: string;
56}
57
58export interface TrustAnchorProps {
59 /**
60 * Name of the trust anchor. If omitted, a unique name is generated from the
61 * app, stage and logical ID. The name is updatable in place.
62 */
63 trustAnchorName?: string;
64 /**
65 * PEM-encoded CA certificate bundle establishing the trust root
66 * (`CERTIFICATE_BUNDLE` source). Exactly one of `certificateBundle` or
67 * `acmPcaArn` must be provided.
68 */
69 certificateBundle?: string;
70 /**
71 * ARN of an AWS Private CA (`AWS_ACM_PCA` source) establishing the trust
72 * root. Exactly one of `certificateBundle` or `acmPcaArn` must be provided.
73 */
74 acmPcaArn?: string;
75 /**
76 * Whether the trust anchor is enabled for trust validation. When disabled,
77 * temporary credential requests specifying this trust anchor are rejected.
78 * @default true
79 */
80 enabled?: boolean;
81 /**
82 * Customized certificate-expiry notifications. Events omitted here keep
83 * their AWS default notification; a setting previously managed by this
84 * resource and later removed is reset to the AWS default.
85 */
86 notificationSettings?: TrustAnchorNotificationSetting[];
87 /**
88 * User-defined tags for the trust anchor.
89 */
90 tags?: Record<string, string>;
91}
92
93export interface TrustAnchor extends Resource<
94 "AWS.RolesAnywhere.TrustAnchor",
95 TrustAnchorProps,
96 {
97 /**
98 * Unique ID of the trust anchor.
99 */
100 trustAnchorId: string;
101 /**
102 * ARN of the trust anchor.
103 */
104 trustAnchorArn: string;
105 /**
106 * Name of the trust anchor.
107 */
108 trustAnchorName: string;
109 /**
110 * Whether the trust anchor is enabled.
111 */
112 enabled: boolean;
113 },
114 never,
115 Providers
116> {}
117
118/**
119 * An IAM Roles Anywhere trust anchor. A trust anchor establishes trust
120 * between IAM Roles Anywhere and your certificate authority (CA) — either an
121 * uploaded PEM CA certificate bundle or a reference to an AWS Private CA.
122 * Workloads outside AWS authenticate with certificates issued by the CA in
123 * exchange for temporary AWS credentials.
124 * ### Creating a Trust Anchor
125 * **Example:** Certificate Bundle Trust Anchor
126 * ```typescript
127 * const anchor = yield* RolesAnywhere.TrustAnchor("Anchor", {
128 * certificateBundle: CA_CERTIFICATE_PEM,
129 * });
130 * ```
131 *
132 * **Example:** AWS Private CA Trust Anchor
133 * ```typescript
134 * const anchor = yield* RolesAnywhere.TrustAnchor("Anchor", {
135 * acmPcaArn: privateCa.certificateAuthorityArn,
136 * });
137 * ```
138 *
139 * ### Disabling a Trust Anchor
140 * **Example:** Disabled Trust Anchor
141 * ```typescript
142 * const anchor = yield* RolesAnywhere.TrustAnchor("Anchor", {
143 * certificateBundle: CA_CERTIFICATE_PEM,
144 * enabled: false,
145 * });
146 * ```
147 *
148 * ### Expiry Notifications
149 * **Example:** Custom Notification Threshold
150 * ```typescript
151 * const anchor = yield* RolesAnywhere.TrustAnchor("Anchor", {
152 * certificateBundle: CA_CERTIFICATE_PEM,
153 * notificationSettings: [
154 * { event: "CA_CERTIFICATE_EXPIRY", threshold: "30 days" },
155 * ],
156 * });
157 * ```
158 *
159 * @resource
160 */
161export const TrustAnchor = Resource<TrustAnchor>(
162 "AWS.RolesAnywhere.TrustAnchor",
163);
164
165const toAttrs = (detail: rolesanywhere.TrustAnchorDetail) => ({
166 trustAnchorId: detail.trustAnchorId!,
167 trustAnchorArn: detail.trustAnchorArn!,
168 trustAnchorName: detail.name!,
169 enabled: detail.enabled ?? false,
170});
171
172const desiredSource = Effect.fn(function* (props: TrustAnchorProps) {
173 if (
174 (props.certificateBundle === undefined) ===
175 (props.acmPcaArn === undefined)
176 ) {
177 return yield* new TrustAnchorSourceConflict({
178 message: "exactly one of certificateBundle or acmPcaArn must be provided",
179 });
180 }
181 return props.certificateBundle !== undefined
182 ? {
183 sourceType: "CERTIFICATE_BUNDLE",
184 sourceData: { x509CertificateData: props.certificateBundle.trim() },
185 }
186 : {
187 sourceType: "AWS_ACM_PCA",
188 sourceData: { acmPcaArn: props.acmPcaArn! },
189 };
190});
191
192/** The key AWS uses to identify a notification setting. */
193const notificationKey = (setting: { event: string; channel?: string }) =>
194 `${setting.event}|${setting.channel ?? "ALL"}`;
195
196const toWireNotificationSettings = (
197 settings: ReadonlyArray<TrustAnchorNotificationSetting>,
198): rolesanywhere.NotificationSetting[] =>
199 settings.map((setting) => ({
200 enabled: setting.enabled ?? true,
201 event: setting.event,
202 threshold: toWireDays(setting.threshold),
203 channel: setting.channel,
204 }));
205
206const sourceDrift = (
207 observed: rolesanywhere.Source | undefined,
208 desired: { sourceType: string; sourceData: rolesanywhere.SourceData },
209) => {
210 if (observed === undefined) return true;
211 if (observed.sourceType !== desired.sourceType) return true;
212 const observedData = observed.sourceData;
213 const desiredData = desired.sourceData;
214 if ("x509CertificateData" in desiredData) {
215 return (
216 observedData === undefined ||
217 !("x509CertificateData" in observedData) ||
218 observedData.x509CertificateData?.trim() !==
219 desiredData.x509CertificateData
220 );
221 }
222 return (
223 observedData === undefined ||
224 !("acmPcaArn" in observedData) ||
225 observedData.acmPcaArn !== desiredData.acmPcaArn
226 );
227};
228
229export const TrustAnchorProvider = () =>
230 Provider.effect(
231 TrustAnchor,
232 Effect.gen(function* () {
233 const createName = Effect.fn(function* (
234 id: string,
235 props: TrustAnchorProps,
236 ) {
237 return (
238 props.trustAnchorName ??
239 (yield* createPhysicalName({ id, maxLength: 255 }))
240 );
241 });
242
243 /** Find a trust anchor by its user-facing name across all pages. */
244 const findByName = (name: string) =>
245 rolesanywhere.listTrustAnchors.items({}).pipe(
246 Stream.filter((detail) => detail.name === name),
247 Stream.runHead,
248 Effect.map((head) => (head._tag === "Some" ? head.value : undefined)),
249 );
250
251 const getById = (trustAnchorId: string) =>
252 rolesanywhere.getTrustAnchor({ trustAnchorId }).pipe(
253 Effect.map((r) => r.trustAnchor),
254 Effect.catchTag("ResourceNotFoundException", () =>
255 Effect.succeed(undefined),
256 ),
257 );
258
259 return {
260 stables: ["trustAnchorId", "trustAnchorArn"],
261
262 read: Effect.fn(function* ({ id, olds, output }) {
263 const found = output?.trustAnchorId
264 ? yield* getById(output.trustAnchorId)
265 : yield* findByName(yield* createName(id, olds ?? {}));
266 if (found === undefined) return undefined;
267 const attrs = toAttrs(found);
268 const tags = yield* readRolesAnywhereTags(attrs.trustAnchorArn);
269 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
270 }),
271
272 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
273 const name = output?.trustAnchorName ?? (yield* createName(id, news));
274 const internalTags = yield* createInternalTags(id);
275 const desiredTags = { ...internalTags, ...news.tags };
276 const source = yield* desiredSource(news);
277 const desiredEnabled = news.enabled ?? true;
278 const desiredNotifications = toWireNotificationSettings(
279 news.notificationSettings ?? [],
280 );
281
282 // 1. Observe — cloud state is authoritative; output caches the id.
283 let live = output?.trustAnchorId
284 ? yield* getById(output.trustAnchorId)
285 : yield* findByName(name);
286
287 // 2. Ensure — create if missing.
288 if (live === undefined) {
289 const created = yield* rolesanywhere.createTrustAnchor({
290 name,
291 source,
292 enabled: desiredEnabled,
293 tags: toWireTags(desiredTags),
294 notificationSettings:
295 desiredNotifications.length > 0
296 ? desiredNotifications
297 : undefined,
298 });
299 live = created.trustAnchor;
300 } else {
301 // 3. Sync — converge name/source, then the enabled flag.
302 const desiredName = news.trustAnchorName ?? live.name!;
303 if (live.name !== desiredName || sourceDrift(live.source, source)) {
304 const updated = yield* rolesanywhere.updateTrustAnchor({
305 trustAnchorId: live.trustAnchorId!,
306 name: desiredName,
307 source,
308 });
309 live = updated.trustAnchor;
310 }
311 }
312
313 // 3a. Sync notification settings — diff the OBSERVED settings
314 // against the desired set. AWS installs default settings that must
315 // not be touched unless the user manages that event/channel, so
316 // resets are driven by the settings previously declared in `olds`
317 // rather than full replacement of observed state.
318 const observedNotifications = new Map(
319 (live.notificationSettings ?? []).map((setting) => [
320 notificationKey(setting),
321 setting,
322 ]),
323 );
324 const notificationDrift = desiredNotifications.some((desired) => {
325 const observed = observedNotifications.get(
326 notificationKey(desired),
327 );
328 return (
329 observed === undefined ||
330 observed.enabled !== desired.enabled ||
331 (desired.threshold !== undefined &&
332 observed.threshold !== desired.threshold)
333 );
334 });
335 if (notificationDrift) {
336 const updated = yield* rolesanywhere.putNotificationSettings({
337 trustAnchorId: live.trustAnchorId!,
338 notificationSettings: desiredNotifications,
339 });
340 live = updated.trustAnchor;
341 }
342 const desiredNotificationKeys = new Set(
343 desiredNotifications.map(notificationKey),
344 );
345 const resetKeys = (olds?.notificationSettings ?? [])
346 .filter(
347 (previous) =>
348 !desiredNotificationKeys.has(notificationKey(previous)),
349 )
350 .map((previous) => ({
351 event: previous.event,
352 channel: previous.channel,
353 }));
354 if (resetKeys.length > 0) {
355 const reset = yield* rolesanywhere
356 .resetNotificationSettings({
357 trustAnchorId: live.trustAnchorId!,
358 notificationSettingKeys: resetKeys,
359 })
360 .pipe(
361 Effect.catchTag("ResourceNotFoundException", () =>
362 Effect.succeed(undefined),
363 ),
364 );
365 live = reset?.trustAnchor ?? live;
366 }
367
368 if ((live.enabled ?? false) !== desiredEnabled) {
369 // The enable/disable response can echo the pre-toggle state — the
370 // successful call itself is authoritative for the flag.
371 yield* desiredEnabled
372 ? rolesanywhere.enableTrustAnchor({
373 trustAnchorId: live.trustAnchorId!,
374 })
375 : rolesanywhere.disableTrustAnchor({
376 trustAnchorId: live.trustAnchorId!,
377 });
378 live = { ...live, enabled: desiredEnabled };
379 }
380
381 // 3b. Sync tags against observed cloud tags.
382 yield* syncRolesAnywhereTags(live.trustAnchorArn!, desiredTags);
383
384 yield* session.note(live.trustAnchorId!);
385 return toAttrs(live);
386 }),
387
388 delete: Effect.fn(function* ({ output }) {
389 yield* rolesanywhere
390 .deleteTrustAnchor({ trustAnchorId: output.trustAnchorId })
391 .pipe(
392 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
393 );
394 }),
395
396 list: () =>
397 rolesanywhere.listTrustAnchors.items({}).pipe(
398 Stream.map(toAttrs),
399 Stream.runCollect,
400 Effect.map((chunk) => Array.from(chunk)),
401 ),
402 };
403 }),
404 );