1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Stream from "effect/Stream";
6
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14/**
15 * One arm of a Cloudflare Access group rule discriminated union. A rule is a
16 * single-key object whose key selects the rule kind (`email`, `emailDomain`,
17 * `everyone`, `ip`, `geo`, `serviceToken`, etc.) and whose value carries the
18 * rule's parameters.
19 *
20 * Re-exported from `@distilled.cloud/cloudflare/zero-trust`'s
21 * `CreateAccessGroupForAccountRequest` so the full Cloudflare rule surface is
22 * available without re-declaring the union.
23 */
24export type GroupRule =
25 zeroTrust.CreateAccessGroupForAccountRequest["include"][number];
26
27/**
28 * One arm of the exclude-side rule union, and its require-side twin.
29 * Cloudflare's spec types the exclude/require rule lists separately from
30 * include — a few rule kinds (e.g. the GitHub-organization rule) carry the
31 * raw wire shape there — so these props use the SDK's own unions rather
32 * than reusing {@link GroupRule}.
33 */
34export type GroupExcludeRule = NonNullable<
35 zeroTrust.CreateAccessGroupForAccountRequest["exclude"]
36>[number];
37export type GroupRequireRule = NonNullable<
38 zeroTrust.CreateAccessGroupForAccountRequest["require"]
39>[number];
40
41export type GroupProps = {
42 /**
43 * Display name for the group. Used as a stable identifier so the provider
44 * can locate the group by name during adoption / state recovery. If
45 * omitted, a unique name is generated from the stack/stage/logical id.
46 *
47 * @default ${app}-${stage}-${id}
48 */
49 name?: string;
50 /**
51 * Rules combined with logical OR. A user needs to meet only one of the
52 * Include rules to match the group. Required and must be non-empty.
53 */
54 include: GroupRule[];
55 /**
56 * Rules combined with logical NOT. A user matching any Exclude rule does
57 * not match the group, even if they satisfied an Include rule.
58 */
59 exclude?: GroupExcludeRule[];
60 /**
61 * Rules combined with logical AND. A user must satisfy every Require rule
62 * in addition to an Include rule.
63 */
64 require?: GroupRequireRule[];
65 /**
66 * Whether this is the default group for the Zero Trust organization.
67 *
68 * @default false
69 */
70 isDefault?: boolean;
71};
72
73export type Group = Resource<
74 "Cloudflare.Access.Group",
75 GroupProps,
76 {
77 /** UUID of the group assigned by Cloudflare. */
78 groupId: string;
79 /** Cloudflare account that owns the group. */
80 accountId: string;
81 /** Display name reported by Cloudflare. */
82 name: string;
83 /** Whether Cloudflare reports this group as the organization default. */
84 isDefault: boolean | undefined;
85 },
86 never,
87 Providers
88>;
89
90/**
91 * A Cloudflare Zero Trust Access group — a reusable, account-scoped set of
92 * Access rule criteria. Groups are referenced from Access policies via a
93 * `{ group: { id } }` rule, letting many policies share one membership
94 * definition.
95 * ### Creating a Group
96 * **Example:** Allow a single email domain
97 * ```typescript
98 * const group = yield* Cloudflare.Access.Group("ExampleDomain", {
99 * include: [{ emailDomain: { domain: "example.com" } }],
100 * });
101 * ```
102 *
103 * **Example:** Combine include, exclude and require rules
104 * ```typescript
105 * const group = yield* Cloudflare.Access.Group("UsEngineers", {
106 * include: [{ emailDomain: { domain: "example.com" } }],
107 * exclude: [{ email: { email: "intern@example.com" } }],
108 * require: [{ geo: { countryCode: "US" } }],
109 * });
110 * ```
111 *
112 * ### Referencing a Group from a Policy
113 * **Example:** Allow members of the group
114 * ```typescript
115 * const group = yield* Cloudflare.Access.Group("Team", {
116 * include: [{ emailDomain: { domain: "example.com" } }],
117 * });
118 *
119 * const policy = yield* Cloudflare.Access.Policy("AllowTeam", {
120 * decision: "allow",
121 * include: [{ group: { id: group.groupId } }],
122 * });
123 * ```
124 *
125 * @resource
126 * @product Access
127 * @category Cloudflare One (Zero Trust)
128 */
129export const Group = Resource<Group>("Cloudflare.Access.Group");
130
131export const isGroup = (value: unknown): value is Group =>
132 Predicate.hasProperty(value, "Type") &&
133 value.Type === "Cloudflare.Access.Group";
134
135export const GroupProvider = () =>
136 Provider.succeed(Group, {
137 stables: ["groupId", "accountId"],
138 diff: Effect.fn(function* ({ news, output }) {
139 const { accountId } = yield* yield* CloudflareEnvironment;
140 if (!isResolved(news)) return undefined;
141 if ((output?.accountId ?? accountId) !== accountId) {
142 return { action: "replace" } as const;
143 }
144 // Everything else (name, rules, isDefault) converges via PUT.
145 }),
146 read: Effect.fn(function* ({ id, output, olds }) {
147 const { accountId } = yield* yield* CloudflareEnvironment;
148 const acct = output?.accountId ?? accountId;
149 if (output?.groupId) {
150 const direct = yield* zeroTrust
151 .getAccessGroupForAccount({
152 accountId: acct,
153 groupId: output.groupId,
154 })
155 .pipe(
156 Effect.map(toObserved),
157 Effect.catchTag("AccessGroupNotFound", () =>
158 Effect.succeed(undefined),
159 ),
160 );
161 if (direct && direct.id) {
162 return {
163 groupId: direct.id,
164 accountId: acct,
165 name: direct.name ?? output.name,
166 isDefault: direct.isDefault ?? output.isDefault,
167 };
168 }
169 }
170 const name = yield* createGroupName(id, olds?.name ?? output?.name);
171 const existing = yield* findGroupByName(acct, name);
172 if (!existing || !existing.id) return undefined;
173 return {
174 groupId: existing.id,
175 accountId: acct,
176 name: existing.name ?? name,
177 isDefault: existing.isDefault ?? undefined,
178 };
179 }),
180 list: Effect.fn(function* () {
181 const { accountId } = yield* yield* CloudflareEnvironment;
182 return yield* zeroTrust.listAccessGroupsForAccount
183 .pages({ accountId })
184 .pipe(
185 Stream.runCollect,
186 Effect.map((chunk) =>
187 Array.from(chunk).flatMap((page) =>
188 (page.result ?? [])
189 .filter(
190 (g): g is (typeof page.result)[number] & { id: string } =>
191 g.id != null,
192 )
193 .map((g) => ({
194 groupId: g.id,
195 accountId,
196 name: g.name ?? "",
197 isDefault: g.isDefault ?? undefined,
198 })),
199 ),
200 ),
201 );
202 }),
203 reconcile: Effect.fn(function* ({ id, news = {} as GroupProps, output }) {
204 const { accountId } = yield* yield* CloudflareEnvironment;
205 const name = yield* createGroupName(id, news.name);
206 const acct = output?.accountId ?? accountId;
207
208 // Observe — prefer the cached groupId, fall back to a name lookup so
209 // we recover from out-of-band deletes and partial state-persistence
210 // failures.
211 let observed: ObservedGroup | undefined;
212 if (output?.groupId) {
213 observed = yield* zeroTrust
214 .getAccessGroupForAccount({
215 accountId: acct,
216 groupId: output.groupId,
217 })
218 .pipe(
219 Effect.map(toObserved),
220 Effect.catchTag("AccessGroupNotFound", () =>
221 Effect.succeed(undefined),
222 ),
223 );
224 }
225 if (!observed) {
226 observed = yield* findGroupByName(acct, name);
227 }
228
229 // Ensure — create the group when missing. Tolerate a race where a
230 // parallel actor created the same-named group by re-observing.
231 let ensured: ObservedGroup;
232 if (!observed || !observed.id) {
233 ensured = yield* zeroTrust
234 .createAccessGroupForAccount({
235 accountId: acct,
236 name,
237 include: news.include,
238 exclude: news.exclude,
239 require: news.require,
240 isDefault: news.isDefault,
241 })
242 .pipe(
243 Effect.map(toObserved),
244 Effect.catch((err) =>
245 Effect.gen(function* () {
246 const existing = yield* findGroupByName(acct, name);
247 if (existing && existing.id) return existing;
248 return yield* Effect.fail(err);
249 }),
250 ),
251 );
252 } else {
253 // Sync — Cloudflare PUTs the group as a whole replacement, so a
254 // single update converges every mutable field (name, rule sets,
255 // isDefault). The API is idempotent for equal payloads.
256 const prior = observed;
257 const updated = yield* zeroTrust.updateAccessGroupForAccount({
258 accountId: acct,
259 groupId: prior.id!,
260 name,
261 include: news.include,
262 exclude: news.exclude,
263 require: news.require,
264 isDefault: news.isDefault,
265 });
266 ensured = {
267 id: updated.id ?? prior.id,
268 name: updated.name ?? prior.name,
269 isDefault: updated.isDefault ?? prior.isDefault,
270 };
271 }
272
273 if (!ensured.id) {
274 return yield* Effect.fail(new Error("Group: ensured group missing id"));
275 }
276 return {
277 groupId: ensured.id,
278 accountId: acct,
279 name: ensured.name ?? name,
280 isDefault: ensured.isDefault ?? news.isDefault,
281 };
282 }),
283 delete: Effect.fn(function* ({ output }) {
284 yield* zeroTrust
285 .deleteAccessGroupForAccount({
286 accountId: output.accountId,
287 groupId: output.groupId,
288 })
289 .pipe(Effect.catchTag("AccessGroupNotFound", () => Effect.void));
290 }),
291 });
292
293const createGroupName = (id: string, name: string | undefined) =>
294 Effect.gen(function* () {
295 if (name) return name;
296 return yield* createPhysicalName({ id });
297 });
298
299const findGroupByName = (acct: string, name: string) =>
300 zeroTrust.listAccessGroupsForAccount.items({ accountId: acct }).pipe(
301 Stream.filter((g): g is ObservedGroup => g.name === name),
302 Stream.runHead,
303 Effect.map(Option.getOrUndefined),
304 Effect.catch(() => Effect.succeed(undefined)),
305 );
306
307type ObservedGroup = {
308 id?: string | null;
309 name?: string | null;
310 isDefault?: boolean | null;
311};
312
313const toObserved = (g: ObservedGroup): ObservedGroup => ({
314 id: g.id,
315 name: g.name,
316 isDefault: g.isDefault,
317});