@gfargo/doorman 3.14.0 → 3.14.2
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/dist/bin/run.js +47 -47
- package/dist/bin/run.mjs +47 -47
- package/package.json +1 -1
- package/skills/doorman/SKILL.md +27 -3
- package/skills/doorman/references/cloudflare.md +105 -53
- package/skills/doorman/references/fastly.md +1 -1
- package/skills/doorman/references/gcp.md +184 -0
- package/skills/doorman/references/rules.md +87 -73
- package/skills/doorman/references/templates.md +15 -4
package/package.json
CHANGED
package/skills/doorman/SKILL.md
CHANGED
|
@@ -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,7 +112,26 @@ 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
|
|
|
@@ -117,8 +140,9 @@ Load the relevant reference file for detailed documentation:
|
|
|
117
140
|
| Task | Reference |
|
|
118
141
|
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
119
142
|
| 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
|
|
143
|
+
| Cloudflare-specific setup, Lists API, managed rule groups, expression translation, limitations | [references/cloudflare.md](references/cloudflare.md) |
|
|
121
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) |
|
|
122
146
|
| Available templates and what they protect against | [references/templates.md](references/templates.md) |
|
|
123
147
|
| CI/CD integration, automation, export formats, validation in pipelines | [references/cicd.md](references/cicd.md) |
|
|
124
148
|
|
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
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
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
| `
|
|
107
|
-
|
|
|
108
|
-
|
|
|
109
|
-
|
|
|
110
|
-
|
|
|
111
|
-
|
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
"
|
|
160
|
-
"
|
|
161
|
-
{ "
|
|
162
|
-
{ "
|
|
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": { "
|
|
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
|
-
"
|
|
171
|
-
"
|
|
216
|
+
"enabled": true,
|
|
217
|
+
"conditions": [{ "field": "path", "operator": "starts_with", "value": "/api/" }],
|
|
172
218
|
"action": {
|
|
173
|
-
"
|
|
174
|
-
|
|
175
|
-
"
|
|
176
|
-
|
|
177
|
-
|
|
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
|
```
|
|
@@ -119,7 +119,7 @@ Unlike classic Fastly VCL services, Next-Gen WAF rule and list writes take effec
|
|
|
119
119
|
| -------------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------- |
|
|
120
120
|
| Geo targeting | Country/city/continent/region | Country only | No sub-country condition field |
|
|
121
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 |
|
|
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
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.
|
|
@@ -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
|
+
```
|