1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13/**
14 * One arm of a Cloudflare Access policy rule discriminated union. A rule is a
15 * single-key object whose key selects the rule kind (`email`, `emailDomain`,
16 * `everyone`, `ip`, etc.) and whose value carries the rule's parameters.
17 *
18 * Re-exported from `@distilled.cloud/cloudflare/zero-trust`'s
19 * `CreateAccessPolicyRequest` so the full Cloudflare rule surface is available
20 * without re-declaring the union.
21 */
22export type PolicyRule = zeroTrust.CreateAccessPolicyRequest["include"][number];
23
24/**
25 * One arm of the exclude-side rule union, and its require-side twin.
26 * Cloudflare's spec types the exclude/require rule lists separately from
27 * include — a few rule kinds (e.g. the GitHub-organization rule) carry the
28 * raw wire shape there — so these props use the SDK's own unions rather
29 * than reusing {@link PolicyRule}.
30 */
31export type PolicyExcludeRule = NonNullable<
32 zeroTrust.CreateAccessPolicyRequest["exclude"]
33>[number];
34export type PolicyRequireRule = NonNullable<
35 zeroTrust.CreateAccessPolicyRequest["require"]
36>[number];
37
38/**
39 * Scalar shorthand for rule kinds with a single parameter (and bare names
40 * for the parameter-less kinds) — expanded to Cloudflare's wire shape by
41 * the providers, so both spellings are equivalent:
42 *
43 * ```ts
44 * include: [{ emailDomain: "example.com" }] // { emailDomain: { domain: "example.com" } }
45 * include: [{ email: "sam@example.com" }] // { email: { email: "sam@example.com" } }
46 * include: ["everyone"] // { everyone: {} }
47 * require: [{ geo: "US" }] // { geo: { countryCode: "US" } }
48 * ```
49 *
50 * Multi-parameter kinds (`gsuite`, `okta`, `saml`, `oidc`, `azureAD`,
51 * `githubOrganization`, `externalEvaluation`, `authContext`) keep their
52 * wire shape — there is no scalar to collapse them to.
53 */
54export type PolicyRuleShorthand =
55 | "everyone"
56 | "certificate"
57 | "anyValidServiceToken"
58 | { email: string }
59 | { emailDomain: string }
60 | { emailList: string }
61 | { ip: string }
62 | { ipList: string }
63 | { group: string }
64 | { loginMethod: string }
65 | { serviceToken: string }
66 | { geo: string }
67 | { authMethod: string }
68 | { devicePosture: string }
69 | { commonName: string }
70 | { linkedAppToken: string }
71 | { userRiskScore: string }
72 | { cloudflareAccountMember: string };
73
74/** A rule in either spelling: Cloudflare's wire shape or the scalar shorthand. */
75export type PolicyRuleInput = PolicyRule | PolicyRuleShorthand;
76export type PolicyExcludeRuleInput = PolicyExcludeRule | PolicyRuleShorthand;
77export type PolicyRequireRuleInput = PolicyRequireRule | PolicyRuleShorthand;
78
79/** Rule kind → its single parameter's (camelCase) member name. */
80const SCALAR_RULE_PARAMS: Record<string, string> = {
81 email: "email",
82 emailDomain: "domain",
83 emailList: "id",
84 ip: "ip",
85 ipList: "id",
86 group: "id",
87 loginMethod: "id",
88 serviceToken: "tokenId",
89 geo: "countryCode",
90 authMethod: "authMethod",
91 devicePosture: "integrationUid",
92 commonName: "commonName",
93 linkedAppToken: "appUid",
94 userRiskScore: "userRiskScore",
95 cloudflareAccountMember: "accountId",
96};
97
98/**
99 * Expand the scalar shorthand to Cloudflare's wire shape; wire-shaped rules
100 * pass through untouched.
101 */
102export const normalizePolicyRule = <Rule>(
103 rule: Rule | PolicyRuleShorthand,
104): Rule => {
105 if (typeof rule === "string") {
106 // "everyone" -> { everyone: {} }
107 return { [rule]: {} } as Rule;
108 }
109 const keys = Object.keys(rule as object);
110 if (keys.length === 1) {
111 const kind = keys[0];
112 const value = (rule as Record<string, unknown>)[kind];
113 const param = SCALAR_RULE_PARAMS[kind];
114 if (param !== undefined && (value === null || typeof value !== "object")) {
115 return { [kind]: { [param]: value } } as Rule;
116 }
117 }
118 return rule as Rule;
119};
120
121export function normalizePolicyRules<Rule>(
122 rules: ReadonlyArray<Rule | PolicyRuleShorthand>,
123): Rule[];
124export function normalizePolicyRules<Rule>(
125 rules: ReadonlyArray<Rule | PolicyRuleShorthand> | undefined,
126): Rule[] | undefined;
127export function normalizePolicyRules<Rule>(
128 rules: ReadonlyArray<Rule | PolicyRuleShorthand> | undefined,
129): Rule[] | undefined {
130 return rules === undefined
131 ? undefined
132 : rules.map((r) => normalizePolicyRule(r));
133}
134
135/**
136 * Decision Cloudflare Access takes when a request matches this policy.
137 *
138 * - `"allow"` — admit the user.
139 * - `"deny"` — block the user.
140 * - `"non_identity"` — admit without requiring an identity provider login.
141 * - `"bypass"` — skip Access entirely.
142 */
143export type PolicyDecision =
144 | "allow"
145 | "deny"
146 | "non_identity"
147 | "bypass"
148 // Match distilled's open-ended literal union so Cloudflare-returned values
149 // outside the closed set still narrow cleanly.
150 | (string & {});
151
152export type PolicyProps = {
153 /**
154 * Display name for the policy. Treated as a stable identifier so the
155 * provider can locate the policy by name during adoption / state recovery.
156 * If omitted, a unique name is generated from the stack/stage/logical id.
157 *
158 * @default ${app}-${stage}-${id}
159 */
160 name?: string;
161 /**
162 * Decision the policy enforces when its rules match. Changing the decision
163 * triggers replacement.
164 */
165 decision: PolicyDecision;
166 /**
167 * Rules combined with logical OR. A request must satisfy at least one
168 * include rule for the policy to match. Required and must be non-empty.
169 */
170 include: PolicyRuleInput[];
171 /**
172 * Rules combined with logical NOT. A request matching any exclude rule is
173 * rejected by the policy even if it satisfied an include rule.
174 */
175 exclude?: PolicyExcludeRuleInput[];
176 /**
177 * Rules combined with logical AND. A request must satisfy every require
178 * rule in addition to an include rule.
179 */
180 require?: PolicyRequireRuleInput[];
181 /**
182 * Duration of issued session tokens. Format: `300ms`, `2h45m`, etc. When
183 * unset, applications using this policy fall back to their own configured
184 * session duration.
185 */
186 sessionDuration?: string;
187 /**
188 * When true, Access requires an administrator to approve each authentication
189 * request before the user is admitted.
190 *
191 * @default false
192 */
193 approvalRequired?: boolean;
194 /**
195 * When true, users must enter a justification when logging in to any
196 * application that consumes this policy.
197 *
198 * @default false
199 */
200 purposeJustificationRequired?: boolean;
201 /**
202 * Adopt an existing reusable policy with the same name when the engine has
203 * no prior state for this logical id.
204 *
205 * @default false
206 */
207 adopt?: boolean;
208};
209
210export declare namespace Policy {
211 /**
212 * A single Access policy rule. See {@link PolicyRule} for the full
213 * discriminated union (email, emailDomain, everyone, ip, ipList, group,
214 * gsuite, githubOrganization, okta, azureAD, saml, oidc, deviceCheck via
215 * `devicePosture`, externalEvaluation, etc.).
216 */
217 export type RuleGroup = PolicyRuleInput;
218}
219
220export type Policy = Resource<
221 "Cloudflare.Access.Policy",
222 PolicyProps,
223 {
224 /** UUID of the policy assigned by Cloudflare. */
225 policyId: string;
226 /** Display name reported by Cloudflare. */
227 name: string;
228 /** Decision the policy enforces. */
229 decision: string;
230 /** Cloudflare account that owns the policy. */
231 accountId: string;
232 /** Creation timestamp reported by Cloudflare, when available. */
233 createdAt: string | undefined;
234 /** Last-modified timestamp reported by Cloudflare, when available. */
235 updatedAt: string | undefined;
236 },
237 never,
238 Providers
239>;
240
241/**
242 * A reusable, account-scoped Cloudflare Access policy. Distinct from the
243 * inline policies attached directly to an `Application` — a reusable
244 * policy can be referenced by multiple applications by id.
245 * ### Creating a Policy
246 * **Example:** Allow a single email domain
247 * ```typescript
248 * const policy = yield* Cloudflare.Access.Policy("AllowExampleDomain", {
249 * decision: "allow",
250 * include: [{ emailDomain: { domain: "example.com" } }],
251 * });
252 * ```
253 *
254 * **Example:** Allow everyone but require purpose justification
255 * ```typescript
256 * const policy = yield* Cloudflare.Access.Policy("OpenWithJustification", {
257 * decision: "allow",
258 * include: [{ everyone: {} }],
259 * purposeJustificationRequired: true,
260 * sessionDuration: "12h",
261 * });
262 * ```
263 *
264 * ### Combining rule groups
265 * **Example:** Include + exclude + require
266 * ```typescript
267 * const policy = yield* Cloudflare.Access.Policy("EngineersExceptInterns", {
268 * decision: "allow",
269 * include: [{ emailDomain: { domain: "example.com" } }],
270 * exclude: [{ email: { email: "intern@example.com" } }],
271 * require: [{ geo: { countryCode: "US" } }],
272 * });
273 * ```
274 *
275 * @resource
276 * @product Access
277 * @category Cloudflare One (Zero Trust)
278 */
279export const Policy = Resource<Policy>("Cloudflare.Access.Policy", {
280 aliases: ["Cloudflare.AccessPolicy"],
281});
282
283export const PolicyProvider = () =>
284 Provider.succeed(Policy, {
285 stables: ["policyId", "accountId", "decision"],
286 diff: Effect.fn(function* ({ id, olds = {}, news, output }) {
287 const { accountId } = yield* yield* CloudflareEnvironment;
288 if (!isResolved(news)) return undefined;
289 if ((output?.accountId ?? accountId) !== accountId) {
290 return { action: "replace" } as const;
291 }
292 const oldName = output?.name ?? (yield* createPolicyName(id, olds.name));
293 // Auto-generated names are engine-owned: the deployed name stays
294 // authoritative even if the generator would name this id differently
295 // today. Only an explicit user-provided name can force a replace.
296 const name = news.name ?? oldName;
297 if (name !== oldName) {
298 return { action: "replace" } as const;
299 }
300 const oldDecision = output?.decision ?? olds.decision;
301 if (oldDecision && oldDecision !== news.decision) {
302 return { action: "replace" } as const;
303 }
304 }),
305 reconcile: Effect.fn(function* ({ id, news = {} as PolicyProps, output }) {
306 const { accountId } = yield* yield* CloudflareEnvironment;
307 // Prefer the deployed name: regenerating would target a different
308 // resource if the generator's output for this id ever drifts.
309 const name = yield* createPolicyName(id, news.name ?? output?.name);
310 const acct = output?.accountId ?? accountId;
311
312 // Observe — prefer cached policyId, fall back to a name lookup so
313 // we recover from out-of-band deletes and partial state-persistence
314 // failures.
315 let observed: ObservedPolicy | undefined;
316 if (output?.policyId) {
317 observed = yield* zeroTrust
318 .getAccessPolicy({
319 accountId: acct,
320 policyId: output.policyId,
321 })
322 .pipe(
323 Effect.map(toObserved),
324 Effect.catch((): Effect.Effect<ObservedPolicy | undefined> =>
325 Effect.succeed(undefined),
326 ),
327 );
328 }
329 if (!observed) {
330 observed = yield* findPolicyByName(acct, name);
331 }
332
333 // Ensure — create the policy if missing. Tolerate a race where a
334 // parallel actor created the same-named policy by re-observing.
335 let ensured: ObservedPolicy;
336 if (!observed || !observed.id) {
337 ensured = yield* zeroTrust
338 .createAccessPolicy({
339 accountId: acct,
340 name,
341 decision: news.decision,
342 include: normalizePolicyRules(news.include),
343 exclude: normalizePolicyRules(news.exclude),
344 require: normalizePolicyRules(news.require),
345 sessionDuration: news.sessionDuration,
346 approvalRequired: news.approvalRequired,
347 purposeJustificationRequired: news.purposeJustificationRequired,
348 })
349 .pipe(
350 Effect.map(toObserved),
351 Effect.catch((err) =>
352 Effect.gen(function* () {
353 const existing = yield* findPolicyByName(acct, name);
354 if (existing && existing.id) return existing;
355 return yield* Effect.fail(err);
356 }),
357 ),
358 );
359 } else {
360 // Sync — Cloudflare PUTs the policy as a whole replacement, so a
361 // single update converges every mutable field. Always issuing the
362 // PUT keeps the resource convergent against drift; the API is
363 // idempotent for equal payloads.
364 const prior = observed;
365 const updated = yield* zeroTrust.updateAccessPolicy({
366 accountId: acct,
367 policyId: prior.id!,
368 name,
369 decision: news.decision,
370 include: normalizePolicyRules(news.include),
371 exclude: normalizePolicyRules(news.exclude),
372 require: normalizePolicyRules(news.require),
373 sessionDuration: news.sessionDuration,
374 approvalRequired: news.approvalRequired,
375 purposeJustificationRequired: news.purposeJustificationRequired,
376 });
377 ensured = {
378 id: updated.id ?? prior.id,
379 name: updated.name ?? prior.name,
380 decision: updated.decision ?? prior.decision,
381 createdAt: updated.createdAt ?? prior.createdAt,
382 updatedAt: updated.updatedAt ?? prior.updatedAt,
383 };
384 }
385
386 if (!ensured.id) {
387 return yield* Effect.fail(
388 new Error("Policy: ensured policy missing id"),
389 );
390 }
391 return {
392 policyId: ensured.id,
393 name: ensured.name ?? name,
394 decision: ensured.decision ?? news.decision,
395 accountId: acct,
396 createdAt: ensured.createdAt ?? undefined,
397 updatedAt: ensured.updatedAt ?? undefined,
398 };
399 }),
400 // Account-scoped collection (pattern (b)): enumerate every reusable
401 // Access policy in the ambient account, exhaustively paginated, and
402 // hydrate each into the exact `read` Attributes shape. Items missing the
403 // mandatory id are skipped (typed per-item drop).
404 list: Effect.fn(function* () {
405 const { accountId } = yield* yield* CloudflareEnvironment;
406 return yield* zeroTrust.listAccessPolicies.pages({ accountId }).pipe(
407 Stream.runCollect,
408 Effect.map((chunk) =>
409 Array.from(chunk).flatMap((page) =>
410 (page.result ?? []).flatMap((raw) => {
411 const observed = toObserved(raw as RawPolicy);
412 if (!observed.id) return [];
413 return [
414 {
415 policyId: observed.id,
416 name: observed.name ?? "",
417 decision: observed.decision ?? "allow",
418 accountId,
419 createdAt: observed.createdAt ?? undefined,
420 updatedAt: observed.updatedAt ?? undefined,
421 },
422 ];
423 }),
424 ),
425 ),
426 );
427 }),
428 delete: Effect.fn(function* ({ output }) {
429 yield* zeroTrust
430 .deleteAccessPolicy({
431 accountId: output.accountId,
432 policyId: output.policyId,
433 })
434 .pipe(Effect.catch((): Effect.Effect<void> => Effect.void));
435 }),
436 read: Effect.fn(function* ({ id, output, olds }) {
437 const { accountId } = yield* yield* CloudflareEnvironment;
438 const acct = output?.accountId ?? accountId;
439 if (output?.policyId) {
440 const direct = yield* zeroTrust
441 .getAccessPolicy({
442 accountId: acct,
443 policyId: output.policyId,
444 })
445 .pipe(
446 Effect.catch((): Effect.Effect<ObservedPolicy | undefined> =>
447 Effect.succeed(undefined),
448 ),
449 );
450 if (direct && direct.id) {
451 return {
452 policyId: direct.id,
453 name: direct.name ?? output.name,
454 decision: direct.decision ?? output.decision,
455 accountId: acct,
456 createdAt: direct.createdAt ?? output.createdAt,
457 updatedAt: direct.updatedAt ?? output.updatedAt,
458 };
459 }
460 }
461 const name = yield* createPolicyName(id, olds?.name ?? output?.name);
462 const existing = yield* findPolicyByName(acct, name);
463 if (!existing || !existing.id) return undefined;
464 return {
465 policyId: existing.id,
466 name: existing.name ?? name,
467 decision: existing.decision ?? olds?.decision ?? "allow",
468 accountId: acct,
469 createdAt: existing.createdAt ?? undefined,
470 updatedAt: existing.updatedAt ?? undefined,
471 };
472 }),
473 });
474
475const createPolicyName = (id: string, name: string | undefined) =>
476 Effect.gen(function* () {
477 if (name) return name;
478 return yield* createPhysicalName({ id });
479 });
480
481const findPolicyByName = (acct: string, name: string) =>
482 zeroTrust.listAccessPolicies.items({ accountId: acct }).pipe(
483 Stream.filter((p) => p.name === name),
484 Stream.runHead,
485 Effect.map(Option.getOrUndefined),
486 Effect.catch(() => Effect.succeed(undefined)),
487 );
488
489type ObservedPolicy = {
490 id?: string | null;
491 name?: string | null;
492 decision?: PolicyDecision | null;
493 createdAt?: string | null;
494 updatedAt?: string | null;
495};
496
497// Distilled response shapes (Get / Create / Update) all carry the same set
498// of fields we care about, but typed wider than `ObservedPolicy`. Narrow at
499// the API boundary so the rest of the reconciler stays in our shape.
500type RawPolicy = {
501 id?: string | null;
502 name?: string | null;
503 decision?: PolicyDecision | null | string;
504 createdAt?: string | null;
505 updatedAt?: string | null;
506};
507
508const toObserved = (r: RawPolicy): ObservedPolicy => ({
509 id: r.id,
510 name: r.name,
511 decision: r.decision ?? null,
512 createdAt: r.createdAt,
513 updatedAt: r.updatedAt,
514});