@gfargo/doorman 3.14.1 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gfargo/doorman",
3
- "version": "3.14.1",
3
+ "version": "3.15.0",
4
4
  "description": "Manage firewall rules as code across multiple providers",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -76,7 +76,7 @@ FASTLY_WORKSPACE_ID=workspace_xxx
76
76
  }
77
77
  ```
78
78
 
79
- For Cloudflare or Fastly, add `provider` and `providers` fields instead of `projectId`/`teamId`.
79
+ 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.
80
80
 
81
81
  ## Core Workflow
82
82
 
@@ -93,6 +93,10 @@ doorman backup && doorman validate && doorman diff && doorman sync && doorman st
93
93
 
94
94
  ## Rule Shape (Minimal)
95
95
 
96
+ Two different rule shapes, picked by whether the config has `provider`/`providers` set (see [Config Structure](#config-structure) above) — **they are not interchangeable, and mixing them fails validation.**
97
+
98
+ **Legacy shape** (no `provider`/`providers` — Vercel-only):
99
+
96
100
  ```json
97
101
  {
98
102
  "name": "Block Admin",
@@ -108,19 +112,39 @@ doorman backup && doorman validate && doorman diff && doorman sync && doorman st
108
112
 
109
113
  **Operators**: `eq`, `pre` (prefix), `suf` (suffix), `sub` (contains), `inc` (in array), `re` (regex), `ex` (exists), `nex` (not exists)
110
114
 
111
- **Actions**: `deny`, `challenge`, `rate_limit`, `redirect`, `log`, `bypass`
115
+ **Actions**: `deny`, `challenge`, `rate_limit`, `redirect`, `log`, `bypass` (no `allow`/`block`)
116
+
117
+ **Unified shape** (`provider`/`providers` set — required for Cloudflare/Fastly):
118
+
119
+ ```json
120
+ {
121
+ "name": "Block Admin",
122
+ "enabled": true,
123
+ "conditions": [{ "field": "path", "operator": "starts_with", "value": "/admin" }],
124
+ "action": { "type": "deny" }
125
+ }
126
+ ```
127
+
128
+ **Logic**: conditions default to AND across all of them. Tag conditions with a `group` number for OR-of-AND-groups (same conditions sharing a `group` are AND'd, distinct `group`s are OR'd) — see [references/rules.md](references/rules.md) for the full explanation and an example.
129
+
130
+ **Condition fields**: `ip`, `country`, `region`, `city`, `asn`, `path`, `host`, `method`, `header`, `query`, `cookie`, `user_agent`, `referer`, `scheme`, `port` — support varies by provider, see [references/cloudflare.md](references/cloudflare.md)/[references/fastly.md](references/fastly.md)/[references/gcp.md](references/gcp.md)
131
+
132
+ **Operators**: `eq`, `ne`, `contains`, `not_contains`, `starts_with`, `ends_with`, `matches`, `in`, `not_in`, `gt`, `ge`, `lt`, `le`, `exists`, `not_exists` — **on Vercel specifically, `ne`/`not_contains`/`not_in`/`gt`/`ge`/`lt`/`le` currently degrade silently to `eq` (known bug, [doorman#261](https://github.com/gfargo/doorman/issues/261)) — avoid them in a Vercel-targeted config until that's fixed.**
133
+
134
+ **Actions**: `log`, `deny`, `challenge`, `bypass`, `rate_limit`, `redirect`, `allow`, `block` — **`allow`/`block` are invalid on Vercel specifically ([doorman#262](https://github.com/gfargo/doorman/issues/262)); use `bypass`/`deny` there instead.**
112
135
 
113
136
  ## When to Read Each Reference
114
137
 
115
138
  Load the relevant reference file for detailed documentation:
116
139
 
117
- | Task | Reference |
118
- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
119
- | Writing rules — full field docs, operators, actions, IP blocking, patterns | [references/rules.md](references/rules.md) |
120
- | Cloudflare-specific setup, Lists API, expression translation, limitations | [references/cloudflare.md](references/cloudflare.md) |
121
- | Fastly-specific setup, condition/action mapping, rate-limit signal requirement, limitations | [references/fastly.md](references/fastly.md) |
122
- | Available templates and what they protect against | [references/templates.md](references/templates.md) |
123
- | CI/CD integration, automation, export formats, validation in pipelines | [references/cicd.md](references/cicd.md) |
140
+ | Task | Reference |
141
+ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
142
+ | Writing rules — full field docs, operators, actions, IP blocking, patterns | [references/rules.md](references/rules.md) |
143
+ | Cloudflare-specific setup, Lists API, managed rule groups, expression translation, limitations | [references/cloudflare.md](references/cloudflare.md) |
144
+ | Fastly-specific setup, condition/action mapping, rate-limit signal requirement, limitations | [references/fastly.md](references/fastly.md) |
145
+ | GCP Cloud Armor setup, CEL translation, priority model, manual e2e verification runbook | [references/gcp.md](references/gcp.md) |
146
+ | Available templates and what they protect against | [references/templates.md](references/templates.md) |
147
+ | CI/CD integration, automation, export formats, validation in pipelines | [references/cicd.md](references/cicd.md) |
124
148
 
125
149
  ## Principles
126
150
 
@@ -54,37 +54,50 @@ doorman list --provider cloudflare # Show deployed rules
54
54
 
55
55
  ## Rule Translation
56
56
 
57
- Doorman translates its unified rule format into Cloudflare Wirefilter expressions automatically. The translation is bidirectional — `doorman download` pulls Cloudflare expressions back into the unified format.
57
+ Doorman translates its unified rule format (`conditions`/`enabled`/`action: {type}` — different from the legacy shape [rules.md](rules.md) documents) into Cloudflare Wirefilter expressions automatically. The translation is bidirectional — `doorman download` pulls Cloudflare expressions back into the unified format. Cloudflare requires the unified format — a config with `provider: "cloudflare"` cannot use the legacy `conditionGroup`/`mitigate` shape.
58
58
 
59
59
  ### Field Mapping
60
60
 
61
- | Doorman Type | Cloudflare Field |
62
- | -------------------- | ----------------------------- |
63
- | `path` | `http.request.uri.path` |
64
- | `method` | `http.request.method` |
65
- | `host` | `http.host` |
66
- | `user_agent` | `http.user_agent` |
67
- | `ip_address` | `ip.src` |
68
- | `header` | `http.request.headers["key"]` |
69
- | `query` | `http.request.uri.query` |
70
- | `cookie` | `http.cookie` |
71
- | `geo_country` | `ip.geoip.country` |
72
- | `geo_city` | `ip.geoip.city` |
73
- | `geo_continent` | `ip.geoip.continent` |
74
- | `geo_country_region` | `ip.geoip.subdivision_1` |
75
- | `geo_as_number` | `ip.geoip.asnum` |
76
- | `scheme` | `ssl` (boolean) |
61
+ Cloudflare supports all 15 unified condition fields:
62
+
63
+ | Doorman Field | Cloudflare Field | Notes |
64
+ | ------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `ip` | `ip.src` | |
66
+ | `country` | `ip.geoip.country` | |
67
+ | `region` | `ip.geoip.subdivision_1` | |
68
+ | `city` | `ip.geoip.city` | |
69
+ | `asn` | `ip.geoip.asnum` | |
70
+ | `path` | `http.request.uri.path` | |
71
+ | `host` | `http.host` | |
72
+ | `method` | `http.request.method` | |
73
+ | `header` | `http.request.headers["key"]` | Requires `key` (the header name) |
74
+ | `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)) |
75
+ | `cookie` | `http.cookie["key"]` | Requires `key` (the cookie name) |
76
+ | `user_agent` | `http.user_agent` | |
77
+ | `referer` | `http.referer` | |
78
+ | `scheme` | `ssl` (boolean) | |
79
+ | `port` | `cf.edge.server_port` | |
80
+
81
+ ### Operator Mapping
82
+
83
+ Cloudflare supports all 15 unified operators exactly, no approximation — `not_contains`/`not_in`/`not_exists` compile to a wrapped `not (...)` expression rather than a single wirefilter token, but the semantics are exact:
84
+
85
+ `eq`, `ne`, `contains`, `not_contains`, `starts_with`, `ends_with`, `matches`, `in`, `not_in`, `gt`, `ge`, `lt`, `le`, `exists`, `not_exists`
86
+
87
+ `matches` (regex) may be plan-restricted on Cloudflare's side — regex matching in the Ruleset Engine is an Enterprise-plan feature on some plans. Doorman always emits the correct `matches` expression regardless of plan; Cloudflare's API may reject it if your zone's plan doesn't support regex. Use `contains`/`starts_with`/`ends_with` as fallbacks if you hit this.
77
88
 
78
89
  ### Action Mapping
79
90
 
80
- | Doorman Action | Cloudflare Action |
81
- | -------------- | -------------------------------- |
82
- | `deny` | `block` |
83
- | `challenge` | `managed_challenge` |
84
- | `rate_limit` | `block` + `ratelimit` config |
85
- | `redirect` | `redirect` + `from_value` params |
86
- | `log` | `log` |
87
- | `bypass` | `skip` |
91
+ | Doorman Action | Cloudflare Action | Notes |
92
+ | -------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `log` | `log` | |
94
+ | `deny` | `block` | |
95
+ | `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 |
96
+ | `bypass` | `skip` | |
97
+ | `allow` | `allow` | |
98
+ | `block` | `block` | Same target as `deny` |
99
+ | `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 |
100
+ | `redirect` | `redirect` + `from_value` params | ⚠️ If `action.redirect` is omitted, this silently becomes a rule with no redirect target — no error raised locally |
88
101
 
89
102
  ## Lists API (Bulk IP Management)
90
103
 
@@ -99,27 +112,27 @@ Without `CLOUDFLARE_ACCOUNT_ID`, IP blocking falls back to individual WAF rules
99
112
 
100
113
  ## Limitations & Differences
101
114
 
102
- | Feature | Vercel | Cloudflare | Notes |
103
- | ------------------------- | ------------------- | ----------------------------- | ------------------------------------------------- |
104
- | `re` operator | All plans | Enterprise only | Use `sub`/`pre`/`suf` as alternatives |
105
- | `environment` type | Yes | No | Vercel-specific concept |
106
- | `ja3_digest`/`ja4_digest` | Yes | No | Vercel TLS fingerprints |
107
- | `region` type | Yes | No | Vercel edge region |
108
- | IP Lists (bulk) | Individual rules | Lists API | Cloudflare needs `accountId` |
109
- | Max custom rules | ~100 | 5-125 (plan dependent) | Free: 5, Pro: 20, Business: 100, Enterprise: 125+ |
110
- | Rate limit | Via rule action | Separate phase | Different underlying mechanism |
111
- | Rule order | Parallel evaluation | Sequential (first match wins) | Ordering matters on Cloudflare |
115
+ `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.
116
+
117
+ | Feature | Cloudflare | Notes |
118
+ | ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------- |
119
+ | `matches` (regex) | Enterprise-plan-restricted on some plans | Doorman emits it regardless; Cloudflare's API may reject it — see Operator Mapping above |
120
+ | IP Lists (bulk) | Lists API | Needs `accountId`; without it, falls back to individual `ip.src` rules |
121
+ | Max custom rules | 5-125 (plan dependent) | Free: 5, Pro: 20, Business: 100, Enterprise: 125+ |
122
+ | Rate limit | Separate phase | `ratelimit` config attached to a `block` action, not a distinct rule type |
123
+ | Rule order | Sequential (first match wins) | `priority` is fully honoured — unlike Vercel, which can't reposition rules that already exist remotely |
124
+ | Managed rule groups | Supported via `managedRules` | See [Managed Rule Groups](#managed-rule-groups) below — Cloudflare-only among doorman's providers today |
112
125
 
113
126
  ## Translation Warnings
114
127
 
115
128
  The `RuleTranslator` surfaces warnings when a translation is lossy:
116
129
 
117
- - **Regex on non-Enterprise** — `re` operator only works on Cloudflare Enterprise; downgraded to `contains` with a warning
118
- - **Unsupported types** — Vercel-only fields (`environment`, `ja3_digest`, `ja4_digest`, `region`) are dropped with a warning
119
- - **Duration differences** — `actionDuration` maps differently between providers
130
+ - **Unmapped managed-ruleset override action** — an `action`/`overrides[].action` in `managedRules` with no Cloudflare equivalent (only `log`/`deny`/`challenge`/`allow` map cleanly) is dropped with a warning
120
131
  - **Negation edge cases** — complex negated conditions may produce subtly different behavior in Wirefilter
121
132
 
122
- Run `doorman validate` to surface warnings before deploying — it auto-detects Cloudflare from the config's `provider` field.
133
+ Doorman does **not** currently warn on the `rate_limit`/`redirect` omitted-config-object cases noted in Action Mapping above, or the `query`-condition `key`-ignored case noted in Field Mapping above ([doorman#263](https://github.com/gfargo/doorman/issues/263)) — both are known gaps, not something `doorman validate` catches today. Double-check those specifically rather than relying on validation output.
134
+
135
+ Run `doorman validate` to surface the warnings that do exist before deploying — it auto-detects Cloudflare from the config's `provider` field.
123
136
 
124
137
  ## Cloudflare-Specific Validation
125
138
 
@@ -139,6 +152,39 @@ The `CloudflareOptimizer` consolidates rules for efficient deployment:
139
152
  - Deduplicates IP entries across rules and Lists
140
153
  - Computes minimal changesets to avoid unnecessary API calls (diff-based sync)
141
154
 
155
+ ## Managed Rule Groups
156
+
157
+ Cloudflare is the only provider doorman can deploy vendor-managed rulesets (Cloudflare Managed Ruleset, OWASP CRS, etc.) for today. Add a `managedRules` array alongside `rules`/`ips`:
158
+
159
+ ```json
160
+ {
161
+ "managedRules": [
162
+ {
163
+ "id": "execute-owasp-crs",
164
+ "ruleset": "efb7b8c949ac4650a09736fc376e9aee",
165
+ "name": "OWASP Core Ruleset",
166
+ "enabled": true,
167
+ "action": "log",
168
+ "overrides": [
169
+ { "ruleId": "981176", "action": "deny" },
170
+ { "ruleId": "981245", "enabled": false }
171
+ ]
172
+ }
173
+ ]
174
+ }
175
+ ```
176
+
177
+ | Property | Type | Required | Description |
178
+ | ----------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
179
+ | `id` | string | No | Doorman's diff/sync identifier for this deployment. Omit for a new declaration — doorman assigns one on first sync. |
180
+ | `ruleset` | string | Yes | The vendor ruleset id to deploy (e.g. Cloudflare Managed Ruleset's well-known id shown above) |
181
+ | `name` | string | No | Human label |
182
+ | `enabled` | boolean | Yes | Whether this deployment is active |
183
+ | `action` | string | No | Ruleset-wide override — downgrade every rule in the group to this action. One of `log`, `deny`, `challenge`, `allow` |
184
+ | `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 |
185
+
186
+ 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.
187
+
142
188
  ## Example: Full Cloudflare Config
143
189
 
144
190
  ```json
@@ -156,31 +202,37 @@ The `CloudflareOptimizer` consolidates rules for efficient deployment:
156
202
  "id": "rule_block_bots",
157
203
  "name": "Block Bad Bots",
158
204
  "description": "Block known malicious crawlers",
159
- "active": true,
160
- "conditionGroup": [
161
- { "conditions": [{ "type": "user_agent", "op": "sub", "value": "AhrefsBot" }] },
162
- { "conditions": [{ "type": "user_agent", "op": "sub", "value": "SemrushBot" }] }
205
+ "enabled": true,
206
+ "conditions": [
207
+ { "field": "user_agent", "operator": "contains", "value": "AhrefsBot", "group": 0 },
208
+ { "field": "user_agent", "operator": "contains", "value": "SemrushBot", "group": 1 }
163
209
  ],
164
- "action": { "mitigate": { "action": "deny" } }
210
+ "action": { "type": "deny" }
165
211
  },
166
212
  {
167
213
  "id": "rule_rate_limit_api",
168
214
  "name": "Rate Limit API",
169
215
  "description": "Limit API requests to 100/min per IP",
170
- "active": true,
171
- "conditionGroup": [{ "conditions": [{ "type": "path", "op": "pre", "value": "/api/" }] }],
216
+ "enabled": true,
217
+ "conditions": [{ "field": "path", "operator": "starts_with", "value": "/api/" }],
172
218
  "action": {
173
- "mitigate": {
174
- "action": "rate_limit",
175
- "rateLimit": {
176
- "requests": 100,
177
- "window": "1m",
178
- "characteristics": ["ip.src"]
179
- }
219
+ "type": "rate_limit",
220
+ "rateLimit": {
221
+ "requests": 100,
222
+ "window": "1m",
223
+ "characteristics": ["ip.src"]
180
224
  }
181
225
  }
182
226
  }
183
227
  ],
228
+ "managedRules": [
229
+ {
230
+ "ruleset": "efb7b8c949ac4650a09736fc376e9aee",
231
+ "name": "OWASP Core Ruleset",
232
+ "enabled": true,
233
+ "action": "log"
234
+ }
235
+ ],
184
236
  "ips": [{ "ip": "203.0.113.0/24", "action": "deny", "notes": "Known attack subnet" }]
185
237
  }
186
238
  ```
@@ -115,12 +115,12 @@ Unlike classic Fastly VCL services, Next-Gen WAF rule and list writes take effec
115
115
 
116
116
  ## Limitations & Differences
117
117
 
118
- | Feature | Vercel | Fastly | Notes |
119
- | -------------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------- |
120
- | Geo targeting | Country/city/continent/region | Country only | No sub-country condition field |
121
- | Rule ordering | Best-effort (insertion order) | None | Next-Gen WAF rules are evaluated independently — `priority` has no effect |
122
- | Managed/vendor rule groups | CRS (enterprise) | Templated signal rules | Not yet configurable through Doorman for either provider (#183) |
123
- | Signal/exclusion rules | — | `type: signal` | Not managed by Doorman — see below |
118
+ | Feature | Vercel | Fastly | Notes |
119
+ | -------------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
120
+ | Geo targeting | Country/city/continent/region | Country only | No sub-country condition field |
121
+ | Rule ordering | Best-effort (insertion order) | None | Next-Gen WAF rules are evaluated independently — `priority` has no effect |
122
+ | 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 |
123
+ | Signal/exclusion rules | — | `type: signal` | Not managed by Doorman — see below |
124
124
 
125
125
  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.
126
126
 
@@ -0,0 +1,184 @@
1
+ # GCP Cloud Armor Provider Reference
2
+
3
+ Google Cloud Armor support in Doorman (beta). Manage a Cloud Armor security policy's custom rules through the same config-as-code workflow as Vercel/Cloudflare/Fastly.
4
+
5
+ ## Setup
6
+
7
+ ### Environment Variables
8
+
9
+ Unlike the other three providers, GCP intentionally reuses GCP's own ecosystem-standard env var names rather than doorman-prefixed ones — a user with GCP already configured for anything else likely has these set already:
10
+
11
+ ```bash
12
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json # Optional — service account key path; omit to use Application Default Credentials
13
+ GOOGLE_CLOUD_PROJECT=your-project-id # Required — GCP project ID
14
+ GCP_POLICY_NAME=your-policy-name # Required — Cloud Armor security policy name (no ecosystem-standard env var exists for this)
15
+ ```
16
+
17
+ ### One-Time Project Setup
18
+
19
+ 1. **Enable the Compute Engine API** — Cloud Armor security policies live under it:
20
+ ```bash
21
+ gcloud services enable compute.googleapis.com --project=YOUR_PROJECT
22
+ ```
23
+ 2. **Authenticate** — two options:
24
+ - **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.
25
+ - **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.
26
+ 3. **Create the security policy itself** — Doorman manages rules _within_ an existing policy, it does not create the policy resource:
27
+ ```bash
28
+ gcloud compute security-policies create YOUR_POLICY_NAME --description="managed by doorman"
29
+ ```
30
+
31
+ ### Config Shape
32
+
33
+ Multi-provider config with explicit provider declaration:
34
+
35
+ ```json
36
+ {
37
+ "$schema": "https://doorman.griffen.codes/schema.json",
38
+ "provider": "gcp",
39
+ "providers": {
40
+ "gcp": {
41
+ "projectId": "your-project-id",
42
+ "policyName": "your-policy-name"
43
+ }
44
+ },
45
+ "rules": [],
46
+ "ips": []
47
+ }
48
+ ```
49
+
50
+ Or pass `--provider gcp` to any provider-aware command to override auto-detection. `serviceAccountKeyPath` has no `configKey` — a filesystem path is environment-specific and never belongs in a config file meant to be shared/committed; set it via `GOOGLE_APPLICATION_CREDENTIALS` instead.
51
+
52
+ ## Usage
53
+
54
+ `doorman init` only supports Vercel today — it has no `--provider` flag and doesn't prompt for GCP credentials. Create `.doorman.json` by hand using the config shape above, then:
55
+
56
+ ```bash
57
+ doorman validate # Validate — auto-detects gcp from the config's `provider` field
58
+ doorman status --provider gcp
59
+ doorman diff --provider gcp
60
+ doorman sync --provider gcp
61
+ doorman download --provider gcp
62
+ ```
63
+
64
+ ## Rule Translation
65
+
66
+ Cloud Armor rules match on CEL (Common Expression Language) — an expression-string DSL, structurally the same problem as Cloudflare's Wirefilter. Doorman generates/parses only the flat subset Cloud Armor's real-world usage actually needs: Google's own docs cap this at "up to five subexpressions" combined with `&&`/`||`, no nesting — unlike Wirefilter, Cloud Armor's CEL condition tree never needs an OR nested inside an AND.
67
+
68
+ ### Field Mapping
69
+
70
+ | Doorman Field | Cloud Armor CEL | Notes |
71
+ | ------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------ |
72
+ | `ip` | `origin.ip == '...'` or `inIpRange(origin.ip, '...')` | Bare address uses `==`; a CIDR value (contains `/`) uses `inIpRange()` |
73
+ | `path` | `request.path` | |
74
+ | `method` | `request.method` | |
75
+ | `country` | `origin.region_code` | Country-level only |
76
+ | `asn` | `origin.asn` | Numeric comparison |
77
+ | `host` | `request.headers['host']` | Guarded with `has(...)` — indexing an absent header key is a CEL runtime error |
78
+ | `user_agent` | `request.headers['user-agent']` | Guarded with `has(...)` |
79
+ | `referer` | `request.headers['referer']` | Guarded with `has(...)` |
80
+ | `header` | `request.headers['<key>']` | Requires `key` (the header name); guarded with `has(...)` |
81
+ | `query` | `request.query` | Raw, undecoded query string — no parsed per-parameter map |
82
+ | `cookie` | `request.headers['cookie']` | No parsed cookie map — see Cookie Matching below |
83
+
84
+ 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.
85
+
86
+ ### Cookie Matching
87
+
88
+ 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.
89
+
90
+ ### Action Mapping
91
+
92
+ | Doorman Action | Cloud Armor Action | Notes |
93
+ | --------------- | ---------------------------- | ------------------------------------------------------------------- |
94
+ | `deny`, `block` | `deny(403)` | |
95
+ | `allow` | `allow` | |
96
+ | `bypass` | `allow` | |
97
+ | `rate_limit` | `throttle` | Requires `rateLimitOptions` — see below |
98
+ | `redirect` | `redirect` | Requires `redirectOptions` (target URL) |
99
+ | `log` | `allow` + `preview: true` | No dedicated log-only action — evaluates and logs without enforcing |
100
+ | `challenge` | `deny(403)` (with a warning) | No standalone challenge action for ordinary custom rules |
101
+
102
+ ## The Priority Model
103
+
104
+ Cloud Armor's real structural difference from every other provider: there is no separate, server-assigned rule id. A rule's `priority` — an explicit integer required on every rule — is simultaneously its doorman `id`, its evaluation order, **and** its addressing key for updates/deletes.
105
+
106
+ - A new local rule/IP with no priority yet gets one assigned automatically during `sync`, spaced 1000 apart (matching common Cloud Armor/`gcloud` convention), checked against a freshly-fetched remote policy to avoid collisions.
107
+ - **Cloud Armor has no "change priority" operation.** Editing a rule's `priority` in config relocates it via remove-then-add-at-the-new-priority — the only relocation path the real API supports — not an in-place PATCH.
108
+ - Cloud Armor rejects a priority that collides with an existing rule outright.
109
+
110
+ ## No Dedicated IP-Blocking Resource
111
+
112
+ 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[]`.
113
+
114
+ ## The Mandatory Default Rule
115
+
116
+ 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.
117
+
118
+ 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.
119
+
120
+ ## Rate Limiting
121
+
122
+ `rate_limit` maps to Cloud Armor's `throttle` action, with `rateLimitOptions.rateLimitThreshold` (`count`/`intervalSec`) and `conformAction: 'allow'`/`exceedAction: 'deny(429)'`. A `rate_limit` rule with no `rateLimit` block configured emits a warning and will likely be rejected by the API — Cloud Armor requires `rateLimitOptions` for this action.
123
+
124
+ ## Operations & Polling
125
+
126
+ Every mutating call (`addRule`/`patchRule`/`removeRule`) returns a long-running GCP Operation, not the updated resource directly. Doorman polls it internally — starting at 250ms, backing off toward a 1s ceiling, capped at 60s total — until it reports `DONE`, so every command still presents the same "await and it's done" shape the other three providers already have; GCP's async-operation model never leaks into the CLI surface.
127
+
128
+ ## Limitations & Differences
129
+
130
+ | Feature | Vercel | GCP Cloud Armor | Notes |
131
+ | --------------------------- | ----------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
132
+ | Rule ordering | Best-effort (insertion order) | Explicit required `priority` | Lower priority number evaluates first; collisions rejected outright |
133
+ | IP blocking | Dedicated resource | Just a specially-shaped `rules[]` entry | No separate list/resource — see above |
134
+ | Geo targeting | Country/city/continent/region | Country only (`origin.region_code`) | `region`/`city` fields throw rather than silently drop |
135
+ | Port / scheme conditions | Yes | No CEL equivalent | Throw rather than silently drop |
136
+ | Managed/vendor rule groups | — | Preconfigured WAF rules exist on the real API | Not yet configurable through Doorman for any provider (tracked separately) |
137
+ | Standalone challenge action | Yes | No | Falls back to `deny(403)` with a warning |
138
+ | 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 |
139
+
140
+ ## Manual End-to-End Verification
141
+
142
+ Cloud Armor is the one provider where a fully-offline mock-server e2e is not possible: `demos/cloudarmor-mock-server.mjs` can only exercise `CloudArmorClient`'s request/response handling once a caller already has a real, working `GoogleAuth` setup — `google-auth-library`'s OAuth2 token exchange has to reach Google's real `oauth2.googleapis.com` even for local testing (see that file's own top-of-file comment). Verifying a change to this provider for real means testing against an actual, disposable GCP resource:
143
+
144
+ 1. Confirm `gcloud` is authenticated: `gcloud auth application-default login`.
145
+ 2. Enable the API once per project: `gcloud services enable compute.googleapis.com --project=YOUR_PROJECT`.
146
+ 3. Create a throwaway policy: `gcloud compute security-policies create doorman-e2e-test --description="disposable - safe to delete"`.
147
+ 4. Hand-author a `.doorman.json` pointing `providers.gcp.projectId`/`policyName` at it, with a couple of rules and IPs. Use RFC 5737 documentation-only ranges for IPs (`203.0.113.0/24`, `198.51.100.0/24`, `192.0.2.0/24`) — never real addresses.
148
+ 5. Run the full command surface against it: `status` → `diff` → `sync --ci` → `status` → `download --ci`. Then edit a rule's `priority` in the config and re-sync to exercise relocation, and remove a rule and run `sync --ci --allowDeletions` to exercise deletion — `sync --ci` alone correctly refuses to delete anything without that flag, rather than silently proceeding.
149
+ 6. Delete the policy when done: `gcloud compute security-policies delete doorman-e2e-test --project=YOUR_PROJECT --quiet`.
150
+
151
+ **Cost**: Cloud Armor Standard is $5/policy/month + $1/rule/month (pay-as-you-go, prorated), independent of whether the policy is attached to a backend service. Trivial for a verification run lasting a few hours, but not literally free — delete the policy promptly when done.
152
+
153
+ ## Example: Full GCP Config
154
+
155
+ ```json
156
+ {
157
+ "$schema": "https://doorman.griffen.codes/schema.json",
158
+ "provider": "gcp",
159
+ "providers": {
160
+ "gcp": {
161
+ "projectId": "my-gcp-project",
162
+ "policyName": "doorman-managed-policy"
163
+ }
164
+ },
165
+ "rules": [
166
+ {
167
+ "name": "Block Bad Bots",
168
+ "enabled": true,
169
+ "conditions": [{ "field": "user_agent", "operator": "contains", "value": "AhrefsBot" }],
170
+ "action": { "type": "deny" }
171
+ },
172
+ {
173
+ "name": "Rate Limit API",
174
+ "enabled": true,
175
+ "conditions": [{ "field": "path", "operator": "starts_with", "value": "/api/" }],
176
+ "action": {
177
+ "type": "rate_limit",
178
+ "rateLimit": { "requests": 100, "window": "1m" }
179
+ }
180
+ }
181
+ ],
182
+ "ips": [{ "ip": "203.0.113.0/24", "action": "deny", "notes": "Known attack subnet" }]
183
+ }
184
+ ```
@@ -2,6 +2,8 @@
2
2
 
3
3
  Complete reference for creating and configuring Doorman firewall rules.
4
4
 
5
+ > **Format note:** this reference covers the **legacy (Vercel-only) rule format** — no `provider`/`providers` field in the config. If your config has `provider`/`providers` set (required for Cloudflare/Fastly), rules use a different shape entirely (`conditions`/`enabled`/flat `action: {type}` instead of `conditionGroup`/`active`/`action: {mitigate}`) — see [cloudflare.md](cloudflare.md)/[fastly.md](fastly.md) for the correct format and field/operator/action support per provider, and `SKILL.md`'s "Rule Shape" section for a side-by-side comparison.
6
+
5
7
  ## Rule Structure
6
8
 
7
9
  Every rule in the `rules` array has this shape: