diff --git a/assets/demos/gcp-sync.gif b/assets/demos/gcp-sync.gif new file mode 100644 index 0000000..94ace7c Binary files /dev/null and b/assets/demos/gcp-sync.gif differ diff --git a/demos/fixtures/gcp-demo-local.doorman.json b/demos/fixtures/gcp-demo-local.doorman.json new file mode 100644 index 0000000..d4081fc --- /dev/null +++ b/demos/fixtures/gcp-demo-local.doorman.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://doorman.griffen.codes/schema.json", + "version": "2.0", + "provider": "gcp", + "providers": { + "gcp": { "projectId": "demo-project", "policyName": "doorman-demo-policy" } + }, + "rules": [ + { + "id": "1000", + "name": "Block Bad Bots", + "description": "Block Bad Bots", + "enabled": true, + "conditions": [{ "field": "user_agent", "operator": "contains", "value": "bot" }], + "action": { "type": "deny" }, + "priority": 1000 + }, + { + "name": "Block Admin Access", + "enabled": true, + "conditions": [{ "field": "path", "operator": "starts_with", "value": "/admin" }], + "action": { "type": "deny" } + } + ], + "ips": [] +} diff --git a/demos/fixtures/gcp-demo-remote.json b/demos/fixtures/gcp-demo-remote.json new file mode 100644 index 0000000..f8d4a6b --- /dev/null +++ b/demos/fixtures/gcp-demo-remote.json @@ -0,0 +1,17 @@ +{ + "projectId": "demo-project", + "policyName": "doorman-demo-policy", + "policyId": "1234567890", + "rules": [ + { + "priority": 1000, + "description": "Block Bad Bots", + "match": { + "expr": { + "expression": "(has(request.headers['user-agent']) && request.headers['user-agent'].contains('bot'))" + } + }, + "action": "deny(403)" + } + ] +} diff --git a/demos/tapes/gcp-sync.tape b/demos/tapes/gcp-sync.tape new file mode 100644 index 0000000..15e8e1c --- /dev/null +++ b/demos/tapes/gcp-sync.tape @@ -0,0 +1,35 @@ +Source demos/tapes/_setup.tape +Set Width 1900 +Set Height 700 + +Output assets/demos/gcp-sync.gif + +# Unlike the other providers' tapes, this one can't be fully offline: GCP's +# GoogleAuth still mints a real OAuth2 access token against Google's real +# infrastructure (using the recorder's own `gcloud auth application-default +# login` session) even though the actual securityPolicies request/response +# traffic below is routed to the local mock server. Re-recording this tape +# requires live GCP credentials — see skills/doorman/references/gcp.md's +# "Manual End-to-End Verification" section. +Hide +Type "pkill -f 'cloudarmor-mock-server.mjs --port 4867' 2>/dev/null; true" Enter +Type "MOCK_SERVER=$(pwd)/demos/cloudarmor-mock-server.mjs" Enter +Type "MOCK_FIXTURE=$(pwd)/demos/fixtures/gcp-demo-remote.json" Enter +Type "LOCAL_SEED=$(pwd)/demos/fixtures/gcp-demo-local.doorman.json" Enter +Type "REPO_BIN=$(pwd)/bin/run" Enter +Type "node $MOCK_SERVER --port 4867 --fixture $MOCK_FIXTURE > /dev/null 2>&1 & MOCKPID=$!" Enter +Type "sleep 1" Enter +Type "export DOORMAN_GCP_API_BASE_URL=http://127.0.0.1:4867" Enter +Type "mkdir -p demos/.fixtures/gcp-sync && cd demos/.fixtures/gcp-sync && cp $LOCAL_SEED .doorman.json && clear" Enter +Type "alias doorman=$REPO_BIN" Enter +Type "clear" Enter +Show + +Type "doorman sync" Enter +Sleep 3000ms +Enter +Sleep 3000ms + +Hide +Type "kill $MOCKPID 2>/dev/null" Enter +Show diff --git a/skills/doorman/SKILL.md b/skills/doorman/SKILL.md index c57d4e9..70c3d34 100644 --- a/skills/doorman/SKILL.md +++ b/skills/doorman/SKILL.md @@ -76,7 +76,7 @@ FASTLY_WORKSPACE_ID=workspace_xxx } ``` -For Cloudflare or Fastly, add `provider` and `providers` fields instead of `projectId`/`teamId` — and note this switches the *rule* shape too, not just the top-level fields. See [Rule Shape](#rule-shape-minimal) below. +For Cloudflare or Fastly, add `provider` and `providers` fields instead of `projectId`/`teamId` — and note this switches the _rule_ shape too, not just the top-level fields. See [Rule Shape](#rule-shape-minimal) below. ## Core Workflow @@ -137,14 +137,14 @@ Two different rule shapes, picked by whether the config has `provider`/`provider Load the relevant reference file for detailed documentation: -| Task | Reference | -| ------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| Writing rules — full field docs, operators, actions, IP blocking, patterns | [references/rules.md](references/rules.md) | +| Task | Reference | +| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| Writing rules — full field docs, operators, actions, IP blocking, patterns | [references/rules.md](references/rules.md) | | Cloudflare-specific setup, Lists API, managed rule groups, expression translation, limitations | [references/cloudflare.md](references/cloudflare.md) | -| Fastly-specific setup, condition/action mapping, rate-limit signal requirement, limitations | [references/fastly.md](references/fastly.md) | -| GCP Cloud Armor setup, CEL translation, priority model, manual e2e verification runbook | [references/gcp.md](references/gcp.md) | -| Available templates and what they protect against | [references/templates.md](references/templates.md) | -| CI/CD integration, automation, export formats, validation in pipelines | [references/cicd.md](references/cicd.md) | +| Fastly-specific setup, condition/action mapping, rate-limit signal requirement, limitations | [references/fastly.md](references/fastly.md) | +| GCP Cloud Armor setup, CEL translation, priority model, manual e2e verification runbook | [references/gcp.md](references/gcp.md) | +| Available templates and what they protect against | [references/templates.md](references/templates.md) | +| CI/CD integration, automation, export formats, validation in pipelines | [references/cicd.md](references/cicd.md) | ## Principles diff --git a/skills/doorman/references/cloudflare.md b/skills/doorman/references/cloudflare.md index c2d9a9b..9498c6f 100644 --- a/skills/doorman/references/cloudflare.md +++ b/skills/doorman/references/cloudflare.md @@ -60,23 +60,23 @@ Doorman translates its unified rule format (`conditions`/`enabled`/`action: {typ Cloudflare supports all 15 unified condition fields: -| Doorman Field | Cloudflare Field | Notes | -| -------------- | ------------------------------ | ------------------------------------------------------------------------------------------ | -| `ip` | `ip.src` | | -| `country` | `ip.geoip.country` | | -| `region` | `ip.geoip.subdivision_1` | | -| `city` | `ip.geoip.city` | | -| `asn` | `ip.geoip.asnum` | | -| `path` | `http.request.uri.path` | | -| `host` | `http.host` | | -| `method` | `http.request.method` | | -| `header` | `http.request.headers["key"]` | Requires `key` (the header name) | +| Doorman Field | Cloudflare Field | Notes | +| ------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `ip` | `ip.src` | | +| `country` | `ip.geoip.country` | | +| `region` | `ip.geoip.subdivision_1` | | +| `city` | `ip.geoip.city` | | +| `asn` | `ip.geoip.asnum` | | +| `path` | `http.request.uri.path` | | +| `host` | `http.host` | | +| `method` | `http.request.method` | | +| `header` | `http.request.headers["key"]` | Requires `key` (the header name) | | `query` | `http.request.uri.query` | ⚠️ `key` is currently ignored — matches the whole query string, not one parameter ([doorman#263](https://github.com/gfargo/doorman/issues/263)) | -| `cookie` | `http.cookie["key"]` | Requires `key` (the cookie name) | -| `user_agent` | `http.user_agent` | | -| `referer` | `http.referer` | | -| `scheme` | `ssl` (boolean) | | -| `port` | `cf.edge.server_port` | | +| `cookie` | `http.cookie["key"]` | Requires `key` (the cookie name) | +| `user_agent` | `http.user_agent` | | +| `referer` | `http.referer` | | +| `scheme` | `ssl` (boolean) | | +| `port` | `cf.edge.server_port` | | ### Operator Mapping @@ -88,16 +88,16 @@ Cloudflare supports all 15 unified operators exactly, no approximation — `not_ ### Action Mapping -| Doorman Action | Cloudflare Action | Notes | -| --------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `log` | `log` | | -| `deny` | `block` | | -| `challenge` | `managed_challenge` | Cloudflare's `challenge`/`managed_challenge`/`js_challenge` all fold back to unified `challenge` on `download` — a one-way narrowing, not a failure | -| `bypass` | `skip` | | -| `allow` | `allow` | | -| `block` | `block` | Same target as `deny` | -| `rate_limit` | `block` + `ratelimit` config | ⚠️ If `action.rateLimit` is omitted, this silently becomes a plain unconditional `block` rule with no rate-limit effect — no error raised locally | -| `redirect` | `redirect` + `from_value` params | ⚠️ If `action.redirect` is omitted, this silently becomes a rule with no redirect target — no error raised locally | +| Doorman Action | Cloudflare Action | Notes | +| -------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `log` | `log` | | +| `deny` | `block` | | +| `challenge` | `managed_challenge` | Cloudflare's `challenge`/`managed_challenge`/`js_challenge` all fold back to unified `challenge` on `download` — a one-way narrowing, not a failure | +| `bypass` | `skip` | | +| `allow` | `allow` | | +| `block` | `block` | Same target as `deny` | +| `rate_limit` | `block` + `ratelimit` config | ⚠️ If `action.rateLimit` is omitted, this silently becomes a plain unconditional `block` rule with no rate-limit effect — no error raised locally | +| `redirect` | `redirect` + `from_value` params | ⚠️ If `action.redirect` is omitted, this silently becomes a rule with no redirect target — no error raised locally | ## Lists API (Bulk IP Management) @@ -112,16 +112,16 @@ Without `CLOUDFLARE_ACCOUNT_ID`, IP blocking falls back to individual WAF rules ## Limitations & Differences -`environment`, `ja3_digest`, `ja4_digest` are **legacy-format-only concepts** — they exist on Vercel's native rule type but have no unified-format equivalent at all, so they're not reachable through a `provider`/`providers`-tagged config for *any* provider, not just Cloudflare. Every other unified field maps to something on Cloudflare — see Field Mapping above. +`environment`, `ja3_digest`, `ja4_digest` are **legacy-format-only concepts** — they exist on Vercel's native rule type but have no unified-format equivalent at all, so they're not reachable through a `provider`/`providers`-tagged config for _any_ provider, not just Cloudflare. Every other unified field maps to something on Cloudflare — see Field Mapping above. -| Feature | Cloudflare | Notes | -| -------------------------- | ------------------------------ | ------------------------------------------------- | -| `matches` (regex) | Enterprise-plan-restricted on some plans | Doorman emits it regardless; Cloudflare's API may reject it — see Operator Mapping above | -| IP Lists (bulk) | Lists API | Needs `accountId`; without it, falls back to individual `ip.src` rules | -| Max custom rules | 5-125 (plan dependent) | Free: 5, Pro: 20, Business: 100, Enterprise: 125+ | -| Rate limit | Separate phase | `ratelimit` config attached to a `block` action, not a distinct rule type | -| Rule order | Sequential (first match wins) | `priority` is fully honoured — unlike Vercel, which can't reposition rules that already exist remotely | -| Managed rule groups | Supported via `managedRules` | See [Managed Rule Groups](#managed-rule-groups) below — Cloudflare-only among doorman's providers today | +| Feature | Cloudflare | Notes | +| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `matches` (regex) | Enterprise-plan-restricted on some plans | Doorman emits it regardless; Cloudflare's API may reject it — see Operator Mapping above | +| IP Lists (bulk) | Lists API | Needs `accountId`; without it, falls back to individual `ip.src` rules | +| Max custom rules | 5-125 (plan dependent) | Free: 5, Pro: 20, Business: 100, Enterprise: 125+ | +| Rate limit | Separate phase | `ratelimit` config attached to a `block` action, not a distinct rule type | +| Rule order | Sequential (first match wins) | `priority` is fully honoured — unlike Vercel, which can't reposition rules that already exist remotely | +| Managed rule groups | Supported via `managedRules` | See [Managed Rule Groups](#managed-rule-groups) below — Cloudflare-only among doorman's providers today | ## Translation Warnings @@ -174,14 +174,14 @@ Cloudflare is the only provider doorman can deploy vendor-managed rulesets (Clou } ``` -| Property | Type | Required | Description | -| --- | --- | --- | --- | -| `id` | string | No | Doorman's diff/sync identifier for this deployment. Omit for a new declaration — doorman assigns one on first sync. | -| `ruleset` | string | Yes | The vendor ruleset id to deploy (e.g. Cloudflare Managed Ruleset's well-known id shown above) | -| `name` | string | No | Human label | -| `enabled` | boolean | Yes | Whether this deployment is active | -| `action` | string | No | Ruleset-wide override — downgrade every rule in the group to this action. One of `log`, `deny`, `challenge`, `allow` | -| `overrides` | array | No | Per-rule overrides within the ruleset — `{ "ruleId": string, "action"?: string, "enabled"?: boolean }`, referenced by the *vendor's* rule id within that ruleset, not a doorman id | +| Property | Type | Required | Description | +| ----------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | No | Doorman's diff/sync identifier for this deployment. Omit for a new declaration — doorman assigns one on first sync. | +| `ruleset` | string | Yes | The vendor ruleset id to deploy (e.g. Cloudflare Managed Ruleset's well-known id shown above) | +| `name` | string | No | Human label | +| `enabled` | boolean | Yes | Whether this deployment is active | +| `action` | string | No | Ruleset-wide override — downgrade every rule in the group to this action. One of `log`, `deny`, `challenge`, `allow` | +| `overrides` | array | No | Per-rule overrides within the ruleset — `{ "ruleId": string, "action"?: string, "enabled"?: boolean }`, referenced by the _vendor's_ rule id within that ruleset, not a doorman id | Managed rule groups deploy in Cloudflare's separate managed-rules phase, evaluated independently of custom `rules` — no ordering interaction to think about between the two. `getChanges`/`syncRules` diff `managedRules` the same way as `rules`/`ips` — `doorman diff`/`doorman sync` cover it with no extra flags. diff --git a/skills/doorman/references/fastly.md b/skills/doorman/references/fastly.md index cd73a71..0a1b07e 100644 --- a/skills/doorman/references/fastly.md +++ b/skills/doorman/references/fastly.md @@ -115,12 +115,12 @@ Unlike classic Fastly VCL services, Next-Gen WAF rule and list writes take effec ## Limitations & Differences -| Feature | Vercel | Fastly | Notes | -| -------------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------- | -| Geo targeting | Country/city/continent/region | Country only | No sub-country condition field | -| Rule ordering | Best-effort (insertion order) | None | Next-Gen WAF rules are evaluated independently — `priority` has no effect | +| Feature | Vercel | Fastly | Notes | +| -------------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Geo targeting | Country/city/continent/region | Country only | No sub-country condition field | +| Rule ordering | Best-effort (insertion order) | None | Next-Gen WAF rules are evaluated independently — `priority` has no effect | | Managed/vendor rule groups | CRS (enterprise) | Templated signal rules | Doorman-managed on Cloudflare only ([cloudflare.md](cloudflare.md)); not yet configurable through Doorman for Vercel or Fastly | -| Signal/exclusion rules | — | `type: signal` | Not managed by Doorman — see below | +| Signal/exclusion rules | — | `type: signal` | Not managed by Doorman — see below | Fastly rules of type `signal` or `templated_signal` (which tag or exclude WAF signals rather than allow/block/redirect a request) are skipped entirely by `doorman download`/`fetchConfig` rather than forced into a lossy `UnifiedRule` — they simply don't appear in the local config. diff --git a/skills/doorman/references/gcp.md b/skills/doorman/references/gcp.md index 126053e..31b700e 100644 --- a/skills/doorman/references/gcp.md +++ b/skills/doorman/references/gcp.md @@ -23,7 +23,7 @@ GCP_POLICY_NAME=your-policy-name # Required — Cloud Armor sec 2. **Authenticate** — two options: - **Application Default Credentials** (recommended for local/dev use): `gcloud auth application-default login`. Uses your own Google account, no key file to create, store, or later revoke. Leave `serviceAccountKeyPath`/`GOOGLE_APPLICATION_CREDENTIALS` unset — `GoogleAuth` falls through to ADC automatically. - **Service account key** (for CI/production): create a service account with `roles/compute.securityAdmin` (the narrowest predefined role covering `securityPolicies.get/insert/patch/addRule/patchRule/removeRule/list`), generate a JSON key, and point `GOOGLE_APPLICATION_CREDENTIALS`/`serviceAccountKeyPath` at it. -3. **Create the security policy itself** — Doorman manages rules *within* an existing policy, it does not create the policy resource: +3. **Create the security policy itself** — Doorman manages rules _within_ an existing policy, it does not create the policy resource: ```bash gcloud compute security-policies create YOUR_POLICY_NAME --description="managed by doorman" ``` @@ -67,37 +67,37 @@ Cloud Armor rules match on CEL (Common Expression Language) — an expression-st ### Field Mapping -| Doorman Field | Cloud Armor CEL | Notes | -| -------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `ip` | `origin.ip == '...'` or `inIpRange(origin.ip, '...')` | Bare address uses `==`; a CIDR value (contains `/`) uses `inIpRange()` | -| `path` | `request.path` | | -| `method` | `request.method` | | -| `country` | `origin.region_code` | Country-level only | -| `asn` | `origin.asn` | Numeric comparison | -| `host` | `request.headers['host']` | Guarded with `has(...)` — indexing an absent header key is a CEL runtime error | -| `user_agent` | `request.headers['user-agent']` | Guarded with `has(...)` | -| `referer` | `request.headers['referer']` | Guarded with `has(...)` | -| `header` | `request.headers['']` | Requires `key` (the header name); guarded with `has(...)` | -| `query` | `request.query` | Raw, undecoded query string — no parsed per-parameter map | -| `cookie` | `request.headers['cookie']` | No parsed cookie map — see Cookie Matching below | +| Doorman Field | Cloud Armor CEL | Notes | +| ------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------ | +| `ip` | `origin.ip == '...'` or `inIpRange(origin.ip, '...')` | Bare address uses `==`; a CIDR value (contains `/`) uses `inIpRange()` | +| `path` | `request.path` | | +| `method` | `request.method` | | +| `country` | `origin.region_code` | Country-level only | +| `asn` | `origin.asn` | Numeric comparison | +| `host` | `request.headers['host']` | Guarded with `has(...)` — indexing an absent header key is a CEL runtime error | +| `user_agent` | `request.headers['user-agent']` | Guarded with `has(...)` | +| `referer` | `request.headers['referer']` | Guarded with `has(...)` | +| `header` | `request.headers['']` | Requires `key` (the header name); guarded with `has(...)` | +| `query` | `request.query` | Raw, undecoded query string — no parsed per-parameter map | +| `cookie` | `request.headers['cookie']` | No parsed cookie map — see Cookie Matching below | Fields with **no CEL equivalent at all** (`region`, `city`, `port`, `scheme`) fail loudly with a clear error rather than being silently dropped or approximated — Cloud Armor has no continent/region/city-level geo attribute (only country-level `origin.region_code`), and no per-request port attribute (Cloud Armor operates at L7 behind a load balancer). A rule using one of these doesn't sync at all; there's no partial/lossy version of it to fall back to. ### Cookie Matching -Cloud Armor has no parsed per-cookie map, only the raw `Cookie` header string. Without a `key`, a cookie condition checks the whole header. *With* a `key` (the common case — "is this specific cookie set to this value") Doorman composes a `key=value` substring and searches for it in the raw header — the only thing CEL can actually check. Only `eq`/`ne`/`contains`/`not_contains` have an unambiguous meaning against that synthesized substring; any other operator on a keyed cookie condition throws rather than emitting a check that looks precise but isn't. +Cloud Armor has no parsed per-cookie map, only the raw `Cookie` header string. Without a `key`, a cookie condition checks the whole header. _With_ a `key` (the common case — "is this specific cookie set to this value") Doorman composes a `key=value` substring and searches for it in the raw header — the only thing CEL can actually check. Only `eq`/`ne`/`contains`/`not_contains` have an unambiguous meaning against that synthesized substring; any other operator on a keyed cookie condition throws rather than emitting a check that looks precise but isn't. ### Action Mapping -| Doorman Action | Cloud Armor Action | Notes | -| --------------- | -------------------- | ----------------------------------------------------------------------- | -| `deny`, `block` | `deny(403)` | | -| `allow` | `allow` | | -| `bypass` | `allow` | | -| `rate_limit` | `throttle` | Requires `rateLimitOptions` — see below | -| `redirect` | `redirect` | Requires `redirectOptions` (target URL) | -| `log` | `allow` + `preview: true` | No dedicated log-only action — evaluates and logs without enforcing | -| `challenge` | `deny(403)` (with a warning) | No standalone challenge action for ordinary custom rules | +| Doorman Action | Cloud Armor Action | Notes | +| --------------- | ---------------------------- | ------------------------------------------------------------------- | +| `deny`, `block` | `deny(403)` | | +| `allow` | `allow` | | +| `bypass` | `allow` | | +| `rate_limit` | `throttle` | Requires `rateLimitOptions` — see below | +| `redirect` | `redirect` | Requires `redirectOptions` (target URL) | +| `log` | `allow` + `preview: true` | No dedicated log-only action — evaluates and logs without enforcing | +| `challenge` | `deny(403)` (with a warning) | No standalone challenge action for ordinary custom rules | ## The Priority Model @@ -109,13 +109,13 @@ Cloud Armor's real structural difference from every other provider: there is no ## No Dedicated IP-Blocking Resource -Unlike Vercel/Cloudflare/Fastly, Cloud Armor has no separate IP-list resource — an `ips[]` entry is just an ordinary rule under the hood, with a single `ip == X` (or `inIpRange(...)` for CIDR) CEL condition and nothing else layered on. Doorman classifies a fetched rule as an IP entry only when it has *exactly* that shape (one condition, `allow`/`deny(403)` action, nothing else) — a hand-authored `rules[]` entry that happens to match on `ip` alone is left as a rule, not silently reclassified into `ips[]`. +Unlike Vercel/Cloudflare/Fastly, Cloud Armor has no separate IP-list resource — an `ips[]` entry is just an ordinary rule under the hood, with a single `ip == X` (or `inIpRange(...)` for CIDR) CEL condition and nothing else layered on. Doorman classifies a fetched rule as an IP entry only when it has _exactly_ that shape (one condition, `allow`/`deny(403)` action, nothing else) — a hand-authored `rules[]` entry that happens to match on `ip` alone is left as a rule, not silently reclassified into `ips[]`. ## The Mandatory Default Rule Every real Cloud Armor security policy carries one rule Doorman never manages: a server-injected default catch-all at the maximum priority (`2147483647`), using a different match shape (`versionedExpr`/`config.srcIpRanges`, not CEL) — it cannot be removed, only its action changed. Doorman skips it entirely on fetch; it never appears in your local config and is never a candidate for deletion. If you need to change its action (rarely necessary — it only fires when nothing else matches), use `gcloud compute security-policies describe`/`update` directly. -Any *other* rule using that same basic (non-CEL) match shape — e.g. hand-created via `gcloud ... --src-ip-ranges` or the Console — gets an explicit warning on fetch rather than a crash or a silent drop: Doorman can't represent it, and leaves it unmanaged. +Any _other_ rule using that same basic (non-CEL) match shape — e.g. hand-created via `gcloud ... --src-ip-ranges` or the Console — gets an explicit warning on fetch rather than a crash or a silent drop: Doorman can't represent it, and leaves it unmanaged. ## Rate Limiting @@ -127,15 +127,15 @@ Every mutating call (`addRule`/`patchRule`/`removeRule`) returns a long-running ## Limitations & Differences -| Feature | Vercel | GCP Cloud Armor | Notes | -| ----------------------------- | -------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------- | -| Rule ordering | Best-effort (insertion order) | Explicit required `priority` | Lower priority number evaluates first; collisions rejected outright | -| IP blocking | Dedicated resource | Just a specially-shaped `rules[]` entry | No separate list/resource — see above | -| Geo targeting | Country/city/continent/region | Country only (`origin.region_code`) | `region`/`city` fields throw rather than silently drop | -| Port / scheme conditions | Yes | No CEL equivalent | Throw rather than silently drop | -| Managed/vendor rule groups | — | Preconfigured WAF rules exist on the real API | Not yet configurable through Doorman for any provider (tracked separately) | -| Standalone challenge action | Yes | No | Falls back to `deny(403)` with a warning | -| Change a rule's priority | N/A (no priority concept) | Remove + re-add under the new priority | No in-place relocation on the real API | +| Feature | Vercel | GCP Cloud Armor | Notes | +| --------------------------- | ----------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- | +| Rule ordering | Best-effort (insertion order) | Explicit required `priority` | Lower priority number evaluates first; collisions rejected outright | +| IP blocking | Dedicated resource | Just a specially-shaped `rules[]` entry | No separate list/resource — see above | +| Geo targeting | Country/city/continent/region | Country only (`origin.region_code`) | `region`/`city` fields throw rather than silently drop | +| Port / scheme conditions | Yes | No CEL equivalent | Throw rather than silently drop | +| Managed/vendor rule groups | — | Preconfigured WAF rules exist on the real API | Not yet configurable through Doorman for any provider (tracked separately) | +| Standalone challenge action | Yes | No | Falls back to `deny(403)` with a warning | +| Change a rule's priority | N/A (no priority concept) | Remove + re-add under the new priority | No in-place relocation on the real API | ## Manual End-to-End Verification