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.ResourceGroup" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * The scope of a resource group — a scope key (e.g.
17 * `com.cloudflare.api.account.{accountId}`) plus the objects it contains
18 * (e.g. `com.cloudflare.api.account.zone.{zoneId}` or `*` for everything
19 * in the scope).
20 */
21export interface ResourceGroupScopeInput {
22 /**
23 * The scope key, e.g. `com.cloudflare.api.account.{accountId}`.
24 */
25 key: string;
26 /**
27 * The objects within the scope this resource group spans, e.g.
28 * `com.cloudflare.api.account.zone.{zoneId}` or `*`.
29 */
30 objects: { key: string }[];
31}
32
33/**
34 * A fully-resolved resource group scope as observed on Cloudflare.
35 */
36export interface ResourceGroupScope {
37 /** The scope key, e.g. `com.cloudflare.api.account.{accountId}`. */
38 key: string;
39 /** The objects within the scope this resource group spans. */
40 objects: { key: string }[];
41}
42
43export interface ResourceGroupProps {
44 /**
45 * Name of the resource group. If omitted, a unique name is generated
46 * from the app, stage, and logical ID.
47 * @default ${app}-${stage}-${id}
48 */
49 name?: string;
50 /**
51 * The scope of the resource group: a scope key (typically
52 * `com.cloudflare.api.account.{accountId}`) and the objects it
53 * contains (zones, or `*` for the whole account). Mutable in place.
54 */
55 scope: ResourceGroupScopeInput;
56}
57
58export interface ResourceGroupAttributes {
59 /** Cloudflare-assigned identifier of the resource group. */
60 resourceGroupId: string;
61 /** The Cloudflare account the resource group belongs to. */
62 accountId: string;
63 /** Name of the resource group. */
64 name: string;
65 /** The scope of the resource group as observed on Cloudflare. */
66 scope: ResourceGroupScope;
67}
68
69export type ResourceGroup = Resource<
70 TypeId,
71 ResourceGroupProps,
72 ResourceGroupAttributes,
73 never,
74 Providers
75>;
76
77/**
78 * A Cloudflare IAM resource group — a named set of account resources
79 * (zones, or the whole account) that fine-grained policies attach to.
80 *
81 * Resource groups pair with permission groups inside a user group policy:
82 * the permission group says *what* actions are allowed, the resource group
83 * says *which* resources they apply to. Both `name` and `scope` are mutable
84 * in place.
85 *
86 * Account-scoped IAM (resource groups, user groups) is an Enterprise
87 * feature.
88 * ### Creating a Resource Group
89 * **Example:** Scope a group to the whole account
90 * ```typescript
91 * const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment;
92 * const group = yield* Cloudflare.Iam.ResourceGroup("AllResources", {
93 * scope: {
94 * key: `com.cloudflare.api.account.${accountId}`,
95 * objects: [{ key: "*" }],
96 * },
97 * });
98 * ```
99 *
100 * **Example:** Scope a group to a single zone
101 * ```typescript
102 * const group = yield* Cloudflare.Iam.ResourceGroup("ZoneOnly", {
103 * name: "my-zone-resources",
104 * scope: {
105 * key: `com.cloudflare.api.account.${accountId}`,
106 * objects: [
107 * { key: `com.cloudflare.api.account.zone.${zone.zoneId}` },
108 * ],
109 * },
110 * });
111 * ```
112 *
113 * ### Using with User Groups
114 * **Example:** Attach to a user group policy
115 * ```typescript
116 * yield* Cloudflare.Iam.UserGroup("Readers", {
117 * policies: [
118 * {
119 * access: "allow",
120 * permissionGroups: [readOnlyPermissionGroupId],
121 * resourceGroups: [group.resourceGroupId],
122 * },
123 * ],
124 * });
125 * ```
126 *
127 * @see https://developers.cloudflare.com/fundamentals/manage-members/scoped-roles/
128 *
129 * @resource
130 * @product IAM
131 * @category Account & Identity
132 */
133export const ResourceGroup = Resource<ResourceGroup>(TypeId);
134
135/**
136 * Returns true if the given value is an ResourceGroup resource.
137 */
138export const isResourceGroup = (value: unknown): value is ResourceGroup =>
139 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
140
141export const ResourceGroupProvider = () =>
142 Provider.succeed(ResourceGroup, {
143 stables: ["resourceGroupId", "accountId"],
144
145 read: Effect.fn(function* ({ id, output, olds }) {
146 const { accountId } = yield* yield* CloudflareEnvironment;
147 const acct = output?.accountId ?? accountId;
148
149 if (output?.resourceGroupId) {
150 const observed = yield* getResourceGroup(acct, output.resourceGroupId);
151 if (observed) return toAttributes(observed, acct);
152 return undefined;
153 }
154
155 // Cold read — recover from lost state by matching the deterministic
156 // physical name. Names are not unique on Cloudflare's side; an exact
157 // match on our generated/explicit name is the best identity we have.
158 const name = yield* createGroupName(id, olds?.name);
159 const match = yield* findByName(acct, name);
160 return match ? toAttributes(match, acct) : undefined;
161 }),
162
163 reconcile: Effect.fn(function* ({ id, news, output }) {
164 const { accountId } = yield* yield* CloudflareEnvironment;
165 const name = yield* createGroupName(id, news.name);
166 // Inputs have been resolved to concrete strings by Plan.
167 const desiredScope = resolveScope(news.scope);
168
169 // 1. Observe — the id cached on `output` is a hint, not a guarantee:
170 // a missing group falls through to the name scan and then create.
171 let observed = output?.resourceGroupId
172 ? yield* getResourceGroup(accountId, output.resourceGroupId)
173 : undefined;
174 if (!observed) {
175 observed = yield* findByName(accountId, name);
176 }
177
178 // 2. Ensure — create when missing. Names are not unique on
179 // Cloudflare's side, so there is no AlreadyExists race to tolerate.
180 if (!observed) {
181 const created = yield* iam.createResourceGroup({
182 accountId,
183 name,
184 scope: desiredScope,
185 });
186 return toAttributes(created, accountId);
187 }
188
189 // 3. Sync — diff observed name/scope against desired; the update is
190 // a PUT, so send the full body, but skip the call on a no-op.
191 const observedScope = parseScope(observed.scope);
192 const dirty =
193 (observed.name ?? "") !== name ||
194 !sameScope(observedScope, desiredScope);
195 if (!dirty) {
196 return toAttributes(observed, accountId);
197 }
198
199 const updated = yield* iam.updateResourceGroup({
200 accountId,
201 resourceGroupId: observed.id,
202 name,
203 scope: desiredScope,
204 });
205 return toAttributes(updated, accountId);
206 }),
207
208 delete: Effect.fn(function* ({ output }) {
209 yield* iam
210 .deleteResourceGroup({
211 accountId: output.accountId,
212 resourceGroupId: output.resourceGroupId,
213 })
214 .pipe(Effect.catchTag("ResourceGroupNotFound", () => Effect.void));
215 }),
216
217 // Account collection — the list op returns the full group record (id,
218 // name, scope) per page, so each item maps straight to the `read`
219 // Attributes shape without a per-item GET. Predefined/system resource
220 // groups are returned alongside ours, so a read-only list is often
221 // non-empty. Cloudflare paginates a single page set; exhaust it.
222 list: Effect.fn(function* () {
223 const { accountId } = yield* yield* CloudflareEnvironment;
224 return yield* iam.listResourceGroups.pages({ accountId }).pipe(
225 Stream.runCollect,
226 Effect.map((chunk) =>
227 Array.from(chunk).flatMap((page) =>
228 (page.result ?? [])
229 // Cloudflare seeds every account with predefined, non-editable
230 // system resource groups named `com.cloudflare.api.account.*`.
231 // They can't be deleted (`UnprocessableEntity: non-editable`),
232 // so exclude them from enumeration.
233 .filter((group) => !isSystemGroupName(group.name))
234 .map((group) => toAttributes(group, accountId)),
235 ),
236 ),
237 );
238 }),
239 });
240
241type ObservedResourceGroup = {
242 id: string;
243 name?: string | null;
244 scope: iam.ResourceGroupsListResultItemScope;
245};
246
247/**
248 * Cloudflare's predefined, non-editable account resource groups use the
249 * reserved `com.cloudflare.api.account.*` name. They are seeded on every
250 * account and cannot be deleted, so they must be excluded from enumeration.
251 */
252const isSystemGroupName = (name: string | null | undefined): boolean =>
253 (name ?? "").startsWith("com.cloudflare.api.");
254
255/**
256 * Read a resource group by id, mapping "gone" (`ResourceGroupNotFound`,
257 * HTTP 404 / Cloudflare error code 404) to `undefined`.
258 */
259const getResourceGroup = (accountId: string, resourceGroupId: string) =>
260 iam.getResourceGroup({ accountId, resourceGroupId }).pipe(
261 Effect.map((g): ObservedResourceGroup | undefined => g),
262 Effect.catchTag("ResourceGroupNotFound", () => Effect.succeed(undefined)),
263 );
264
265/**
266 * Find a resource group by exact name. Names are not unique on
267 * Cloudflare's side; pick the lexicographically-first id for determinism.
268 */
269const findByName = (accountId: string, name: string) =>
270 iam.listResourceGroups.items({ accountId, name }).pipe(
271 Stream.runCollect,
272 Effect.map((chunk) =>
273 Array.from(chunk)
274 .filter(
275 (g): g is ObservedResourceGroup & { name: string } => g.name === name,
276 )
277 .sort((a, b) => a.id.localeCompare(b.id))
278 .at(0),
279 ),
280 );
281
282const createGroupName = (id: string, name: string | undefined) =>
283 Effect.gen(function* () {
284 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
285 });
286
287/** Resolve `Input<string>` scope fields to concrete strings (post-Plan). */
288const resolveScope = (scope: ResourceGroupScopeInput): ResourceGroupScope => ({
289 key: scope.key as string,
290 objects: scope.objects.map((o) => ({ key: o.key as string })),
291});
292
293/**
294 * Decode the `unknown`-typed observed scope into our structured shape.
295 * Cloudflare always returns `{ key, objects: [{ key }] }` for a persisted
296 * resource group.
297 */
298const parseScope = (scope: unknown): ResourceGroupScope => {
299 const key =
300 Predicate.hasProperty(scope, "key") && typeof scope.key === "string"
301 ? scope.key
302 : "";
303 const objects =
304 Predicate.hasProperty(scope, "objects") && Array.isArray(scope.objects)
305 ? scope.objects.flatMap((o: unknown) =>
306 Predicate.hasProperty(o, "key") && typeof o.key === "string"
307 ? [{ key: o.key }]
308 : [],
309 )
310 : [];
311 return { key, objects };
312};
313
314const sameScope = (a: ResourceGroupScope, b: ResourceGroupScope) =>
315 a.key === b.key &&
316 a.objects.length === b.objects.length &&
317 a.objects
318 .map((o) => o.key)
319 .sort()
320 .join(",") ===
321 b.objects
322 .map((o) => o.key)
323 .sort()
324 .join(",");
325
326const toAttributes = (
327 group: ObservedResourceGroup,
328 accountId: string,
329): ResourceGroupAttributes => ({
330 resourceGroupId: group.id,
331 accountId,
332 name: group.name ?? "",
333 scope: parseScope(group.scope),
334});