Skip to content

Commit e8a1f6e

Browse files
barnumbirrclaude
andcommitted
v0.9.0: automatic tier detection, thread safety, cleanup
Added: - Automatic Cloud Armor tier detection — resolve_zone_id inspects ddos_protection_config and rule_visibility to classify policies as standard, plus, or enterprise. Fixed: - recaptcha_options_config validation error message now includes zone_name/extension_key prefix. Changed: - Policy settings and linter registration now thread-safe. Removed: - Unused format_plan/count_changes from PolicySettingsFormatter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent df15e04 commit e8a1f6e

9 files changed

Lines changed: 416 additions & 95 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.9.0] - 2026-04-10
9+
10+
### Added
11+
- Automatic Cloud Armor tier detection — `resolve_zone_id` inspects
12+
`ddos_protection_config` and
13+
`adaptive_protection_config.layer7_ddos_defense_config.rule_visibility` to
14+
classify policies as `standard`, `plus`, or `enterprise`. Detected tiers are
15+
exposed via `zone_plans` and feed into the core zone plans cache for automatic
16+
plan-tier-aware linting (GA501, GA502).
17+
18+
### Fixed
19+
- `recaptcha_options_config` validation error message now includes the
20+
`zone_name/extension_key` prefix consistent with all other validation
21+
messages.
22+
23+
### Changed
24+
- Policy settings and linter registration are now thread-safe
25+
(`threading.Lock`).
26+
27+
### Removed
28+
- Unused `format_plan` and `count_changes` methods from
29+
`PolicySettingsFormatter`.
30+
831
## [0.8.5] - 2026-04-09
932

1033
### Changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Safety thresholds are configured under `safety:` (framework-owned, not forwarded
7070
|---------|--------|-------|
7171
| Phase rules (4 phases) | Supported | Security policy rules |
7272
| Policy settings | Supported | Adaptive protection, DDoS config, default rule action |
73+
| Automatic tier detection | Supported | Detects `standard`, `plus`, or `enterprise` from policy config (no manual setting needed) |
7374
| Custom rulesets | Not supported | — |
7475
| Lists | Not supported | Use inline IP ranges in match config |
7576
| Page Shield | Not supported | — |

docs/lint.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,9 +1920,9 @@ gcloud_armor_custom_rules:
19201920

19211921
**Severity:** WARNING
19221922

1923-
Google Cloud Armor standard tier limits each security policy to **10 rules** that use `matches()` (regex) in their CEL expression. This check counts regex rules across all phases in the policy and warns when the limit is exceeded.
1923+
Google Cloud Armor standard tier limits each security policy to **10 rules** that use `matches()` (regex) in their CEL expression. This check counts regex rules across all phases in the policy and warns when the limit is exceeded. The tier is auto-detected from the policy's DDoS and adaptive protection configuration (see [GA502](#ga502--rule-count-exceeds-tier-limit) for details); this rule only fires when the detected tier is `standard`.
19241924

1925-
**Triggers on:** A policy with more than 10 rules using `matches()`.
1925+
**Triggers on:** A standard-tier policy with more than 10 rules using `matches()`.
19261926

19271927
**Fix:** Reduce the number of regex rules, combine patterns, or upgrade to Cloud Armor Plus/Enterprise which has higher limits.
19281928

@@ -1940,7 +1940,7 @@ Cloud Armor has per-policy rule count limits that vary by tier:
19401940
| Plus | 512 |
19411941
| Enterprise | 1024 |
19421942

1943-
This check compares the number of rules in a phase against the configured tier's limit. The tier is determined by the `plan_tier` setting (defaults to "enterprise", the most permissive).
1943+
This check compares the number of rules in a phase against the tier's limit. The tier is auto-detected from the policy's `ddos_protection_config` and `adaptive_protection_config.layer7_ddos_defense_config.rule_visibility` during zone resolution (`standard`, `plus`, or `enterprise`). When detection isn't possible (e.g., the policy lacks these fields), the tier falls back to `enterprise` (the most permissive).
19441944

19451945
**Triggers on:** A phase with more rules than the tier allows.
19461946

octorules_google/_policy_settings.py

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,10 @@ def _validate_policy_settings(desired, zone_name, errors, lines):
266266

267267
rc_cfg = settings.get("recaptcha_options_config")
268268
if rc_cfg is not None and not isinstance(rc_cfg, dict):
269-
errors.append(f"recaptcha_options_config must be a mapping, got {type(rc_cfg).__name__}")
269+
errors.append(
270+
f" {zone_name}/{_EXT_KEY}: recaptcha_options_config must be a mapping,"
271+
f" got {type(rc_cfg).__name__}"
272+
)
270273

271274

272275
def _dump_policy_settings(scope, provider, out_dir):
@@ -291,27 +294,6 @@ def _dump_policy_settings(scope, provider, out_dir):
291294
class PolicySettingsFormatter:
292295
"""Formats policy settings diffs for plan output."""
293296

