1import * as firewall from "@distilled.cloud/cloudflare/firewall";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13const FirewallAccessRuleTypeId = "Cloudflare.Firewall.AccessRule" as const;
14type FirewallAccessRuleTypeId = typeof FirewallAccessRuleTypeId;
15
16/**
17 * The action an IP Access rule applies to a matched request.
18 *
19 * Note: `block` for `country`/`asn` targets requires an Enterprise plan;
20 * `challenge` and `managed_challenge` work on all plans.
21 */
22export type AccessRuleMode =
23 | "block"
24 | "challenge"
25 | "whitelist"
26 | "js_challenge"
27 | "managed_challenge";
28
29/**
30 * What a rule's configuration matches on: a single IPv4 (`ip`), a single
31 * IPv6 (`ip6`), a CIDR range (`ip_range`), an AS number (`asn`), or a
32 * two-letter ISO-3166-1 alpha-2 country code (`country`).
33 */
34export type AccessRuleTarget = "ip" | "ip6" | "ip_range" | "asn" | "country";
35
36/**
37 * The match configuration of an IP Access rule. Immutable — the Cloudflare
38 * API only allows `mode`/`notes` to be patched, so changing the
39 * configuration replaces the rule.
40 */
41export interface AccessRuleConfiguration {
42 /**
43 * What the rule matches on.
44 */
45 target: AccessRuleTarget;
46 /**
47 * The value to match — e.g. `198.51.100.4` (`ip`), `2001:db8::/64`
48 * (`ip_range`), `AS13335` (`asn`), or `US` (`country`).
49 */
50 value: string;
51}
52
53export interface AccessRuleProps {
54 /**
55 * Zone the rule applies to. When omitted, the rule is created at the
56 * account level and applies to every zone in the account.
57 *
58 * Stable — moving a rule between scopes triggers a replacement.
59 */
60 zoneId?: string;
61 /**
62 * The rule's match configuration (target + value).
63 *
64 * Immutable — the API has no way to change a rule's configuration
65 * (only `mode`/`notes` are patchable), so changing it triggers a
66 * replacement.
67 */
68 configuration: AccessRuleConfiguration;
69 /**
70 * The action to apply to a matched request. Mutable — patched in place.
71 */
72 mode: AccessRuleMode;
73 /**
74 * An informative summary of the rule, typically used as a reminder or
75 * explanation. Mutable — patched in place.
76 */
77 notes?: string;
78}
79
80export interface AccessRuleAttributes {
81 /** Cloudflare-assigned identifier of the IP Access rule. */
82 ruleId: string;
83 /** Zone the rule belongs to, or `undefined` for account-scoped rules. */
84 zoneId: string | undefined;
85 /** Account the rule was created under. */
86 accountId: string;
87 /** The rule's match configuration. */
88 configuration: AccessRuleConfiguration;
89 /** The action applied to matched requests. */
90 mode: AccessRuleMode;
91 /** The actions available for this rule. */
92 allowedModes: AccessRuleMode[];
93 /** The rule's informative summary, if set. */
94 notes: string | undefined;
95 /** ISO8601 creation timestamp. */
96 createdOn: string | undefined;
97 /** ISO8601 last-modified timestamp. */
98 modifiedOn: string | undefined;
99}
100
101export type AccessRule = Resource<
102 FirewallAccessRuleTypeId,
103 AccessRuleProps,
104 AccessRuleAttributes,
105 never,
106 Providers
107>;
108
109/**
110 * A Cloudflare IP Access rule — block, challenge, or whitelist requests by
111 * IP, CIDR range, ASN, or country, either on a single zone or across the
112 * whole account.
113 *
114 * A rule's identity is its `configuration` (target + value) within a scope:
115 * Cloudflare rejects a second rule for the same configuration with a
116 * duplicate error, and the configuration cannot be changed after creation —
117 * only `mode` and `notes` are mutable. Changing `configuration` or moving
118 * the rule between zone and account scope triggers a replacement.
119 *
120 * Safety: IP Access rules carry no ownership markers. When there is no
121 * prior state, `read` scans the scope for an existing rule with the same
122 * configuration and reports it as `Unowned`, so the engine refuses to take
123 * it over unless `--adopt` (or `adopt(true)`) is set.
124 * ### Blocking an IP
125 * **Example:** Block a single IPv4 address on a zone
126 * ```typescript
127 * yield* Cloudflare.Firewall.AccessRule("BlockBadActor", {
128 * zoneId: zone.zoneId,
129 * configuration: { target: "ip", value: "198.51.100.4" },
130 * mode: "block",
131 * notes: "repeated credential stuffing",
132 * });
133 * ```
134 *
135 * **Example:** Block a CIDR range account-wide
136 * ```typescript
137 * // No zoneId — the rule applies to every zone in the account.
138 * yield* Cloudflare.Firewall.AccessRule("BlockScannerRange", {
139 * configuration: { target: "ip_range", value: "203.0.113.0/24" },
140 * mode: "block",
141 * });
142 * ```
143 *
144 * ### Challenging traffic
145 * **Example:** Managed challenge for a country
146 * ```typescript
147 * // `block` for country targets is Enterprise-only; challenges work on
148 * // all plans.
149 * yield* Cloudflare.Firewall.AccessRule("ChallengeCountry", {
150 * zoneId: zone.zoneId,
151 * configuration: { target: "country", value: "KP" },
152 * mode: "managed_challenge",
153 * });
154 * ```
155 *
156 * ### Whitelisting
157 * **Example:** Always allow an office IP
158 * ```typescript
159 * yield* Cloudflare.Firewall.AccessRule("AllowOffice", {
160 * zoneId: zone.zoneId,
161 * configuration: { target: "ip", value: "192.0.2.10" },
162 * mode: "whitelist",
163 * notes: "office egress IP",
164 * });
165 * ```
166 *
167 * @see https://developers.cloudflare.com/waf/tools/ip-access-rules/
168 *
169 * @resource
170 * @product Firewall
171 * @category Application Security
172 */
173export const AccessRule = Resource<AccessRule>(FirewallAccessRuleTypeId);
174
175/**
176 * Returns true if the given value is a AccessRule resource.
177 */
178export const isAccessRule = (value: unknown): value is AccessRule =>
179 Predicate.hasProperty(value, "Type") &&
180 value.Type === FirewallAccessRuleTypeId;
181
182export const AccessRuleProvider = () =>
183 Provider.succeed(AccessRule, {
184 stables: ["ruleId", "zoneId", "accountId", "allowedModes", "createdOn"],
185
186 diff: Effect.fn(function* ({ olds = {}, news }) {
187 const o = olds as AccessRuleProps;
188 const n = news as AccessRuleProps;
189 // No prior props to compare against — let the engine decide.
190 if (o.configuration === undefined) return undefined;
191 // The API can only patch mode/notes — configuration is immutable.
192 if (
193 o.configuration.target !== n.configuration.target ||
194 o.configuration.value !== n.configuration.value
195 ) {
196 return { action: "replace" } as const;
197 }
198 // Scope change (zone <-> account, or a different zone) replaces.
199 if ((o.zoneId === undefined) !== (n.zoneId === undefined)) {
200 return { action: "replace" } as const;
201 }
202 // zoneId is Input<string>; compare only once both are concrete.
203 if (
204 typeof o.zoneId === "string" &&
205 typeof n.zoneId === "string" &&
206 o.zoneId !== n.zoneId
207 ) {
208 return { action: "replace" } as const;
209 }
210 return undefined;
211 }),
212
213 read: Effect.fn(function* ({ output, olds }) {
214 const { accountId } = yield* yield* CloudflareEnvironment;
215 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
216 const acct = output?.accountId ?? accountId;
217
218 // Owned path: refresh by our persisted rule id.
219 if (output?.ruleId) {
220 const observed = yield* getRule(zoneId, acct, output.ruleId);
221 if (observed) return toAttributes(observed, zoneId, acct);
222 }
223
224 // Adoption path: a rule with this configuration may already exist in
225 // the scope. Access rules carry no ownership markers, so we cannot
226 // prove we created it — brand it `Unowned` so the engine refuses to
227 // take over unless `adopt` is set.
228 const configuration = output?.configuration ?? olds?.configuration;
229 if (configuration) {
230 const observed = yield* findByConfiguration(
231 zoneId,
232 acct,
233 configuration,
234 );
235 if (observed) return Unowned(toAttributes(observed, zoneId, acct));
236 }
237 return undefined;
238 }),
239
240 reconcile: Effect.fn(function* ({ news, output }) {
241 const { accountId } = yield* yield* CloudflareEnvironment;
242 // Inputs have been resolved to concrete strings by Plan.
243 const zoneId = news.zoneId as string | undefined;
244
245 // 1. Observe — the rule id cached on `output` is a hint, not a
246 // guarantee: a missing rule falls through to the configuration
247 // scan and then to create.
248 let observed = output?.ruleId
249 ? yield* getRule(zoneId, accountId, output.ruleId)
250 : undefined;
251
252 // 2. Fall back to scanning the scope for a configuration match.
253 // Ownership has already been verified upstream — `read` reports
254 // existing rules as `Unowned` and the engine gates takeover
255 // behind the adopt policy before reconcile ever runs.
256 if (!observed) {
257 observed = yield* findByConfiguration(
258 zoneId,
259 accountId,
260 news.configuration,
261 );
262 }
263
264 // 3. Ensure — create when missing. A concurrent create surfaces as
265 // `DuplicateAccessRule` (Cloudflare code 10009): converge by
266 // re-scanning for the rule that won the race.
267 if (!observed) {
268 observed = yield* createRule(zoneId, accountId, news).pipe(
269 Effect.catchTag("DuplicateAccessRule", (error) =>
270 findByConfiguration(zoneId, accountId, news.configuration).pipe(
271 Effect.flatMap((existing) =>
272 existing ? Effect.succeed(existing) : Effect.fail(error),
273 ),
274 ),
275 ),
276 );
277 }
278
279 // 4. Sync — diff observed mode/notes against desired; skip the
280 // patch call entirely on a no-op.
281 const dirty =
282 observed.mode !== news.mode ||
283 (news.notes !== undefined && (observed.notes ?? "") !== news.notes);
284 if (dirty) {
285 observed = yield* patchRule(zoneId, accountId, observed.id, news);
286 }
287
288 return toAttributes(observed, zoneId, accountId);
289 }),
290
291 // IP Access rules are hybrid-scoped: account-level rules (zoneId
292 // undefined) plus per-zone rules. Enumerate both — the account
293 // collection, then fan out across every zone — and tag each item with
294 // its scope so the result matches the exact `read` Attributes shape.
295 list: Effect.fn(function* () {
296 const { accountId } = yield* yield* CloudflareEnvironment;
297
298 const accountRules = yield* firewall.listAccessRulesForAccount
299 .pages({ accountId })
300 .pipe(
301 Stream.runCollect,
302 Effect.map((chunk) =>
303 Array.from(chunk).flatMap((page) =>
304 (page.result ?? []).map((rule) =>
305 toAttributes(rule, undefined, accountId),
306 ),
307 ),
308 ),
309 // No permission to read account-level rules — skip them.
310 Effect.catchTag("Forbidden", () => Effect.succeed([])),
311 );
312
313 const zones = yield* listAllZones(accountId);
314 const zoneRuleGroups = yield* Effect.forEach(
315 zones,
316 (zone) =>
317 firewall.listAccessRulesForZone.pages({ zoneId: zone.id }).pipe(
318 Stream.runCollect,
319 Effect.map((chunk) =>
320 Array.from(chunk).flatMap((page) =>
321 (page.result ?? []).map((rule) =>
322 toAttributes(rule, zone.id, accountId),
323 ),
324 ),
325 ),
326 // Plan-gated / partial zones reject the route; skip them.
327 Effect.catchTag("Forbidden", () => Effect.succeed([])),
328 ),
329 { concurrency: 10 },
330 );
331
332 return [...accountRules, ...zoneRuleGroups.flat()];
333 }),
334
335 delete: Effect.fn(function* ({ output }) {
336 // Cloudflare answers DELETE for an already-gone rule with HTTP 200
337 // and `result: null` (not an error envelope), which the typed
338 // response decode rejects — observe first and treat missing as done.
339 const observed = yield* getRule(
340 output.zoneId,
341 output.accountId,
342 output.ruleId,
343 );
344 if (!observed) return;
345 yield* deleteRule(output.zoneId, output.accountId, output.ruleId).pipe(
346 Effect.catchTag("AccessRuleNotFound", () => Effect.void),
347 );
348 }),
349 });
350
351// ---------------------------------------------------------------------------
352// Scoped API helpers (zone-level when zoneId is set, account-level otherwise)
353// ---------------------------------------------------------------------------
354
355type ObservedRule = firewall.GetAccessRuleResponse;
356
357/**
358 * Read a rule by id, mapping "gone" (`AccessRuleNotFound`, Cloudflare error
359 * code 10001 `firewallaccessrules.api.not_found`) to `undefined`.
360 */
361const getRule = (
362 zoneId: string | undefined,
363 accountId: string,
364 ruleId: string,
365) =>
366 (zoneId !== undefined
367 ? firewall.getAccessRuleForZone({ zoneId, ruleId })
368 : firewall.getAccessRuleForAccount({ accountId, ruleId })
369 ).pipe(
370 Effect.map((rule): ObservedRule | undefined => rule),
371 Effect.catchTag("AccessRuleNotFound", () => Effect.succeed(undefined)),
372 );
373
374/**
375 * Find a rule by exact configuration (target + value) within the scope.
376 * The configuration is a rule's identity — Cloudflare rejects duplicates —
377 * so at most one rule can match.
378 */
379const findByConfiguration = (
380 zoneId: string | undefined,
381 accountId: string,
382 configuration: AccessRuleConfiguration,
383) =>
384 (zoneId !== undefined
385 ? firewall.listAccessRulesForZone.items({ zoneId })
386 : firewall.listAccessRulesForAccount.items({ accountId })
387 ).pipe(
388 Stream.runCollect,
389 Effect.map((chunk) =>
390 Array.from(chunk).find(
391 (rule): rule is ObservedRule =>
392 rule.configuration.target === configuration.target &&
393 rule.configuration.value === configuration.value,
394 ),
395 ),
396 );
397
398const createRule = (
399 zoneId: string | undefined,
400 accountId: string,
401 news: AccessRuleProps,
402) =>
403 zoneId !== undefined
404 ? firewall.createAccessRuleForZone({
405 zoneId,
406 configuration: news.configuration,
407 mode: news.mode,
408 notes: news.notes,
409 })
410 : firewall.createAccessRuleForAccount({
411 accountId,
412 configuration: news.configuration,
413 mode: news.mode,
414 notes: news.notes,
415 });
416
417const patchRule = (
418 zoneId: string | undefined,
419 accountId: string,
420 ruleId: string,
421 news: AccessRuleProps,
422) =>
423 zoneId !== undefined
424 ? firewall.patchAccessRuleForZone({
425 zoneId,
426 ruleId,
427 configuration: news.configuration,
428 mode: news.mode,
429 notes: news.notes,
430 })
431 : firewall.patchAccessRuleForAccount({
432 accountId,
433 ruleId,
434 configuration: news.configuration,
435 mode: news.mode,
436 notes: news.notes,
437 });
438
439const deleteRule = (
440 zoneId: string | undefined,
441 accountId: string,
442 ruleId: string,
443) =>
444 zoneId !== undefined
445 ? firewall.deleteAccessRuleForZone({ zoneId, ruleId })
446 : firewall.deleteAccessRuleForAccount({ accountId, ruleId });
447
448const toAttributes = (
449 rule: ObservedRule,
450 zoneId: string | undefined,
451 accountId: string,
452): AccessRuleAttributes => ({
453 ruleId: rule.id,
454 zoneId,
455 accountId,
456 configuration: {
457 // Distilled types each union variant's target/value as optional —
458 // Cloudflare always echoes both for a persisted rule.
459 target: (rule.configuration.target ?? "ip") as AccessRuleTarget,
460 value: rule.configuration.value ?? "",
461 },
462 mode: rule.mode as AccessRuleMode,
463 allowedModes: [...rule.allowedModes] as AccessRuleMode[],
464 notes: rule.notes ?? undefined,
465 createdOn: rule.createdOn ?? undefined,
466 modifiedOn: rule.modifiedOn ?? undefined,
467});