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 type { Input } from "../../Input.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface GroupMembershipProps {
11 /**
12 * Name of the IAM group to manage membership for.
13 */
14 groupName: Input<string>;
15 /**
16 * Exact set of user names that should be members of the group.
17 */
18 userNames: Input<string[]>;
19}
20
21export interface GroupMembership extends Resource<
22 "AWS.IAM.GroupMembership",
23 GroupMembershipProps,
24 {
25 /** The group whose membership is managed. */
26 groupName: string;
27 /** The user names currently in the group. */
28 userNames: string[];
29 },
30 never,
31 Providers
32> {}
33
34/**
35 * An explicit IAM group membership resource that owns a group's managed users.
36 *
37 * `GroupMembership` models the exact set of users in a group, making membership
38 * reconciliation explicit instead of spreading it across user or group resources.
39 * ### Managing Group Membership
40 * **Example:** Sync a Group's Members
41 * ```typescript
42 * const admins = yield* Group("Admins", {
43 * groupName: "admins",
44 * });
45 *
46 * const alice = yield* User("Alice", {
47 * userName: "alice",
48 * });
49 *
50 * const bob = yield* User("Bob", {
51 * userName: "bob",
52 * });
53 *
54 * const membership = yield* GroupMembership("AdminsMembership", {
55 * groupName: admins.groupName,
56 * userNames: [alice.userName, bob.userName],
57 * });
58 * ```
59 *
60 * @resource
61 */
62export const GroupMembership = Resource<GroupMembership>(
63 "AWS.IAM.GroupMembership",
64);
65
66export const GroupMembershipProvider = () =>
67 Provider.succeed(GroupMembership, {
68 stables: ["groupName"],
69 diff: Effect.fn(function* ({ olds, news }) {
70 if (!isResolved(news)) return;
71 if (olds.groupName !== news.groupName) {
72 return { action: "replace" } as const;
73 }
74 }),
75 read: Effect.fn(function* ({ output }) {
76 if (!output) {
77 return undefined;
78 }
79 const response = yield* iam
80 .getGroup({
81 GroupName: output.groupName,
82 })
83 .pipe(
84 Effect.catchTag("NoSuchEntityException", () =>
85 Effect.succeed(undefined),
86 ),
87 );
88 if (!response?.Group?.GroupName) {
89 return undefined;
90 }
91 return {
92 groupName: response.Group.GroupName,
93 userNames: (response.Users ?? [])
94 .map((user) => user.UserName)
95 .filter(
96 (userName): userName is string => typeof userName === "string",
97 ),
98 };
99 }),
100 reconcile: Effect.fn(function* ({ news, session }) {
101 const groupName = news.groupName as string;
102 const desiredUsers = news.userNames as string[];
103
104 // Observe — read the actual membership of the group. `getGroup`
105 // both confirms the group exists and returns the current user list
106 // in a single call.
107 const response = yield* iam
108 .getGroup({ GroupName: groupName })
109 .pipe(
110 Effect.catchTag("NoSuchEntityException", () =>
111 Effect.succeed(undefined),
112 ),
113 );
114 const observedUsers = (response?.Users ?? [])
115 .map((user) => user.UserName)
116 .filter((userName): userName is string => typeof userName === "string");
117
118 // Sync — diff observed against desired and apply only the delta.
119 const observedSet = new Set(observedUsers);
120 const desiredSet = new Set(desiredUsers);
121 for (const userName of desiredUsers) {
122 if (!observedSet.has(userName)) {
123 yield* iam.addUserToGroup({
124 GroupName: groupName,
125 UserName: userName,
126 });
127 }
128 }
129 for (const userName of observedUsers) {
130 if (!desiredSet.has(userName)) {
131 yield* iam
132 .removeUserFromGroup({
133 GroupName: groupName,
134 UserName: userName,
135 })
136 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
137 }
138 }
139
140 yield* session.note(groupName);
141 return {
142 groupName,
143 userNames: desiredUsers,
144 };
145 }),
146 list: Effect.fn(function* () {
147 // The membership resource is keyed by groupName and its Attributes are
148 // the full set of users in that group, so enumerate every IAM group
149 // and read each group's user list — one item per group, matching the
150 // shape `read` produces.
151 const groups = yield* iam.listGroups.pages({}).pipe(
152 Stream.runCollect,
153 Effect.map((chunk) =>
154 Array.from(chunk).flatMap((page) => page.Groups ?? []),
155 ),
156 );
157 const rows = yield* Effect.forEach(
158 groups,
159 (group) =>
160 iam.getGroup.pages({ GroupName: group.GroupName }).pipe(
161 Stream.runCollect,
162 Effect.map((chunk) => ({
163 groupName: group.GroupName,
164 userNames: Array.from(chunk)
165 .flatMap((page) => page.Users ?? [])
166 .map((user) => user.UserName)
167 .filter(
168 (userName): userName is string =>
169 typeof userName === "string",
170 ),
171 })),
172 // The group may be deleted between listGroups and getGroup.
173 Effect.catchTag("NoSuchEntityException", () =>
174 Effect.succeed(undefined),
175 ),
176 ),
177 { concurrency: 10 },
178 );
179 return rows.filter(
180 (row): row is { groupName: string; userNames: string[] } =>
181 row !== undefined,
182 );
183 }),
184 delete: Effect.fn(function* ({ output }) {
185 for (const userName of output.userNames) {
186 yield* iam
187 .removeUserFromGroup({
188 GroupName: output.groupName,
189 UserName: userName,
190 })
191 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
192 }
193 }),
194 });