294-
def format_plan(self, plans: list, zone_name: str) -> list[str]:
295-
lines: list[str] = []
296-
for plan in plans:
297-
if not isinstance(plan, PolicySettingsPlan) or not plan.has_changes:
298-
continue
299-
for change in plan.changes:
300-
if not change.has_changes:
301-
continue
302-
lines.append(
303-
f" {zone_name}/policy_settings.{change.field}:"
304-
f" {change.current!r} -> {change.desired!r}"
305-
)
306-
return lines
307-
308-
def count_changes(self, plans: list) -> int:
309-
count = 0
310-
for plan in plans:
311-
if isinstance(plan, PolicySettingsPlan):
312-
count += sum(1 for c in plan.changes if c.has_changes)
313-
return count
314-
315297
def format_text(self, plans: list, use_color: bool) -> list[str]:
316298
from octorules._color import Pen
317299

@@ -430,18 +412,18 @@ def register_policy_settings() -> None:
430412
with _register_lock:
431413
if _registered:
432414
return
433-
_registered = True
434415

435-
from octorules.extensions import (
436-
register_apply_extension,
437-
register_dump_extension,
438-
register_format_extension,
439-
register_plan_zone_hook,
440-
register_validate_extension,
441-
)
442-
443-
register_plan_zone_hook(_prefetch_policy_settings, _finalize_policy_settings)
444-
register_apply_extension(_EXT_KEY, _apply_policy_settings)
445-
register_format_extension(_EXT_KEY, PolicySettingsFormatter())
446-
register_validate_extension(_validate_policy_settings)
447-
register_dump_extension(_dump_policy_settings)
416+
from octorules.extensions import (
417+
register_apply_extension,
418+
register_dump_extension,
419+
register_format_extension,
420+
register_plan_zone_hook,
421+
register_validate_extension,
422+
)
423+
424+
register_plan_zone_hook(_prefetch_policy_settings, _finalize_policy_settings)
425+
register_apply_extension(_EXT_KEY, _apply_policy_settings)
426+
register_format_extension(_EXT_KEY, PolicySettingsFormatter())
427+
register_validate_extension(_validate_policy_settings)
428+
register_dump_extension(_dump_policy_settings)
429+
_registered = True
Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Google Cloud Armor linter — registers all GCloud-specific lint rules and plugins."""
22

3+
import threading
4+
35
_registered = False
6+
_register_lock = threading.Lock()
47

58

69
def register_google_linter() -> None:
@@ -9,16 +12,17 @@ def register_google_linter() -> None:
912
Safe to call multiple times — subsequent calls are no-ops.
1013
"""
1114
global _registered
12-
if _registered:
13-
return
15+
with _register_lock:
16+
if _registered:
17+
return
1418

15-
from octorules.linter.plugin import LintPlugin, register_linter
16-
from octorules.linter.rules.registry import register_rules
19+
from octorules.linter.plugin import LintPlugin, register_linter
20+
from octorules.linter.rules.registry import register_rules
1721

18-
from octorules_google.linter._plugin import GA_RULE_IDS, google_lint
19-
from octorules_google.linter._rules import GA_RULE_METAS
22+
from octorules_google.linter._plugin import GA_RULE_IDS, google_lint
23+
from octorules_google.linter._rules import GA_RULE_METAS
2024

21-
register_linter(LintPlugin(name="google", lint_fn=google_lint, rule_ids=GA_RULE_IDS))
22-
register_rules(GA_RULE_METAS)
25+
register_linter(LintPlugin(name="google", lint_fn=google_lint, rule_ids=GA_RULE_IDS))
26+
register_rules(GA_RULE_METAS)
2327

24-
_registered = True
28+
_registered = True

octorules_google/provider.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010
import os
11+
import threading
1112

1213
from google.api_core.exceptions import Forbidden, GoogleAPIError, NotFound, Unauthorized
1314
from google.auth.exceptions import DefaultCredentialsError
@@ -97,6 +98,32 @@ def _guarded():
9798
)
9899

99100

