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 { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
12import { toWireSeconds } from "../../Util/Duration.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 readRolesAnywhereTags,
16 syncRolesAnywhereTags,
17 toWireTags,
18} from "./internal.ts";
19
20/**
21 * Raised when the RolesAnywhere API acknowledges a profile write but returns
22 * no profile detail and the profile cannot be found by name afterwards.
23 */
24export class ProfileMissing extends Data.TaggedError("ProfileMissing")<{
25 readonly name: string;
26}> {}
27
28/**
29 * A single rule extracting a value from the certificate field, e.g.
30 * `{ specifier: "CN" }` to map the common name.
31 */
32export interface ProfileMappingRule {
33 /**
34 * The specifier within the certificate field to map, e.g. `CN` or `OU`
35 * for `x509Subject`.
36 */
37 specifier: string;
38}
39
40/**
41 * A mapping from a certificate field to the session tags IAM Roles Anywhere
42 * attaches to the vended session.
43 */
44export interface ProfileAttributeMapping {
45 /**
46 * The certificate field to map: `x509Subject`, `x509Issuer` or `x509SAN`.
47 */
48 certificateField: string;
49 /**
50 * The rules extracting specifiers from the certificate field.
51 */
52 mappingRules: ProfileMappingRule[];
53}
54
55export interface ProfileProps {
56 /**
57 * Name of the profile. If omitted, a unique name is generated from the app,
58 * stage and logical ID. The name is updatable in place.
59 */
60 profileName?: string;
61 /**
62 * IAM role ARNs that IAM Roles Anywhere is trusted to assume on behalf of
63 * authenticated workloads. Each role's trust policy must trust
64 * `rolesanywhere.amazonaws.com`.
65 */
66 roleArns: string[];
67 /**
68 * An inline IAM session policy (JSON) applied to the vended session,
69 * further restricting the assumed role's effective permissions.
70 */
71 sessionPolicy?: string;
72 /**
73 * Managed policy ARNs that apply to the vended session as a permissions
74 * intersection.
75 */
76 managedPolicyArns?: string[];
77 /**
78 * How long vended session credentials are valid for, e.g. `"1 hour"` or
79 * `Duration.minutes(15)` (a bare number is milliseconds). Rounded to whole
80 * seconds on the wire (900-43200 seconds).
81 * @default "1 hour"
82 */
83 duration?: Duration.Input;
84 /**
85 * Whether temporary credential requests must include instance properties.
86 * Immutable after creation — changing it replaces the profile.
87 * @default false
88 */
89 requireInstanceProperties?: boolean;
90 /**
91 * Whether the vended session can carry a caller-specified role session
92 * name.
93 * @default false
94 */
95 acceptRoleSessionName?: boolean;
96 /**
97 * Mappings from certificate fields (`x509Subject`, `x509Issuer`,
98 * `x509SAN`) to the session tags attached to the vended session. Fields
99 * omitted here keep their AWS default mapping; a field previously managed
100 * by this resource and later removed has its custom mapping deleted.
101 */
102 attributeMappings?: ProfileAttributeMapping[];
103 /**
104 * Whether the profile is enabled. When disabled, temporary credential
105 * requests with this profile fail.
106 * @default true
107 */
108 enabled?: boolean;
109 /**
110 * User-defined tags for the profile.
111 */
112 tags?: Record<string, string>;
113}
114
115export interface Profile extends Resource<
116 "AWS.RolesAnywhere.Profile",
117 ProfileProps,
118 {
119 /**
120 * Unique ID of the profile.
121 */
122 profileId: string;
123 /**
124 * ARN of the profile.
125 */
126 profileArn: string;
127 /**
128 * Name of the profile.
129 */
130 profileName: string;
131 /**
132 * IAM role ARNs the profile can vend sessions for.
133 */
134 roleArns: string[];
135 /**
136 * Whether the profile is enabled.
137 */
138 enabled: boolean;
139 },
140 never,
141 Providers
142> {}
143
144/**
145 * An IAM Roles Anywhere profile — the list of IAM roles that the Roles
146 * Anywhere service is trusted to assume for authenticated certificate
147 * identities, optionally intersected with managed policies and an inline
148 * session policy.
149 * ### Creating a Profile
150 * **Example:** Basic Profile
151 * ```typescript
152 * const role = yield* IAM.Role("WorkloadRole", {
153 * assumeRolePolicyDocument: {
154 * Version: "2012-10-17",
155 * Statement: [
156 * {
157 * Effect: "Allow",
158 * Principal: { Service: "rolesanywhere.amazonaws.com" },
159 * Action: ["sts:AssumeRole", "sts:TagSession", "sts:SetSourceIdentity"],
160 * },
161 * ],
162 * },
163 * });
164 * const profile = yield* RolesAnywhere.Profile("Profile", {
165 * roleArns: [role.roleArn],
166 * });
167 * ```
168 *
169 * ### Restricting the Session
170 * **Example:** Session Policy and Duration
171 * ```typescript
172 * const profile = yield* RolesAnywhere.Profile("Profile", {
173 * roleArns: [role.roleArn],
174 * duration: "15 minutes",
175 * sessionPolicy: JSON.stringify({
176 * Version: "2012-10-17",
177 * Statement: [
178 * { Effect: "Allow", Action: "s3:GetObject", Resource: "*" },
179 * ],
180 * }),
181 * });
182 * ```
183 *
184 * ### Mapping Certificate Attributes
185 * **Example:** Session Tags from the Certificate Subject
186 * ```typescript
187 * const profile = yield* RolesAnywhere.Profile("Profile", {
188 * roleArns: [role.roleArn],
189 * attributeMappings: [
190 * {
191 * certificateField: "x509Subject",
192 * mappingRules: [{ specifier: "CN" }],
193 * },
194 * ],
195 * });
196 * ```
197 *
198 * @resource
199 */
200export const Profile = Resource<Profile>("AWS.RolesAnywhere.Profile");
201
202const toAttrs = (detail: rolesanywhere.ProfileDetail) => ({
203 profileId: detail.profileId!,
204 profileArn: detail.profileArn!,
205 profileName: detail.name!,
206 roleArns: [...(detail.roleArns ?? [])],
207 enabled: detail.enabled ?? false,
208});
209
210const sameMembers = (
211 left: ReadonlyArray<string> | undefined,
212 right: ReadonlyArray<string> | undefined,
213) => {
214 const l = [...(left ?? [])].sort();
215 const r = [...(right ?? [])].sort();
216 return l.length === r.length && l.every((v, i) => v === r[i]);
217};
218
219export const ProfileProvider = () =>
220 Provider.effect(
221 Profile,
222 Effect.gen(function* () {
223 const createName = Effect.fn(function* (
224 id: string,
225 props: Partial<ProfileProps>,
226 ) {
227 return (
228 props.profileName ??
229 (yield* createPhysicalName({ id, maxLength: 255 }))
230 );
231 });
232
233 /** Find a profile by its user-facing name across all pages. */
234 const findByName = (name: string) =>
235 rolesanywhere.listProfiles.items({}).pipe(
236 Stream.filter((detail) => detail.name === name),
237 Stream.runHead,
238 Effect.map((head) => (head._tag === "Some" ? head.value : undefined)),
239 );
240
241 const getById = (profileId: string) =>
242 rolesanywhere.getProfile({ profileId }).pipe(
243 Effect.map((r) => r.profile),
244 Effect.catchTag("ResourceNotFoundException", () =>
245 Effect.succeed(undefined),
246 ),
247 );
248
249 return {
250 stables: ["profileId", "profileArn"],
251
252 diff: Effect.fn(function* ({ olds, news }) {
253 if (!isResolved(news)) return undefined;
254 // requireInstanceProperties has no member on updateProfile — it is
255 // immutable after create, so a change forces a replacement.
256 if (
257 (olds?.requireInstanceProperties ?? false) !==
258 (news.requireInstanceProperties ?? false)
259 ) {
260 return { action: "replace" } as const;
261 }
262 }),
263
264 read: Effect.fn(function* ({ id, olds, output }) {
265 const found = output?.profileId
266 ? yield* getById(output.profileId)
267 : yield* findByName(yield* createName(id, olds ?? {}));
268 if (found === undefined) return undefined;
269 const attrs = toAttrs(found);
270 const tags = yield* readRolesAnywhereTags(attrs.profileArn);
271 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
272 }),
273
274 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
275 const name = output?.profileName ?? (yield* createName(id, news));
276 const internalTags = yield* createInternalTags(id);
277 const desiredTags = { ...internalTags, ...news.tags };
278 const desiredEnabled = news.enabled ?? true;
279 const desiredDurationSeconds = toWireSeconds(news.duration);
280
281 // 1. Observe — cloud state is authoritative; output caches the id.
282 let live = output?.profileId
283 ? yield* getById(output.profileId)
284 : yield* findByName(name);
285
286 // 2. Ensure — create if missing.
287 if (live === undefined) {
288 const created = yield* rolesanywhere.createProfile({
289 name,
290 roleArns: news.roleArns,
291 sessionPolicy: news.sessionPolicy,
292 managedPolicyArns: news.managedPolicyArns,
293 durationSeconds: desiredDurationSeconds,
294 requireInstanceProperties: news.requireInstanceProperties,
295 acceptRoleSessionName: news.acceptRoleSessionName,
296 enabled: desiredEnabled,
297 tags: toWireTags(desiredTags),
298 });
299 live = created.profile ?? (yield* findByName(name));
300 if (live === undefined) {
301 return yield* new ProfileMissing({ name });
302 }
303 } else {
304 // 3. Sync — converge the mutable aspects (name, roleArns,
305 // sessionPolicy, managedPolicyArns, duration,
306 // acceptRoleSessionName) by diffing observed against desired.
307 const desiredName = news.profileName ?? live.name!;
308 const drift =
309 live.name !== desiredName ||
310 !sameMembers(live.roleArns, news.roleArns) ||
311 !sameMembers(live.managedPolicyArns, news.managedPolicyArns) ||
312 (news.sessionPolicy !== undefined &&
313 live.sessionPolicy !== news.sessionPolicy) ||
314 (desiredDurationSeconds !== undefined &&
315 live.durationSeconds !== desiredDurationSeconds) ||
316 (news.acceptRoleSessionName !== undefined &&
317 (live.acceptRoleSessionName ?? false) !==
318 news.acceptRoleSessionName);
319 if (drift) {
320 const updated = yield* rolesanywhere.updateProfile({
321 profileId: live.profileId!,
322 name: desiredName,
323 roleArns: news.roleArns,
324 sessionPolicy: news.sessionPolicy,
325 managedPolicyArns: news.managedPolicyArns,
326 durationSeconds: desiredDurationSeconds,
327 acceptRoleSessionName: news.acceptRoleSessionName,
328 });
329 live = updated.profile ?? live;
330 }
331 }
332
333 // Explicitly annotated so control-flow analysis inside the sync
334 // loops below (which reassign from API responses that themselves
335 // take `profile.profileId` as input) never cycles back into an
336 // inferred `ProfileDetail | undefined`.
337 let profile: rolesanywhere.ProfileDetail = live;
338
339 // 3a. Sync attribute mappings — diff the OBSERVED mappings against
340 // the desired set. A fresh profile carries AWS default mappings
341 // that must not be touched unless the user manages that field, so
342 // deletions are driven by the fields previously declared in `olds`
343 // rather than full replacement of observed state.
344 const desiredMappings = news.attributeMappings ?? [];
345 const observedMappings = new Map(
346 (profile.attributeMappings ?? []).map((m) => [
347 m.certificateField,
348 (m.mappingRules ?? []).map((r) => r.specifier),
349 ]),
350 );
351 for (const mapping of desiredMappings) {
352 const observedSpecifiers = observedMappings.get(
353 mapping.certificateField,
354 );
355 const desiredSpecifiers = mapping.mappingRules.map(
356 (r) => r.specifier,
357 );
358 if (
359 observedSpecifiers === undefined ||
360 !sameMembers(observedSpecifiers, desiredSpecifiers)
361 ) {
362 const mapped = yield* rolesanywhere.putAttributeMapping({
363 profileId: profile.profileId!,
364 certificateField: mapping.certificateField,
365 mappingRules: mapping.mappingRules.map((r) => ({
366 specifier: r.specifier,
367 })),
368 });
369 profile = mapped.profile;
370 }
371 }
372 const desiredFields = new Set(
373 desiredMappings.map((m) => m.certificateField),
374 );
375 for (const previous of olds?.attributeMappings ?? []) {
376 if (
377 !desiredFields.has(previous.certificateField) &&
378 observedMappings.has(previous.certificateField)
379 ) {
380 const cleaned = yield* rolesanywhere
381 .deleteAttributeMapping({
382 profileId: profile.profileId!,
383 certificateField: previous.certificateField,
384 })
385 .pipe(
386 Effect.catchTag("ResourceNotFoundException", () =>
387 Effect.succeed(undefined),
388 ),
389 );
390 profile = cleaned?.profile ?? profile;
391 }
392 }
393
394 if ((profile.enabled ?? false) !== desiredEnabled) {
395 // The enable/disable response can echo the pre-toggle state — the
396 // successful call itself is authoritative for the flag.
397 yield* desiredEnabled
398 ? rolesanywhere.enableProfile({ profileId: profile.profileId! })
399 : rolesanywhere.disableProfile({ profileId: profile.profileId! });
400 profile = { ...profile, enabled: desiredEnabled };
401 }
402
403 // 3b. Sync tags against observed cloud tags.
404 yield* syncRolesAnywhereTags(profile.profileArn!, desiredTags);
405
406 yield* session.note(profile.profileId!);
407 return toAttrs(profile);
408 }),
409
410 delete: Effect.fn(function* ({ output }) {
411 yield* rolesanywhere
412 .deleteProfile({ profileId: output.profileId })
413 .pipe(
414 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
415 );
416 }),
417
418 list: () =>
419 rolesanywhere.listProfiles.items({}).pipe(
420 Stream.map(toAttrs),
421 Stream.runCollect,
422 Effect.map((chunk) => Array.from(chunk)),
423 ),
424 };
425 }),
426 );