1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 diffTags,
13 hasTags,
14} from "../../Tags.ts";
15import type { PolicyDocument } from "./Policy.ts";
16import {
17 parsePolicyDocument,
18 stringifyPolicyDocument,
19 toTagRecord,
20} from "./common.ts";
21
22export interface UserProps {
23 /**
24 * User name. If omitted, a deterministic name is generated.
25 */
26 userName?: string;
27 /**
28 * Optional IAM path prefix.
29 * @default "/"
30 */
31 path?: string;
32 /**
33 * Optional permissions boundary policy ARN.
34 */
35 permissionsBoundary?: string;
36 /**
37 * Managed policy ARNs attached to the user.
38 */
39 managedPolicyArns?: string[];
40 /**
41 * Inline policies embedded in the user.
42 */
43 inlinePolicies?: Record<string, PolicyDocument>;
44 /**
45 * User-defined tags to apply to the user.
46 */
47 tags?: Record<string, string>;
48}
49
50export interface User extends Resource<
51 "AWS.IAM.User",
52 UserProps,
53 {
54 /** The ARN of the user. */
55 userArn: string;
56 /** The name of the user. */
57 userName: string;
58 /** The stable unique ID of the user. */
59 userId: string | undefined;
60 /** The IAM path of the user. */
61 path: string | undefined;
62 /** The managed policy ARN used as the permissions boundary, if any. */
63 permissionsBoundary: string | undefined;
64 /** Managed policy ARNs attached to the user. */
65 managedPolicyArns: string[];
66 /** Inline policies embedded in the user, keyed by policy name. */
67 inlinePolicies: Record<string, PolicyDocument>;
68 /** The tags applied to the user. */
69 tags: Record<string, string>;
70 },
71 never,
72 Providers
73> {}
74
75/**
76 * An IAM user with optional inline policies, managed policies, and tags.
77 *
78 * `User` manages a long-lived IAM identity together with its attached managed
79 * policies, inline policies, permissions boundary, and tags.
80 * ### Creating IAM Users
81 * **Example:** User with Managed Policies
82 * ```typescript
83 * const user = yield* User("AppUser", {
84 * userName: "app-user",
85 * managedPolicyArns: [
86 * "arn:aws:iam::aws:policy/ReadOnlyAccess",
87 * ],
88 * });
89 * ```
90 *
91 * @resource
92 */
93export const User = Resource<User>("AWS.IAM.User");
94
95export const UserProvider = () =>
96 Provider.effect(
97 User,
98 Effect.gen(function* () {
99 const toName = (id: string, props: UserProps) =>
100 props.userName
101 ? Effect.succeed(props.userName)
102 : createPhysicalName({ id, maxLength: 64 });
103
104 const readManagedPolicies = Effect.fn(function* (userName: string) {
105 const attached = yield* iam.listAttachedUserPolicies
106 .items({ UserName: userName })
107 .pipe(
108 Stream.runCollect,
109 Effect.map((chunk) => Array.from(chunk)),
110 );
111 return attached
112 .map((policy) => policy.PolicyArn)
113 .filter(
114 (policyArn): policyArn is string => typeof policyArn === "string",
115 );
116 });
117
118 const readInlinePolicies = Effect.fn(function* (userName: string) {
119 const policyNames = yield* iam.listUserPolicies
120 .items({ UserName: userName })
121 .pipe(
122 Stream.runCollect,
123 Effect.map((chunk) => Array.from(chunk)),
124 );
125 const entries = yield* Effect.all(
126 policyNames.map((policyName) =>
127 iam
128 .getUserPolicy({
129 UserName: userName,
130 PolicyName: policyName,
131 })
132 .pipe(
133 Effect.map(
134 (response) =>
135 [
136 policyName,
137 parsePolicyDocument(response.PolicyDocument),
138 ] as const,
139 ),
140 Effect.catchTag("NoSuchEntityException", () =>
141 Effect.succeed([policyName, undefined] as const),
142 ),
143 ),
144 ),
145 );
146 return Object.fromEntries(
147 entries.filter(
148 (entry): entry is [string, PolicyDocument] =>
149 entry[1] !== undefined,
150 ),
151 );
152 });
153
154 const readTags = Effect.fn(function* (userName: string) {
155 const listed = yield* iam.listUserTags({
156 UserName: userName,
157 });
158 return toTagRecord(listed.Tags);
159 });
160
161 const syncManagedPolicies = Effect.fn(function* ({
162 userName,
163 olds,
164 news,
165 }: {
166 userName: string;
167 olds: string[];
168 news: string[];
169 }) {
170 const oldSet = new Set(olds);
171 const newSet = new Set(news);
172 for (const policyArn of news) {
173 if (!oldSet.has(policyArn)) {
174 yield* iam.attachUserPolicy({
175 UserName: userName,
176 PolicyArn: policyArn,
177 });
178 }
179 }
180 for (const policyArn of olds) {
181 if (!newSet.has(policyArn)) {
182 yield* iam
183 .detachUserPolicy({
184 UserName: userName,
185 PolicyArn: policyArn,
186 })
187 .pipe(
188 Effect.catchTag("NoSuchEntityException", () => Effect.void),
189 );
190 }
191 }
192 });
193
194 const syncInlinePolicies = Effect.fn(function* ({
195 userName,
196 olds,
197 news,
198 }: {
199 userName: string;
200 olds: Record<string, PolicyDocument>;
201 news: Record<string, PolicyDocument>;
202 }) {
203 for (const [policyName, document] of Object.entries(news)) {
204 if (
205 JSON.stringify(olds[policyName] ?? null) !==
206 JSON.stringify(document)
207 ) {
208 yield* iam.putUserPolicy({
209 UserName: userName,
210 PolicyName: policyName,
211 PolicyDocument: stringifyPolicyDocument(document),
212 });
213 }
214 }
215 for (const policyName of Object.keys(olds)) {
216 if (!(policyName in news)) {
217 yield* iam
218 .deleteUserPolicy({
219 UserName: userName,
220 PolicyName: policyName,
221 })
222 .pipe(
223 Effect.catchTag("NoSuchEntityException", () => Effect.void),
224 );
225 }
226 }
227 });
228
229 return {
230 stables: ["userArn", "userName", "userId"],
231 list: () =>
232 Effect.gen(function* () {
233 // IAM is global; `listUsers` enumerates every user in the
234 // account. Paginate exhaustively, then hydrate each user's
235 // managed/inline policies and tags (the list summary omits
236 // them) to produce the same Attributes shape `read` returns.
237 const users = yield* iam.listUsers.pages({}).pipe(
238 Stream.runCollect,
239 Effect.map((chunk) =>
240 Array.from(chunk).flatMap((page) => page.Users ?? []),
241 ),
242 );
243
244 const hydrated = yield* Effect.forEach(
245 users,
246 (user) =>
247 Effect.gen(function* () {
248 const [managedPolicyArns, inlinePolicies, tags] =
249 yield* Effect.all([
250 readManagedPolicies(user.UserName),
251 readInlinePolicies(user.UserName),
252 readTags(user.UserName),
253 ]);
254 return {
255 userArn: user.Arn,
256 userName: user.UserName,
257 userId: user.UserId,
258 path: user.Path,
259 permissionsBoundary:
260 user.PermissionsBoundary?.PermissionsBoundaryArn,
261 managedPolicyArns,
262 inlinePolicies,
263 tags,
264 };
265 }).pipe(
266 // A user may be deleted concurrently mid-hydration.
267 Effect.catchTag("NoSuchEntityException", () =>
268 Effect.succeed(undefined),
269 ),
270 ),
271 { concurrency: 10 },
272 );
273
274 return hydrated.filter(
275 (attrs): attrs is NonNullable<typeof attrs> =>
276 attrs !== undefined,
277 );
278 }),
279 diff: Effect.fn(function* ({ id, olds, news }) {
280 if (!isResolved(news)) return;
281 if (
282 (yield* toName(id, olds ?? ({} as UserProps))) !==
283 (yield* toName(id, news))
284 ) {
285 return { action: "replace" } as const;
286 }
287 if ((olds?.path ?? "/") !== (news.path ?? "/")) {
288 return { action: "replace" } as const;
289 }
290 }),
291 read: Effect.fn(function* ({ id, olds, output }) {
292 const userName =
293 output?.userName ?? (yield* toName(id, olds ?? ({} as UserProps)));
294 const response = yield* iam
295 .getUser({
296 UserName: userName,
297 })
298 .pipe(
299 Effect.catchTag("NoSuchEntityException", () =>
300 Effect.succeed(undefined),
301 ),
302 );
303 if (!response?.User?.Arn) {
304 return undefined;
305 }
306 const [managedPolicyArns, inlinePolicies, tags] = yield* Effect.all([
307 readManagedPolicies(userName),
308 readInlinePolicies(userName),
309 readTags(userName),
310 ]);
311 return {
312 userArn: response.User.Arn,
313 userName: response.User.UserName,
314 userId: response.User.UserId,
315 path: response.User.Path,
316 permissionsBoundary:
317 response.User.PermissionsBoundary?.PermissionsBoundaryArn,
318 managedPolicyArns,
319 inlinePolicies,
320 tags,
321 };
322 }),
323 reconcile: Effect.fn(function* ({ id, news, output, session }) {
324 const userName = output?.userName ?? (yield* toName(id, news));
325 const desiredTags = {
326 ...(yield* createInternalTags(id)),
327 ...news.tags,
328 };
329
330 // Observe — read the live user (or absence).
331 const observedResponse = yield* iam
332 .getUser({ UserName: userName })
333 .pipe(
334 Effect.catchTag("NoSuchEntityException", () =>
335 Effect.succeed(undefined),
336 ),
337 );
338 let observedUser = observedResponse?.User;
339
340 // Ensure — create the user when missing. On race, verify
341 // ownership tags before adopting.
342 if (!observedUser?.Arn) {
343 const created = yield* iam
344 .createUser({
345 UserName: userName,
346 Path: news.path,
347 PermissionsBoundary: news.permissionsBoundary,
348 Tags: createTagsList(desiredTags),
349 })
350 .pipe(
351 Effect.catchTag("EntityAlreadyExistsException", () =>
352 iam.getUser({ UserName: userName }).pipe(
353 Effect.filterOrFail(
354 (existing) => hasTags(desiredTags, existing.User?.Tags),
355 () =>
356 new Error(
357 `User '${userName}' already exists and is not managed by alchemy`,
358 ),
359 ),
360 ),
361 ),
362 );
363 observedUser = created.User;
364 }
365
366 // Sync permissions boundary against observed.
367 const observedBoundary =
368 observedUser?.PermissionsBoundary?.PermissionsBoundaryArn;
369 if (news.permissionsBoundary !== observedBoundary) {
370 if (news.permissionsBoundary) {
371 yield* iam.putUserPermissionsBoundary({
372 UserName: userName,
373 PermissionsBoundary: news.permissionsBoundary,
374 });
375 } else if (observedBoundary) {
376 yield* iam
377 .deleteUserPermissionsBoundary({
378 UserName: userName,
379 })
380 .pipe(
381 Effect.catchTag("NoSuchEntityException", () => Effect.void),
382 );
383 }
384 }
385
386 // Sync managed and inline policies — observe live state and
387 // apply only the delta.
388 const [observedManagedPolicies, observedInlinePolicies] =
389 yield* Effect.all([
390 readManagedPolicies(userName),
391 readInlinePolicies(userName),
392 ]);
393 yield* syncManagedPolicies({
394 userName,
395 olds: observedManagedPolicies,
396 news: news.managedPolicyArns ?? [],
397 });
398 yield* syncInlinePolicies({
399 userName,
400 olds: observedInlinePolicies,
401 news: news.inlinePolicies ?? {},
402 });
403
404 // Sync tags against the cloud's actual tags.
405 const observedTags = yield* readTags(userName);
406 const { removed, upsert } = diffTags(observedTags, desiredTags);
407 if (upsert.length > 0) {
408 yield* iam.tagUser({
409 UserName: userName,
410 Tags: upsert,
411 });
412 }
413 if (removed.length > 0) {
414 yield* iam.untagUser({
415 UserName: userName,
416 TagKeys: removed,
417 });
418 }
419
420 // Re-read for fresh attributes after all mutations.
421 const user = yield* iam.getUser({ UserName: userName });
422 const userArn = user.User?.Arn ?? observedUser?.Arn ?? userName;
423
424 yield* session.note(userArn);
425 return {
426 userArn,
427 userName: user.User?.UserName ?? userName,
428 userId: user.User?.UserId ?? observedUser?.UserId,
429 path: user.User?.Path ?? observedUser?.Path ?? news.path ?? "/",
430 permissionsBoundary:
431 user.User?.PermissionsBoundary?.PermissionsBoundaryArn ??
432 news.permissionsBoundary,
433 managedPolicyArns: news.managedPolicyArns ?? [],
434 inlinePolicies: news.inlinePolicies ?? {},
435 tags: desiredTags,
436 };
437 }),
438 delete: Effect.fn(function* ({ output }) {
439 yield* iam
440 .deleteUserPermissionsBoundary({
441 UserName: output.userName,
442 })
443 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
444
445 yield* iam.listUserPolicies.items({ UserName: output.userName }).pipe(
446 Stream.mapEffect((policyName) =>
447 iam
448 .deleteUserPolicy({
449 UserName: output.userName,
450 PolicyName: policyName,
451 })
452 .pipe(
453 Effect.catchTag("NoSuchEntityException", () => Effect.void),
454 ),
455 ),
456 Stream.runDrain,
457 // The user itself may already be gone.
458 Effect.catchTag("NoSuchEntityException", () => Effect.void),
459 );
460
461 yield* iam.listAttachedUserPolicies
462 .items({ UserName: output.userName })
463 .pipe(
464 Stream.mapEffect((policy) =>
465 policy.PolicyArn
466 ? iam
467 .detachUserPolicy({
468 UserName: output.userName,
469 PolicyArn: policy.PolicyArn,
470 })
471 .pipe(
472 Effect.catchTag(
473 "NoSuchEntityException",
474 () => Effect.void,
475 ),
476 )
477 : Effect.void,
478 ),
479 Stream.runDrain,
480 // The user itself may already be gone.
481 Effect.catchTag("NoSuchEntityException", () => Effect.void),
482 );
483
484 yield* iam
485 .deleteUser({
486 UserName: output.userName,
487 })
488 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
489 }),
490 };
491 }),
492 );