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 type { PolicyDocument } from "./Policy.ts";
10import { parsePolicyDocument, stringifyPolicyDocument } from "./common.ts";
11
12export interface GroupProps {
13 /**
14 * Group name. If omitted, a deterministic name is generated.
15 */
16 groupName?: string;
17 /**
18 * Optional IAM path prefix.
19 * @default "/"
20 */
21 path?: string;
22 /**
23 * Managed policy ARNs attached to the group.
24 */
25 managedPolicyArns?: string[];
26 /**
27 * Inline policies embedded in the group.
28 */
29 inlinePolicies?: Record<string, PolicyDocument>;
30}
31
32export interface Group extends Resource<
33 "AWS.IAM.Group",
34 GroupProps,
35 {
36 /** The ARN of the group. */
37 groupArn: string;
38 /** The name of the group. */
39 groupName: string;
40 /** The stable unique ID of the group. */
41 groupId: string | undefined;
42 /** The IAM path of the group. */
43 path: string | undefined;
44 /** Managed policy ARNs attached to the group. */
45 managedPolicyArns: string[];
46 /** Inline policies embedded in the group, keyed by policy name. */
47 inlinePolicies: Record<string, PolicyDocument>;
48 },
49 never,
50 Providers
51> {}
52
53/**
54 * An IAM group that can own managed and inline policies.
55 *
56 * `Group` manages a shared authorization container for IAM users, including
57 * attached managed policies and embedded inline policies.
58 * ### Creating IAM Groups
59 * **Example:** Group with an Inline Policy
60 * ```typescript
61 * const group = yield* Group("SupportGroup", {
62 * groupName: "support",
63 * inlinePolicies: {
64 * SupportReadOnly: {
65 * Version: "2012-10-17",
66 * Statement: [{
67 * Effect: "Allow",
68 * Action: ["cloudwatch:Get*", "cloudwatch:List*"],
69 * Resource: ["*"],
70 * }],
71 * },
72 * },
73 * });
74 * ```
75 *
76 * @resource
77 */
78export const Group = Resource<Group>("AWS.IAM.Group");
79
80export const GroupProvider = () =>
81 Provider.effect(
82 Group,
83 Effect.gen(function* () {
84 const toName = (id: string, props: GroupProps) =>
85 props.groupName
86 ? Effect.succeed(props.groupName)
87 : createPhysicalName({ id, maxLength: 128 });
88
89 const readInlinePolicies = Effect.fn(function* (groupName: string) {
90 const policyNames = yield* iam.listGroupPolicies
91 .items({ GroupName: groupName })
92 .pipe(
93 Stream.runCollect,
94 Effect.map((chunk) => Array.from(chunk)),
95 );
96 const entries = yield* Effect.all(
97 policyNames.map((policyName) =>
98 iam
99 .getGroupPolicy({
100 GroupName: groupName,
101 PolicyName: policyName,
102 })
103 .pipe(
104 Effect.map(
105 (response) =>
106 [
107 policyName,
108 parsePolicyDocument(response.PolicyDocument),
109 ] as const,
110 ),
111 Effect.catchTag("NoSuchEntityException", () =>
112 Effect.succeed([policyName, undefined] as const),
113 ),
114 ),
115 ),
116 );
117 return Object.fromEntries(
118 entries.filter(
119 (entry): entry is [string, PolicyDocument] =>
120 entry[1] !== undefined,
121 ),
122 );
123 });
124
125 const readManagedPolicies = Effect.fn(function* (groupName: string) {
126 const attached = yield* iam.listAttachedGroupPolicies
127 .items({ GroupName: groupName })
128 .pipe(
129 Stream.runCollect,
130 Effect.map((chunk) => Array.from(chunk)),
131 );
132 return attached
133 .map((policy) => policy.PolicyArn)
134 .filter(
135 (policyArn): policyArn is string => typeof policyArn === "string",
136 );
137 });
138
139 const syncManagedPolicies = Effect.fn(function* ({
140 groupName,
141 olds,
142 news,
143 }: {
144 groupName: string;
145 olds: string[];
146 news: string[];
147 }) {
148 const oldSet = new Set(olds);
149 const newSet = new Set(news);
150 for (const policyArn of news) {
151 if (!oldSet.has(policyArn)) {
152 yield* iam.attachGroupPolicy({
153 GroupName: groupName,
154 PolicyArn: policyArn,
155 });
156 }
157 }
158 for (const policyArn of olds) {
159 if (!newSet.has(policyArn)) {
160 yield* iam
161 .detachGroupPolicy({
162 GroupName: groupName,
163 PolicyArn: policyArn,
164 })
165 .pipe(
166 Effect.catchTag("NoSuchEntityException", () => Effect.void),
167 );
168 }
169 }
170 });
171
172 const syncInlinePolicies = Effect.fn(function* ({
173 groupName,
174 olds,
175 news,
176 }: {
177 groupName: string;
178 olds: Record<string, PolicyDocument>;
179 news: Record<string, PolicyDocument>;
180 }) {
181 for (const [policyName, document] of Object.entries(news)) {
182 if (
183 JSON.stringify(olds[policyName] ?? null) !==
184 JSON.stringify(document)
185 ) {
186 yield* iam.putGroupPolicy({
187 GroupName: groupName,
188 PolicyName: policyName,
189 PolicyDocument: stringifyPolicyDocument(document),
190 });
191 }
192 }
193 for (const policyName of Object.keys(olds)) {
194 if (!(policyName in news)) {
195 yield* iam
196 .deleteGroupPolicy({
197 GroupName: groupName,
198 PolicyName: policyName,
199 })
200 .pipe(
201 Effect.catchTag("NoSuchEntityException", () => Effect.void),
202 );
203 }
204 }
205 });
206
207 return {
208 stables: ["groupArn", "groupName", "groupId"],
209 // IAM is global (no region). Enumerate every group via the paginated
210 // `listGroups`, then hydrate each into the full `read` Attributes shape
211 // by reading its attached managed policies and embedded inline policies.
212 list: () =>
213 Effect.gen(function* () {
214 const groups = yield* iam.listGroups.pages({}).pipe(
215 Stream.runCollect,
216 Effect.map((chunk) =>
217 Array.from(chunk).flatMap((page) => page.Groups ?? []),
218 ),
219 );
220 const hydrated = yield* Effect.forEach(
221 groups,
222 (group) =>
223 Effect.gen(function* () {
224 const [managedPolicyArns, inlinePolicies] = yield* Effect.all(
225 [
226 readManagedPolicies(group.GroupName),
227 readInlinePolicies(group.GroupName),
228 ],
229 );
230 return {
231 groupArn: group.Arn,
232 groupName: group.GroupName,
233 groupId: group.GroupId,
234 path: group.Path,
235 managedPolicyArns,
236 inlinePolicies,
237 };
238 }).pipe(
239 // A group can be deleted between `listGroups` and this
240 // per-group hydration (e.g. a sibling test tearing its group
241 // down) — `NoSuchEntityException` here just means it's gone,
242 // so drop it rather than failing the whole enumeration.
243 Effect.catchTag("NoSuchEntityException", () =>
244 Effect.succeed(undefined),
245 ),
246 ),
247 { concurrency: 10 },
248 );
249 return hydrated.filter((g) => g !== undefined);
250 }),
251 diff: Effect.fn(function* ({ id, olds, news }) {
252 if (!isResolved(news)) return;
253 if (
254 (yield* toName(id, olds ?? ({} as GroupProps))) !==
255 (yield* toName(id, news))
256 ) {
257 return { action: "replace" } as const;
258 }
259 if ((olds?.path ?? "/") !== (news.path ?? "/")) {
260 return { action: "replace" } as const;
261 }
262 }),
263 read: Effect.fn(function* ({ id, olds, output }) {
264 const groupName =
265 output?.groupName ??
266 (yield* toName(id, olds ?? ({} as GroupProps)));
267 const response = yield* iam
268 .getGroup({
269 GroupName: groupName,
270 })
271 .pipe(
272 Effect.catchTag("NoSuchEntityException", () =>
273 Effect.succeed(undefined),
274 ),
275 );
276 if (!response?.Group?.Arn) {
277 return undefined;
278 }
279 const [managedPolicyArns, inlinePolicies] = yield* Effect.all([
280 readManagedPolicies(groupName),
281 readInlinePolicies(groupName),
282 ]);
283 return {
284 groupArn: response.Group.Arn,
285 groupName: response.Group.GroupName,
286 groupId: response.Group.GroupId,
287 path: response.Group.Path,
288 managedPolicyArns,
289 inlinePolicies,
290 };
291 }),
292 reconcile: Effect.fn(function* ({ id, news, output, session }) {
293 const groupName = output?.groupName ?? (yield* toName(id, news));
294
295 // Observe — fetch the live group. `getGroup` returns
296 // `NoSuchEntityException` when the group has not been created or
297 // was deleted out-of-band, in which case we fall through to the
298 // ensure step below.
299 let observed = yield* iam
300 .getGroup({ GroupName: groupName })
301 .pipe(
302 Effect.catchTag("NoSuchEntityException", () =>
303 Effect.succeed(undefined),
304 ),
305 );
306
307 // Ensure — create the group when it is missing. A concurrent
308 // peer reconciler may win the race, so tolerate
309 // `EntityAlreadyExistsException` and reload.
310 if (!observed?.Group?.Arn) {
311 observed = yield* iam
312 .createGroup({
313 GroupName: groupName,
314 Path: news.path,
315 })
316 .pipe(
317 Effect.catchTag("EntityAlreadyExistsException", () =>
318 iam.getGroup({ GroupName: groupName }),
319 ),
320 Effect.map((response) => ({
321 Group: response.Group,
322 Users: [],
323 IsTruncated: false,
324 })),
325 );
326 }
327
328 // Sync — for each mutable aspect, diff observed against desired
329 // and apply only the delta. We trust the cloud as the source of
330 // truth instead of relying on `olds`.
331 const [observedManagedPolicies, observedInlinePolicies] =
332 yield* Effect.all([
333 readManagedPolicies(groupName),
334 readInlinePolicies(groupName),
335 ]);
336
337 yield* syncManagedPolicies({
338 groupName,
339 olds: observedManagedPolicies,
340 news: news.managedPolicyArns ?? [],
341 });
342 yield* syncInlinePolicies({
343 groupName,
344 olds: observedInlinePolicies,
345 news: news.inlinePolicies ?? {},
346 });
347
348 const groupArn = observed.Group?.Arn ?? groupName;
349 yield* session.note(groupArn);
350 return {
351 groupArn,
352 groupName,
353 groupId: observed.Group?.GroupId,
354 path: observed.Group?.Path ?? news.path ?? "/",
355 managedPolicyArns: news.managedPolicyArns ?? [],
356 inlinePolicies: news.inlinePolicies ?? {},
357 };
358 }),
359 delete: Effect.fn(function* ({ output }) {
360 yield* iam.getGroup.items({ GroupName: output.groupName }).pipe(
361 Stream.mapEffect((user) =>
362 user.UserName
363 ? iam
364 .removeUserFromGroup({
365 GroupName: output.groupName,
366 UserName: user.UserName,
367 })
368 .pipe(
369 Effect.catchTag(
370 "NoSuchEntityException",
371 () => Effect.void,
372 ),
373 )
374 : Effect.void,
375 ),
376 Stream.runDrain,
377 // The group itself may already be gone.
378 Effect.catchTag("NoSuchEntityException", () => Effect.void),
379 );
380 yield* iam.listGroupPolicies
381 .items({ GroupName: output.groupName })
382 .pipe(
383 Stream.mapEffect((policyName) =>
384 iam
385 .deleteGroupPolicy({
386 GroupName: output.groupName,
387 PolicyName: policyName,
388 })
389 .pipe(
390 Effect.catchTag("NoSuchEntityException", () => Effect.void),
391 ),
392 ),
393 Stream.runDrain,
394 Effect.catchTag("NoSuchEntityException", () => Effect.void),
395 );
396 yield* iam.listAttachedGroupPolicies
397 .items({ GroupName: output.groupName })
398 .pipe(
399 Stream.mapEffect((policy) =>
400 policy.PolicyArn
401 ? iam
402 .detachGroupPolicy({
403 GroupName: output.groupName,
404 PolicyArn: policy.PolicyArn,
405 })
406 .pipe(
407 Effect.catchTag(
408 "NoSuchEntityException",
409 () => Effect.void,
410 ),
411 )
412 : Effect.void,
413 ),
414 Stream.runDrain,
415 Effect.catchTag("NoSuchEntityException", () => Effect.void),
416 );
417 yield* iam
418 .deleteGroup({
419 GroupName: output.groupName,
420 })
421 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
422 }),
423 };
424 }),
425 );