101+
# ---------------------------------------------------------------------------
102+
# Tier detection
103+
# ---------------------------------------------------------------------------
104+
105+
106+
def _detect_tier(policy: dict) -> str:
107+
"""Detect the Cloud Armor tier from a SecurityPolicy dict.
108+
109+
Heuristic:
110+
- No ``ddos_protection_config`` or ddos_protection is not ADVANCED/ADVANCED_PREVIEW
111+
→ ``"standard"``
112+
- ADVANCED or ADVANCED_PREVIEW with layer7 rule_visibility ``"PREMIUM"``
113+
→ ``"enterprise"``
114+
- ADVANCED or ADVANCED_PREVIEW otherwise → ``"plus"``
115+
"""
116+
ddos_cfg = policy.get("ddos_protection_config") or {}
117+
ddos_protection = ddos_cfg.get("ddos_protection", "")
118+
if ddos_protection not in ("ADVANCED", "ADVANCED_PREVIEW"):
119+
return "standard"
120+
adaptive = policy.get("adaptive_protection_config") or {}
121+
layer7 = adaptive.get("layer7_ddos_defense_config") or {}
122+
if layer7.get("rule_visibility") == "PREMIUM":
123+
return "enterprise"
124+
return "plus"
125+
126+
100127
# ---------------------------------------------------------------------------
101128
# Rule classification
102129
# ---------------------------------------------------------------------------
@@ -216,6 +243,8 @@ def __init__(
216243
)
217244
self._max_workers = max_workers
218245
self._timeout = timeout if timeout is not None else 30.0
246+
self._zone_plans: dict[str, str] = {}
247+
self._lock = threading.Lock()
219248

220249
# -- Properties --
221250

@@ -236,8 +265,8 @@ def account_name(self) -> str | None:
236265

237266
@property
238267
def zone_plans(self) -> dict[str, str]:
239-
"""Return empty dict; Cloud Armor has no zone plan tiers."""
240-
return {}
268+
"""Zone tiers detected from policy properties."""
269+
return dict(self._zone_plans)
241270

242271
# -- Helpers --
243272

@@ -330,17 +359,22 @@ def update_policy_settings(self, scope: Scope, settings: dict) -> None:
330359
def resolve_zone_id(self, zone_name: str) -> str:
331360
"""Resolve a security policy name to itself (Cloud Armor uses names).
332361
333-
Verifies the policy exists. Raises ConfigError if not found.
362+
Verifies the policy exists and detects the Cloud Armor tier.
363+
Raises ConfigError if not found.
334364
"""
335365
try:
336-
self._client.get(
366+
response = self._client.get(
337367
project=self._project,
338368
security_policy=zone_name,
339369
timeout=self._timeout,
340370
)
341371
except NotFound:
342372
raise ConfigError(f"No security policy found for {zone_name!r}") from None
343-
log.debug("Resolved %s -> %s", zone_name, zone_name)
373+
policy = to_plain_dict(response)
374+
tier = _detect_tier(policy)
375+
with self._lock:
376+
self._zone_plans[zone_name] = tier
377+
log.debug("Resolved %s -> %s (tier=%s)", zone_name, zone_name, tier)
344378
return zone_name
345379

346380
@_wrap_provider_errors

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "octorules-google"
7-
version = "0.8.5"
7+
version = "0.9.0"
88
description = "Google Cloud Armor provider for octorules"
99
license = "Apache-2.0"
1010
requires-python = ">=3.10"

tests/test_policy_settings.py

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ def test_recaptcha_options_config_non_dict(self):
566566
errors: list[str] = []
567567
_validate_policy_settings(desired, "zone", errors, [])
568568
assert len(errors) == 1
569+
assert "zone/gcloud_armor_policy_settings:" in errors[0]
569570
assert "recaptcha_options_config" in errors[0]
570571
assert "mapping" in errors[0]
571572

@@ -833,46 +834,6 @@ def test_no_drift(self):
833834
assert phases_data == []
834835

835836

836-
# ---------------------------------------------------------------------------
837-
# Format extension — format_plan and count_changes
838-
# ---------------------------------------------------------------------------
839-
class TestFormatPlanAndCount:
840-
def test_format_plan(self):
841-
fmt = PolicySettingsFormatter()
842-
plan = PolicySettingsPlan(
843-
changes=[PolicySettingsChange("default_rule_action", "allow", "deny(403)")]
844-
)
845-
lines = fmt.format_plan([plan], "my-policy")
846-
assert len(lines) == 1
847-
assert "my-policy" in lines[0]
848-
assert "allow" in lines[0]
849-
assert "deny(403)" in lines[0]
850-
851-
def test_count_changes(self):
852-
fmt = PolicySettingsFormatter()
853-
plan = PolicySettingsPlan(
854-
changes=[
855-
PolicySettingsChange("default_rule_action", "allow", "deny(403)"),
856-
PolicySettingsChange(
857-
"ddos_protection_config",
858-
{"ddos_protection": "STANDARD"},
859-
{"ddos_protection": "STANDARD"},
860-
), # no change
861-
PolicySettingsChange(
862-
"advanced_options_config",
863-
{"json_parsing": "DISABLED"},
864-
{"json_parsing": "STANDARD"},
865-
),
866-
]
867-
)
868-
assert fmt.count_changes([plan]) == 2
869-
870-
def test_empty(self):
871-
fmt = PolicySettingsFormatter()
872-
assert fmt.format_plan([], "z") == []
873-
assert fmt.count_changes([]) == 0
874-
875-
876837
# ---------------------------------------------------------------------------
877838
# Provider methods
878839
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)