1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { arrayEquals } from "../../Util/equal.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12/**
13 * Action Cloudflare Gateway takes when a rule's traffic/identity/device-posture
14 * expressions match. Mirrors the open-ended literal union distilled exposes so
15 * unknown values flowing back from Cloudflare narrow cleanly.
16 */
17export type RuleAction =
18 | "on"
19 | "off"
20 | "allow"
21 | "block"
22 | "scan"
23 | "noscan"
24 | "safesearch"
25 | "ytrestricted"
26 | "isolate"
27 | "noisolate"
28 | "override"
29 | "l4_override"
30 | "egress"
31 | "resolve"
32 | "quarantine"
33 | "redirect"
34 | (string & {});
35
36/**
37 * Filter Cloudflare Gateway evaluates the rule against. Selects the layer the
38 * `traffic` expression operates on — DNS, HTTP, raw L4, the egress path, or
39 * the `dns_resolver` plane (which is what private-app destinations use when
40 * Gateway needs to override the answer for an internal hostname).
41 */
42export type RuleFilter =
43 | "http"
44 | "dns"
45 | "l4"
46 | "egress"
47 | "dns_resolver"
48 | (string & {});
49
50/**
51 * Settings the rule applies when it matches. Re-exports distilled's request
52 * shape so every server-recognised knob (override host, block page, DNS
53 * resolvers, BISO controls, egress IPs, redirect target, …) is available
54 * without re-declaring the structure.
55 *
56 * Only a subset is meaningful for any given `action`; consult Cloudflare's
57 * Gateway rule docs for which settings apply per action.
58 */
59export type RuleSettings = NonNullable<
60 zeroTrust.CreateGatewayRuleRequest["ruleSettings"]
61>;
62
63export interface RuleProps {
64 /**
65 * Human-readable rule name. If omitted, a deterministic physical name is
66 * generated from the app/stage/logical-id. Used during adoption to locate
67 * an existing rule by name when no `ruleId` is cached.
68 */
69 name?: string;
70 /**
71 * Action Cloudflare Gateway takes when the rule matches. Stable across
72 * reconciles; changing it triggers a replacement so a rule never silently
73 * flips semantics under existing references.
74 */
75 action: RuleAction;
76 /**
77 * Protocol/layer the rule's `traffic` expression evaluates against.
78 * Cloudflare currently accepts a single filter per rule; the SDK still
79 * types it as an array to mirror the wire shape.
80 */
81 filters: ReadonlyArray<RuleFilter>;
82 /**
83 * Wirefilter expression used for traffic matching. Cloudflare auto-formats
84 * and sanitises this server-side — to avoid perpetual diffs, prefer the
85 * formatted form (e.g. `'any(dns.domains[*] == "internal.example")'`).
86 */
87 traffic?: string;
88 /**
89 * Wirefilter expression used for identity matching (group memberships,
90 * email, IdP attrs). Combined with `traffic` and `devicePosture` via
91 * logical AND.
92 */
93 identity?: string;
94 /**
95 * Wirefilter expression used for device-posture matching (WARP, MDM, etc).
96 */
97 devicePosture?: string;
98 /**
99 * Per-action settings applied when the rule matches. The most common
100 * private-app use case sets `overrideHost` to a `${tunnelId}.cfargotunnel.com`
101 * to point intercepted DNS at a Cloudflare Tunnel.
102 *
103 * @example
104 * ```ts
105 * ruleSettings: {
106 * overrideHost: `${tunnelId}.cfargotunnel.com`,
107 * }
108 * ```
109 */
110 ruleSettings?: RuleSettings;
111 /**
112 * Rule precedence — lower values evaluate first. When unset, Cloudflare
113 * picks one server-side; we then echo whatever it assigned back through
114 * the output attributes.
115 */
116 precedence?: number;
117 /**
118 * Whether the rule is enabled.
119 *
120 * @default true
121 */
122 enabled?: boolean;
123 /**
124 * Free-form description.
125 */
126 description?: string;
127 /**
128 * Adopt an existing rule with the same name (matched during the list-scan
129 * fallback when no `ruleId` is cached) instead of failing on conflict.
130 *
131 * @default false
132 */
133 adopt?: boolean;
134}
135
136export interface RuleAttributes {
137 /** Cloudflare-assigned rule UUID. */
138 ruleId: string;
139 /** Resolved display name (server-side). */
140 name: string;
141 /** Resolved action. */
142 action: RuleAction;
143 /** Resolved filters. */
144 filters: ReadonlyArray<RuleFilter>;
145 /** Server-assigned precedence (always populated on the response). */
146 precedence: number;
147 /** Account that owns this rule. */
148 accountId: string;
149 /** ISO8601 creation timestamp. */
150 createdAt: string | undefined;
151 /** ISO8601 last-update timestamp. */
152 updatedAt: string | undefined;
153}
154
155export type Rule = Resource<
156 "Cloudflare.Gateway.Rule",
157 RuleProps,
158 RuleAttributes,
159 never,
160 Providers
161>;
162
163/**
164 * A Cloudflare Zero Trust Gateway rule.
165 *
166 * Gateway rules sit on the WARP/Gateway data plane and run *before* Access:
167 * they decide whether to allow, block, override, isolate, or redirect a
168 * request based on wirefilter expressions over the request traffic,
169 * the authenticated identity, and the device posture. The most common
170 * companion to {@link Application} with a `private` destination is a
171 * `dns` rule with `action: "override"` that points an internal hostname at
172 * a Cloudflare Tunnel — without it, WARP intercepts the lookup but has
173 * nowhere to send the answer.
174 * ### DNS override for a private app
175 * **Example:** Resolve an internal hostname through a Cloudflare Tunnel
176 * ```typescript
177 * const adminDns = yield* Cloudflare.Gateway.Rule("AdminMicroagiDns", {
178 * name: "research-admin-microagi-dns-override",
179 * action: "override",
180 * filters: ["dns"],
181 * traffic: 'any(dns.domains[*] == "cluster-admin.microagi")',
182 * ruleSettings: {
183 * overrideHost: `${tunnel.tunnelId}.cfargotunnel.com`,
184 * },
185 * enabled: true,
186 * });
187 * ```
188 *
189 * ### Block a category
190 * **Example:** Block known phishing on HTTP
191 * ```typescript
192 * yield* Cloudflare.Gateway.Rule("BlockPhishing", {
193 * name: "block-phishing",
194 * action: "block",
195 * filters: ["http"],
196 * traffic: "any(http.request.uri.content_category[*] in {178})",
197 * });
198 * ```
199 *
200 * @resource
201 * @product Gateway
202 * @category Cloudflare One (Zero Trust)
203 */
204export const Rule = Resource<Rule>("Cloudflare.Gateway.Rule");
205
206// ---------------------------------------------------------------------------
207// Provider
208// ---------------------------------------------------------------------------
209
210export const RuleProvider = () =>
211 Provider.effect(
212 Rule,
213 Effect.gen(function* () {
214 const env = yield* CloudflareEnvironment;
215
216 const createRule = yield* zeroTrust.createGatewayRule;
217 const getRule = yield* zeroTrust.getGatewayRule;
218 const updateRule = yield* zeroTrust.updateGatewayRule;
219 const deleteRule = yield* zeroTrust.deleteGatewayRule;
220 const listRules = zeroTrust.listGatewayRules;
221
222 const resolveName = (id: string, name: string | undefined) =>
223 Effect.gen(function* () {
224 if (name) return name;
225 return yield* createPhysicalName({ id });
226 });
227
228 // Locate an existing rule by name when no ruleId is cached — used for
229 // adoption and as a recovery path after a create returns a conflict.
230 const findRuleByName = (accountId: string, name: string) =>
231 listRules.items({ accountId }).pipe(
232 Stream.runCollect,
233 Effect.map((chunk) =>
234 Array.from(chunk).find(
235 (r) => (r as { name?: string | null }).name === name,
236 ),
237 ),
238 Effect.map((found) =>
239 found === undefined
240 ? undefined
241 : narrowRule(found as Parameters<typeof narrowRule>[0]),
242 ),
243 );
244
245 const observeById = (accountId: string, ruleId: string) =>
246 Effect.gen(function* () {
247 const r = yield* getRule({ accountId, ruleId }).pipe(
248 // Distilled tags transport errors but not the live Cloudflare 404
249 // for a missing rule. Swallow generically so the reconcile flow
250 // falls through to recreate.
251 Effect.catch(() => Effect.succeed(undefined)),
252 );
253 if (r === undefined) return undefined;
254 return narrowRule(r as Parameters<typeof narrowRule>[0]);
255 });
256
257 return {
258 stables: ["ruleId", "action", "accountId"],
259
260 // Account collection (pattern b): Gateway rules are account-scoped
261 // (`/accounts/{id}/gateway/rules`). Exhaustively page the list op,
262 // narrow each rule, and hydrate the exact `read` Attributes shape.
263 // Rules missing a required field (id/action/filters/precedence) are
264 // dropped rather than surfaced as partial rows.
265 list: () =>
266 Effect.gen(function* () {
267 const { accountId } = yield* env;
268 return yield* listRules.pages({ accountId }).pipe(
269 Stream.runCollect,
270 Effect.map((chunk) =>
271 Array.from(chunk).flatMap((page) =>
272 (page.result ?? []).flatMap((raw) => {
273 const r = narrowRule(
274 raw as Parameters<typeof narrowRule>[0],
275 );
276 if (
277 !r.id ||
278 !r.action ||
279 !r.filters ||
280 r.precedence === undefined
281 ) {
282 return [];
283 }
284 return [
285 {
286 ruleId: r.id,
287 name: r.name ?? r.id,
288 action: r.action,
289 filters: r.filters,
290 precedence: r.precedence,
291 accountId,
292 createdAt: r.createdAt,
293 updatedAt: r.updatedAt,
294 } satisfies RuleAttributes,
295 ];
296 }),
297 ),
298 ),
299 );
300 }),
301
302 diff: Effect.fn(function* ({ olds = {}, news }) {
303 if ((olds as RuleProps).action !== undefined) {
304 if ((olds as RuleProps).action !== (news as RuleProps).action) {
305 return { action: "replace" } as const;
306 }
307 }
308 }),
309
310 reconcile: Effect.fn(function* ({ id, news, output }) {
311 const { accountId } = yield* env;
312 const resolvedName = yield* resolveName(id, news.name);
313 const body = buildMutableBody(news, resolvedName);
314
315 // 1. Observe
316 let observed: ObservedRule | undefined;
317 if (output?.ruleId) {
318 observed = yield* observeById(accountId, output.ruleId);
319 }
320 if (!observed) {
321 // Adoption / recovery path — look up by name. Cheap relative to
322 // the create call we'd otherwise blow up on a duplicate name.
323 observed = yield* findRuleByName(accountId, resolvedName);
324 }
325
326 // 2. Ensure
327 if (!observed) {
328 const created = yield* createRule({
329 accountId,
330 name: resolvedName,
331 action: body.action,
332 filters: Array.from(body.filters),
333 traffic: body.traffic,
334 identity: body.identity,
335 devicePosture: body.devicePosture,
336 ruleSettings: body.ruleSettings,
337 precedence: body.precedence,
338 enabled: body.enabled,
339 description: body.description,
340 }).pipe(
341 // Distilled does not tag Conflict — fall back to a name lookup
342 // before re-failing so a racing create still converges.
343 Effect.catch((err) =>
344 Effect.gen(function* () {
345 const existing = yield* findRuleByName(
346 accountId,
347 resolvedName,
348 );
349 if (existing) return existing;
350 return yield* Effect.fail(err);
351 }),
352 ),
353 );
354 observed = narrowRule(created as Parameters<typeof narrowRule>[0]);
355 }
356
357 // 3. Sync
358 if (!observed.id) {
359 return yield* Effect.fail(
360 new Error("Cloudflare did not return a rule id for Gateway rule"),
361 );
362 }
363 if (!bodyEqualsObserved(body, observed)) {
364 const updated = yield* updateRule({
365 accountId,
366 ruleId: observed.id,
367 name: resolvedName,
368 action: body.action,
369 filters: Array.from(body.filters),
370 traffic: body.traffic,
371 identity: body.identity,
372 devicePosture: body.devicePosture,
373 ruleSettings: body.ruleSettings,
374 precedence: body.precedence,
375 enabled: body.enabled,
376 description: body.description,
377 });
378 observed = narrowRule(updated as Parameters<typeof narrowRule>[0]);
379 }
380
381 // 4. Return
382 if (
383 !observed.id ||
384 !observed.action ||
385 !observed.filters ||
386 observed.precedence === undefined
387 ) {
388 return yield* Effect.fail(
389 new Error(
390 "Cloudflare returned a Gateway rule without id/action/filters/precedence",
391 ),
392 );
393 }
394 return {
395 ruleId: observed.id,
396 name: observed.name ?? resolvedName,
397 action: observed.action,
398 filters: observed.filters,
399 precedence: observed.precedence,
400 accountId,
401 createdAt: observed.createdAt,
402 updatedAt: observed.updatedAt,
403 } satisfies RuleAttributes;
404 }),
405
406 delete: Effect.fn(function* ({ output }) {
407 yield* deleteRule({
408 accountId: output.accountId,
409 ruleId: output.ruleId,
410 }).pipe(Effect.catch(() => Effect.void));
411 }),
412
413 read: Effect.fn(function* ({ output }) {
414 if (!output?.ruleId) return undefined;
415 const observed = yield* observeById(output.accountId, output.ruleId);
416 if (
417 !observed?.id ||
418 !observed.action ||
419 !observed.filters ||
420 observed.precedence === undefined
421 ) {
422 return undefined;
423 }
424 return {
425 ruleId: observed.id,
426 name: observed.name ?? output.name,
427 action: observed.action,
428 filters: observed.filters,
429 precedence: observed.precedence,
430 accountId: output.accountId,
431 createdAt: observed.createdAt ?? output.createdAt,
432 updatedAt: observed.updatedAt ?? output.updatedAt,
433 } satisfies RuleAttributes;
434 }),
435 };
436 }),
437 );
438
439interface ObservedRule {
440 readonly id?: string;
441 readonly name?: string;
442 readonly action?: RuleAction;
443 readonly filters?: ReadonlyArray<RuleFilter>;
444 readonly traffic?: string;
445 readonly identity?: string;
446 readonly devicePosture?: string;
447 readonly precedence?: number;
448 readonly enabled?: boolean;
449 readonly description?: string;
450 readonly ruleSettings?: Record<string, unknown>;
451 readonly createdAt?: string;
452 readonly updatedAt?: string;
453}
454
455const undef = <T>(v: T | null | undefined): T | undefined =>
456 v == null ? undefined : v;
457
458const undefArr = <T>(
459 v: ReadonlyArray<T | null> | null | undefined,
460): ReadonlyArray<T> | undefined =>
461 v == null ? undefined : (v.filter((x) => x != null) as ReadonlyArray<T>);
462
463const narrowRule = (raw: {
464 id?: string | null;
465 name?: string | null;
466 action?: RuleAction | null | string;
467 filters?: ReadonlyArray<RuleFilter | null> | null;
468 traffic?: string | null;
469 identity?: string | null;
470 devicePosture?: string | null;
471 precedence?: number | null;
472 enabled?: boolean | null;
473 description?: string | null;
474 ruleSettings?: Record<string, unknown> | null;
475 createdAt?: string | null;
476 updatedAt?: string | null;
477}): ObservedRule => ({
478 id: undef(raw.id),
479 name: undef(raw.name),
480 action: raw.action == null ? undefined : (raw.action as RuleAction),
481 filters: undefArr(raw.filters ?? undefined),
482 traffic: undef(raw.traffic),
483 identity: undef(raw.identity),
484 devicePosture: undef(raw.devicePosture),
485 precedence: undef(raw.precedence),
486 enabled: undef(raw.enabled),
487 description: undef(raw.description),
488 ruleSettings: undef(raw.ruleSettings),
489 createdAt: undef(raw.createdAt),
490 updatedAt: undef(raw.updatedAt),
491});
492
493// ---------------------------------------------------------------------------
494// Body construction
495// ---------------------------------------------------------------------------
496
497interface RuleMutableBody {
498 name: string;
499 action: RuleAction;
500 filters: ReadonlyArray<RuleFilter>;
501 traffic?: string;
502 identity?: string;
503 devicePosture?: string;
504 ruleSettings?: RuleSettings;
505 precedence?: number;
506 enabled?: boolean;
507 description?: string;
508}
509
510const buildMutableBody = (
511 news: RuleProps,
512 resolvedName: string,
513): RuleMutableBody => {
514 const body: RuleMutableBody = {
515 name: resolvedName,
516 action: news.action,
517 filters: news.filters,
518 };
519 if (news.traffic !== undefined) body.traffic = news.traffic;
520 if (news.identity !== undefined) body.identity = news.identity;
521 if (news.devicePosture !== undefined) body.devicePosture = news.devicePosture;
522 if (news.ruleSettings !== undefined) body.ruleSettings = news.ruleSettings;
523 if (news.precedence !== undefined) body.precedence = news.precedence;
524 if (news.enabled !== undefined) body.enabled = news.enabled;
525 if (news.description !== undefined) body.description = news.description;
526 return body;
527};
528
529// ---------------------------------------------------------------------------
530// Drift detection
531// ---------------------------------------------------------------------------
532
533const bodyEqualsObserved = (
534 desired: RuleMutableBody,
535 observed: ObservedRule,
536): boolean => {
537 if (desired.name !== observed.name) return false;
538 if (desired.action !== observed.action) return false;
539 if (!arrayEquals(desired.filters, observed.filters)) return false;
540 if (desired.traffic !== undefined && desired.traffic !== observed.traffic) {
541 return false;
542 }
543 if (
544 desired.identity !== undefined &&
545 desired.identity !== observed.identity
546 ) {
547 return false;
548 }
549 if (
550 desired.devicePosture !== undefined &&
551 desired.devicePosture !== observed.devicePosture
552 ) {
553 return false;
554 }
555 if (
556 desired.precedence !== undefined &&
557 desired.precedence !== observed.precedence
558 ) {
559 return false;
560 }
561 if (desired.enabled !== undefined && desired.enabled !== observed.enabled) {
562 return false;
563 }
564 if (
565 desired.description !== undefined &&
566 desired.description !== observed.description
567 ) {
568 return false;
569 }
570 // ruleSettings is a deeply-nested object whose server echo may include
571 // extra `null` fields. Stringify-compare only when the caller set them.
572 if (desired.ruleSettings !== undefined) {
573 if (
574 JSON.stringify(desired.ruleSettings) !==
575 JSON.stringify(observed.ruleSettings ?? {})
576 ) {
577 return false;
578 }
579 }
580 return true;
581};