1import * as iam from "@distilled.cloud/cloudflare/iam";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Iam.UserGroup" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * A fine-grained policy attached to a user group: an allow/deny decision
17 * over a set of permission groups (what actions) and resource groups
18 * (which resources).
19 */
20export interface UserGroupPolicyInput {
21 /**
22 * Whether the policy allows or denies the combined permission/resource
23 * groups. Note: Cloudflare currently rejects `deny` user-group policies
24 * with "Policy validation failed" — only `allow` is accepted in
25 * practice.
26 */
27 access: "allow" | "deny";
28 /**
29 * IDs of the permission groups (what actions are permitted). Look these
30 * up via the account's `/iam/permission_groups` catalog.
31 */
32 permissionGroups: string[];
33 /**
34 * IDs of the resource groups (which resources the permissions apply
35 * to) — e.g. from {@link ResourceGroup}.
36 */
37 resourceGroups: string[];
38}
39
40/**
41 * A fully-resolved user group policy as observed on Cloudflare, including
42 * the server-assigned policy id.
43 */
44export interface UserGroupPolicy {
45 /**
46 * Server-assigned identifier of the policy. Not stable — Cloudflare
47 * assigns fresh policy ids on every policy update.
48 */
49 id: string | undefined;
50 /** Whether the policy allows or denies. */
51 access: "allow" | "deny";
52 /** IDs of the permission groups in the policy. */
53 permissionGroups: string[];
54 /** IDs of the resource groups in the policy. */
55 resourceGroups: string[];
56}
57
58export interface UserGroupProps {
59 /**
60 * Name of the user group. If omitted, a unique name is generated from
61 * the app, stage, and logical ID.
62 * @default ${app}-${stage}-${id}
63 */
64 name?: string;
65 /**
66 * Fine-grained policies attached to the user group. Mutable in place —
67 * the full set is replaced on update.
68 */
69 policies?: UserGroupPolicyInput[];
70}
71
72export interface UserGroupAttributes {
73 /** Cloudflare-assigned identifier of the user group. */
74 userGroupId: string;
75 /** The Cloudflare account the user group belongs to. */
76 accountId: string;
77 /** Name of the user group. */
78 name: string;
79 /** Policies attached to the user group (with server-assigned ids). */
80 policies: UserGroupPolicy[];
81 /** ISO8601 creation timestamp. */
82 createdOn: string;
83 /** ISO8601 last-modified timestamp. */
84 modifiedOn: string;
85}
86
87export type UserGroup = Resource<
88 TypeId,
89 UserGroupProps,
90 UserGroupAttributes,
91 never,
92 Providers
93>;
94
95/**
96 * A Cloudflare IAM user group — a named set of account members that share
97 * fine-grained policies (permission groups scoped to resource groups).
98 *
99 * Both `name` and `policies` are mutable in place; updating policies
100 * replaces the full set. Add members with
101 * {@link UserGroupMembership}.
102 *
103 * Account-scoped IAM (resource groups, user groups) is an Enterprise
104 * feature.
105 * ### Creating a User Group
106 * **Example:** Empty group
107 * ```typescript
108 * const group = yield* Cloudflare.Iam.UserGroup("Operators", {});
109 * ```
110 *
111 * **Example:** Group with a policy
112 * ```typescript
113 * const readers = yield* Cloudflare.Iam.UserGroup("Readers", {
114 * name: "zone-readers",
115 * policies: [
116 * {
117 * access: "allow",
118 * permissionGroups: [readOnlyPermissionGroupId],
119 * resourceGroups: [resourceGroup.resourceGroupId],
120 * },
121 * ],
122 * });
123 * ```
124 *
125 * ### Managing Members
126 * **Example:** Add an account member to the group
127 * ```typescript
128 * yield* Cloudflare.Iam.UserGroupMembership("SamInReaders", {
129 * userGroup: readers.userGroupId,
130 * memberId: accountMember.memberId,
131 * });
132 * ```
133 *
134 * @see https://developers.cloudflare.com/fundamentals/manage-members/user-groups/
135 *
136 * @resource
137 * @product IAM
138 * @category Account & Identity
139 */
140export const UserGroup = Resource<UserGroup>(TypeId);
141
142/**
143 * Returns true if the given value is an UserGroup resource.
144 */
145export const isUserGroup = (value: unknown): value is UserGroup =>
146 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
147
148export const UserGroupProvider = () =>
149 Provider.succeed(UserGroup, {
150 stables: ["userGroupId", "accountId", "createdOn"],
151
152 // Account-scoped collection: exhaustively paginate the account's
153 // user-groups list. Each page item already carries the full shape
154 // (`id`/`name`/`policies`/`createdOn`/`modifiedOn`) identical to the
155 // single-get response, so it hydrates directly into `read`'s
156 // Attributes via `toAttributes` — no per-item re-fetch needed.
157 list: Effect.fn(function* () {
158 const { accountId } = yield* yield* CloudflareEnvironment;
159 return yield* iam.listUserGroups.pages({ accountId }).pipe(
160 Stream.runCollect,
161 Effect.map((chunk) =>
162 Array.from(chunk).flatMap((page) =>
163 (page.result ?? []).map((group) => toAttributes(group, accountId)),
164 ),
165 ),
166 );
167 }),
168
169 read: Effect.fn(function* ({ id, output, olds }) {
170 const { accountId } = yield* yield* CloudflareEnvironment;
171 const acct = output?.accountId ?? accountId;
172
173 if (output?.userGroupId) {
174 const observed = yield* getUserGroup(acct, output.userGroupId);
175 return observed ? toAttributes(observed, acct) : undefined;
176 }
177
178 // Cold read — recover from lost state by matching the deterministic
179 // physical name (exact match, re-checked client-side).
180 const name = yield* createGroupName(id, olds?.name);
181 const match = yield* findByName(acct, name);
182 if (match) {
183 const observed = yield* getUserGroup(acct, match.id);
184 return observed ? toAttributes(observed, acct) : undefined;
185 }
186 return undefined;
187 }),
188
189 reconcile: Effect.fn(function* ({ id, news, output }) {
190 const { accountId } = yield* yield* CloudflareEnvironment;
191 const name = yield* createGroupName(id, news.name);
192 const desired = resolvePolicies(news.policies ?? []);
193
194 // 1. Observe — the id cached on `output` is a hint, not a
195 // guarantee: a missing group falls through to the name scan and
196 // then to create.
197 let observed = output?.userGroupId
198 ? yield* getUserGroup(accountId, output.userGroupId)
199 : undefined;
200 if (!observed) {
201 const match = yield* findByName(accountId, name);
202 observed = match ? yield* getUserGroup(accountId, match.id) : undefined;
203 }
204
205 // 2. Ensure — create when missing. User group names ARE unique on
206 // Cloudflare's side; losing a create race surfaces as the typed
207 // `UserGroupNameInUse`, in which case we observe the winner and
208 // fall through to sync.
209 if (!observed) {
210 const created = yield* iam
211 .createUserGroup({
212 accountId,
213 name,
214 policies: desired.length > 0 ? desired : undefined,
215 })
216 .pipe(
217 Effect.catchTag("UserGroupNameInUse", () =>
218 Effect.succeed(undefined),
219 ),
220 );
221 if (created) {
222 return toAttributes(created, accountId);
223 }
224 const match = yield* findByName(accountId, name);
225 observed = match ? yield* getUserGroup(accountId, match.id) : undefined;
226 if (!observed) {
227 // The name is claimed but invisible to our listing — re-attempt
228 // the create so the typed conflict surfaces rather than looping.
229 const retried = yield* iam.createUserGroup({
230 accountId,
231 name,
232 policies: desired.length > 0 ? desired : undefined,
233 });
234 return toAttributes(retried, accountId);
235 }
236 }
237
238 // 3. Sync — diff observed name/policies against desired. The PUT
239 // replaces the full policy set and entries MUST be sent without
240 // ids (Cloudflare rejects re-used policy ids with "Cannot re-use
241 // policy IDs"); the server assigns fresh ids on every update.
242 // Skip the call entirely on a no-op.
243 const observedPolicies = parsePolicies(observed.policies);
244 const dirty =
245 observed.name !== name || !samePolicies(observedPolicies, desired);
246 if (!dirty) {
247 return toAttributes(observed, accountId);
248 }
249
250 const updated = yield* iam.updateUserGroup({
251 accountId,
252 userGroupId: observed.id,
253 name,
254 policies: desired,
255 });
256 return toAttributes(updated, accountId);
257 }),
258
259 delete: Effect.fn(function* ({ output }) {
260 yield* iam
261 .deleteUserGroup({
262 accountId: output.accountId,
263 userGroupId: output.userGroupId,
264 })
265 .pipe(Effect.catchTag("UserGroupNotFound", () => Effect.void));
266 }),
267 });
268
269type ObservedUserGroup = iam.GetUserGroupResponse;
270
271/**
272 * Read a user group by id, mapping "gone" (`UserGroupNotFound`,
273 * HTTP 404) to `undefined`.
274 */
275const getUserGroup = (accountId: string, userGroupId: string) =>
276 iam.getUserGroup({ accountId, userGroupId }).pipe(
277 Effect.map((g): ObservedUserGroup | undefined => g),
278 Effect.catchTag("UserGroupNotFound", () => Effect.succeed(undefined)),
279 );
280
281/**
282 * Find a user group by exact name (re-checked client-side). User group
283 * names are unique on Cloudflare's side; the sort is belt-and-braces
284 * determinism.
285 */
286const findByName = (accountId: string, name: string) =>
287 iam.listUserGroups.items({ accountId, name }).pipe(
288 Stream.runCollect,
289 Effect.map((chunk) =>
290 Array.from(chunk)
291 .filter((g) => g.name === name)
292 .sort((a, b) => a.createdOn.localeCompare(b.createdOn))
293 .at(0),
294 ),
295 );
296
297const createGroupName = (id: string, name: string | undefined) =>
298 Effect.gen(function* () {
299 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
300 });
301
302/** Resolve `Input<string>` policy ids to concrete strings (post-Plan). */
303const resolvePolicies = (policies: UserGroupPolicyInput[]) =>
304 policies.map((p) => ({
305 access: p.access,
306 permissionGroups: p.permissionGroups.map((id) => ({ id: id as string })),
307 resourceGroups: p.resourceGroups.map((id) => ({ id: id as string })),
308 }));
309
310const parsePolicies = (
311 policies: ObservedUserGroup["policies"],
312): UserGroupPolicy[] =>
313 (policies ?? []).map((p) => ({
314 id: p.id ?? undefined,
315 access: p.access === "deny" ? "deny" : "allow",
316 permissionGroups: (p.permissionGroups ?? []).map((g) => g.id),
317 resourceGroups: (p.resourceGroups ?? []).map((g) => g.id),
318 }));
319
320/** Canonical string form of a policy set for order-insensitive diffing. */
321const policyKey = (p: {
322 access: string;
323 permissionGroups: { id: string }[] | string[];
324 resourceGroups: { id: string }[] | string[];
325}) => {
326 const ids = (xs: { id: string }[] | string[]) =>
327 xs
328 .map((x) => (typeof x === "string" ? x : x.id))
329 .sort()
330 .join(",");
331 return `${p.access}|${ids(p.permissionGroups)}|${ids(p.resourceGroups)}`;
332};
333
334const samePolicies = (
335 observed: UserGroupPolicy[],
336 desired: ReturnType<typeof resolvePolicies>,
337) =>
338 observed.length === desired.length &&
339 observed.map(policyKey).sort().join(";") ===
340 desired.map(policyKey).sort().join(";");
341
342const toAttributes = (
343 group:
344 | iam.GetUserGroupResponse
345 | iam.CreateUserGroupResponse
346 | iam.UpdateUserGroupResponse,
347 accountId: string,
348): UserGroupAttributes => ({
349 userGroupId: group.id,
350 accountId,
351 name: group.name,
352 policies: parsePolicies(group.policies),
353 createdOn: group.createdOn,
354 modifiedOn: group.modifiedOn,
355});