1import * as iam from "@distilled.cloud/cloudflare/iam";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Iam.UserGroupMembership" as const;
14type TypeId = typeof TypeId;
15
16export interface UserGroupMembershipProps {
17 /**
18 * ID of the user group to add the member to — e.g.
19 * `userGroup.userGroupId`. Immutable — changing it triggers a
20 * replacement.
21 */
22 userGroup: string;
23 /**
24 * Account member ID to add to the user group (the membership id from
25 * the account's member roster, not the user id). Immutable — changing
26 * it triggers a replacement.
27 */
28 memberId: string;
29}
30
31export interface UserGroupMembershipAttributes {
32 /** ID of the user group the member belongs to. */
33 userGroupId: string;
34 /** Account member ID of the member. */
35 memberId: string;
36 /** The Cloudflare account the user group belongs to. */
37 accountId: string;
38 /** The contact email address of the member, if known. */
39 email: string | undefined;
40 /** The member's status in the account (`accepted` or `pending`). */
41 status: string | undefined;
42}
43
44export type UserGroupMembership = Resource<
45 TypeId,
46 UserGroupMembershipProps,
47 UserGroupMembershipAttributes,
48 never,
49 Providers
50>;
51
52/**
53 * Membership of a single account member in a Cloudflare IAM user group.
54 *
55 * This is an existence-only resource: it has no mutable aspects beyond its
56 * identity (user group + member), so changing either property triggers a
57 * replacement. Cloudflare's member-add API is idempotent — adding a member
58 * who is already in the group succeeds — so reconcile is a simple
59 * observe-then-ensure flow.
60 *
61 * Account-scoped IAM (user groups and their members) is an Enterprise
62 * feature.
63 * ### Adding a Member
64 * **Example:** Add an account member to a user group
65 * ```typescript
66 * const group = yield* Cloudflare.Iam.UserGroup("Operators", {});
67 *
68 * yield* Cloudflare.Iam.UserGroupMembership("SamInOperators", {
69 * userGroup: group.userGroupId,
70 * memberId: "b67b4c279ea0177a0ddff0a2ef64b11b",
71 * });
72 * ```
73 *
74 * @see https://developers.cloudflare.com/fundamentals/manage-members/user-groups/
75 *
76 * @resource
77 * @product IAM
78 * @category Account & Identity
79 */
80export const UserGroupMembership = Resource<UserGroupMembership>(TypeId);
81
82/**
83 * Returns true if the given value is an UserGroupMembership resource.
84 */
85export const isUserGroupMembership = (
86 value: unknown,
87): value is UserGroupMembership =>
88 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
89
90export const UserGroupMembershipProvider = () =>
91 Provider.succeed(UserGroupMembership, {
92 stables: ["userGroupId", "memberId", "accountId"],
93
94 // Parent fan-out: memberships are keyed by (user group, member) and
95 // there is no account-wide membership enumeration API. Enumerate every
96 // user group in the account (account-scoped), then list each group's
97 // members with bounded concurrency, paginating both levels exhaustively.
98 list: Effect.fn(function* () {
99 const { accountId } = yield* yield* CloudflareEnvironment;
100 const groups = yield* iam.listUserGroups.pages({ accountId }).pipe(
101 Stream.runCollect,
102 Effect.map((chunk) =>
103 Array.from(chunk).flatMap((page) => page.result ?? []),
104 ),
105 );
106 const rows = yield* Effect.forEach(
107 groups,
108 (group) =>
109 iam.listUserGroupMembers
110 .pages({ accountId, userGroupId: group.id })
111 .pipe(
112 Stream.runCollect,
113 Effect.map((chunk) =>
114 Array.from(chunk).flatMap((page) =>
115 (page.result ?? []).map(
116 (member): UserGroupMembershipAttributes =>
117 toAttributes(member, group.id, accountId),
118 ),
119 ),
120 ),
121 // Group removed out-of-band between enumeration and member
122 // listing — skip it.
123 Effect.catchTag("UserGroupNotFound", () =>
124 Effect.succeed([] as UserGroupMembershipAttributes[]),
125 ),
126 // A group whose policy Cloudflare can't validate rejects the
127 // member listing with a 400 ("Policy validation failed"). It's
128 // not ours to enumerate — contribute nothing rather than failing
129 // the whole account-wide listing.
130 Effect.catchTag("PolicyValidationFailed", () =>
131 Effect.succeed([] as UserGroupMembershipAttributes[]),
132 ),
133 ),
134 { concurrency: 10 },
135 );
136 return rows.flat();
137 }),
138
139 diff: Effect.fn(function* ({ olds, news }) {
140 if (!isResolved(news)) return undefined;
141 // Identity change — both props are the resource's identity, so any
142 // change is a replacement. Compare only once both sides are concrete.
143 if (
144 typeof olds?.userGroup === "string" &&
145 olds.userGroup !== news.userGroup
146 ) {
147 return { action: "replace" } as const;
148 }
149 if (
150 typeof olds?.memberId === "string" &&
151 olds.memberId !== news.memberId
152 ) {
153 return { action: "replace" } as const;
154 }
155 return undefined;
156 }),
157
158 read: Effect.fn(function* ({ output, olds }) {
159 const { accountId } = yield* yield* CloudflareEnvironment;
160 const acct = output?.accountId ?? accountId;
161 // Cold read falls back to the previous props — the identity is fully
162 // user-specified, so the (group, member) pair is the lookup key. The
163 // props may still hold an unresolved `Output` (e.g. a reference to a
164 // sibling group's id when state was persisted before reconcile), so
165 // only use them as a lookup key once they're concrete strings —
166 // otherwise there is nothing to read.
167 const oldGroup =
168 typeof olds?.userGroup === "string" ? olds.userGroup : undefined;
169 const oldMember =
170 typeof olds?.memberId === "string" ? olds.memberId : undefined;
171 const userGroupId = output?.userGroupId ?? oldGroup;
172 const memberId = output?.memberId ?? oldMember;
173 if (userGroupId === undefined || memberId === undefined) {
174 return undefined;
175 }
176 const observed = yield* getMembership(acct, userGroupId, memberId);
177 return observed ? toAttributes(observed, userGroupId, acct) : undefined;
178 }),
179
180 reconcile: Effect.fn(function* ({ news }) {
181 const { accountId } = yield* yield* CloudflareEnvironment;
182 // Inputs have been resolved to concrete strings by Plan.
183 const userGroupId = news.userGroup as string;
184 const memberId = news.memberId as string;
185
186 // A user group created earlier in the same deploy is eventually
187 // consistent: its `/members` sub-resource API briefly answers GET and
188 // POST with `UserGroupNotFound` (404) until the new group propagates
189 // across Cloudflare's edge. Ride out that window with a bounded retry
190 // — `UserGroupNotFound` here means "the group isn't visible yet", not
191 // "the group is gone" (we are mid-create of its membership).
192 const ensure = Effect.gen(function* () {
193 // Observe — membership is existence-only; if it's already there we
194 // are done. A missing *member* (`UserGroupMemberNotFound`) is the
195 // expected greenfield state; a missing *group* (`UserGroupNotFound`)
196 // bubbles to the retry below.
197 const observed = yield* iam
198 .getUserGroupMember({ accountId, userGroupId, memberId })
199 .pipe(
200 Effect.map((m): ObservedMember | undefined => m),
201 Effect.catchTag("UserGroupMemberNotFound", () =>
202 Effect.succeed(undefined),
203 ),
204 );
205 if (observed) {
206 return toAttributes(observed, userGroupId, accountId);
207 }
208
209 // Ensure — add the member. The batch POST is idempotent for members
210 // already in the group, so a concurrent add is not an error.
211 const created = yield* iam.createUserGroupMember({
212 accountId,
213 userGroupId,
214 members: [{ id: memberId }],
215 });
216 const member = created.result.find((m) => m.id === memberId);
217 if (member) {
218 return toAttributes(member, userGroupId, accountId);
219 }
220 // The POST response echoes the full member set; fall back to a read
221 // for the member we just added.
222 const reread = yield* getMembership(accountId, userGroupId, memberId);
223 return toAttributes(reread ?? { id: memberId }, userGroupId, accountId);
224 });
225
226 return yield* ensure.pipe(
227 Effect.retry({
228 while: (e) => e._tag === "UserGroupNotFound",
229 schedule: Schedule.exponential("500 millis"),
230 times: 8,
231 }),
232 );
233 }),
234
235 delete: Effect.fn(function* ({ output }) {
236 yield* iam
237 .deleteUserGroupMember({
238 accountId: output.accountId,
239 userGroupId: output.userGroupId,
240 memberId: output.memberId,
241 })
242 .pipe(
243 // Already gone — the member (404 in group, or no longer a valid
244 // account member at all → InvalidMember) or the whole user group
245 // (404) has been removed out-of-band.
246 Effect.catchTag(
247 ["UserGroupMemberNotFound", "UserGroupNotFound", "InvalidMember"],
248 () => Effect.void,
249 ),
250 );
251 }),
252 });
253
254type ObservedMember = {
255 id: string;
256 email?: string | null;
257 status?: string | null;
258};
259
260/**
261 * Read a membership, mapping "gone" — the member not being in the group
262 * (`UserGroupMemberNotFound`) or the group itself missing
263 * (`UserGroupNotFound`) — to `undefined`.
264 */
265const getMembership = (
266 accountId: string,
267 userGroupId: string,
268 memberId: string,
269) =>
270 iam.getUserGroupMember({ accountId, userGroupId, memberId }).pipe(
271 Effect.map((m): ObservedMember | undefined => m),
272 Effect.catchTag(["UserGroupMemberNotFound", "UserGroupNotFound"], () =>
273 Effect.succeed(undefined),
274 ),
275 );
276
277const toAttributes = (
278 member: ObservedMember,
279 userGroupId: string,
280 accountId: string,
281): UserGroupMembershipAttributes => ({
282 userGroupId,
283 memberId: member.id,
284 accountId,
285 email: member.email ?? undefined,
286 status: member.status ?? undefined,
287});