| 1 | import * as iam from "@distilled.cloud/aws/iam"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { isResolved } from "../../Diff.ts"; |
| 5 | import type { Input } from "../../Input.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | |
| 10 | export 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 | |
| 21 | export 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 | */ |
| 62 | export const GroupMembership = Resource<GroupMembership>( |
| 63 | "AWS.IAM.GroupMembership", |
| 64 | ); |
| 65 | |
| 66 | export 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 | }); |