From 7ce58ae638b89ef07340a4dd72c80ec34fc35c78 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 15:36:37 +0200 Subject: [PATCH] feat: adopt security zones and explicit workload refs Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0291a-1e87-7151-9934-fcbfe3f65eb1 --- SCOPE.md | 37 +- WORK-RECORDS.md | 11 +- .../ADR-0006-enforcement-is-zone-scoped.md | 6 +- ...0009-adopt-security-zones-as-a-consumer.md | 101 ++++ docs/adr/README.md | 3 +- .../security-zone-admission-2026-08-22.md | 34 ++ examples/inventory.seed.yaml | 14 +- examples/warden.production.example.yaml | 18 +- .../production_registry_snapshot.json | 31 +- registry/generated/high-risk-data-paths.yaml | 4 +- registry/routing/catalog.yaml | 91 ++++ scripts/build_flex_auth_registry.py | 89 +++- scripts/check_agent_read_boundary.py | 1 - scripts/check_policy_caller_identity.py | 20 +- scripts/emit_high_risk_paths.py | 9 +- scripts/policy_gate_production_smoke.sh | 8 +- scripts/report_workload_join.py | 234 +++++----- src/warden/access.py | 6 +- src/warden/audit.py | 5 +- src/warden/ca.py | 9 + src/warden/caller_identity.py | 2 +- src/warden/cli.py | 46 +- src/warden/config.py | 52 ++- src/warden/models.py | 3 + src/warden/policy.py | 160 +++++-- src/warden/routing/__init__.py | 3 +- src/warden/routing/catalog.py | 88 +++- src/warden/routing/models.py | 71 +++ tenancy.yaml | 37 +- tests/test_config.py | 28 +- tests/test_desk.py | 3 - tests/test_flex_auth_registry.py | 51 +- tests/test_memory.py | 4 +- tests/test_policy.py | 72 ++- tests/test_proxy.py | 37 +- tests/test_routing.py | 91 +++- tests/test_taint.py | 4 - tests/test_workload_join.py | 52 +++ wiki/NetKingdomSecurityMap.md | 9 +- wiki/OperatorAccessAssist.md | 2 +- wiki/OpsWardenConfig.md | 51 +- wiki/PolicyGatedSigning.md | 441 ++++++------------ wiki/WorkloadSecurityPosture.md | 22 + wiki/playbooks/agent-read-boundary.md | 6 +- wiki/playbooks/binky-company-email-imap.md | 4 +- wiki/playbooks/binky-qonto-api.md | 2 +- wiki/playbooks/ops-bridge-tunnel-cert.md | 9 +- .../railiance-backup-offsite-lane.md | 6 +- .../reuse-surface-hub-write-token.md | 4 +- wiki/playbooks/tenant-secret-onboarding.md | 2 +- wiki/playbooks/whynot-design-npm-publish.md | 7 +- workplans/WARDEN-WP-0032-security-zones.md | 105 ++++- 52 files changed, 1547 insertions(+), 658 deletions(-) create mode 100644 docs/adr/ADR-0009-adopt-security-zones-as-a-consumer.md create mode 100644 docs/evidence/security-zone-admission-2026-08-22.md mode change 100755 => 100644 scripts/report_workload_join.py create mode 100644 tests/test_workload_join.py diff --git a/SCOPE.md b/SCOPE.md index 3581a9e..4c3490c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -17,7 +17,7 @@ access guidance aligned with NetKingdom canon. --- -## Where we are (2026-08-11) +## Where we are (2026-08-22) ops-warden **issues short-lived SSH certificates and routes every other credential need to the subsystem that owns it.** SSH signing is **production-verified** on @@ -48,14 +48,13 @@ the read-only conformance checker `scripts/check_secret_posture_conformance.py` and the dev-tier contract-double library `warden.doubles` (T4). Canon landing in net-kingdom / info-tech-canon is owner-driven (tracked via coordination messages, T5). -**Policy gate** is shipped on the caller side (WP-0007) with production registry -and smoke evidence (WP-0009 archived). flex-auth published the `ssh-certificate` -policy package (FLEX-WP-0006) and now runs the in-cluster pin -`flex-auth-ops-warden` (FLEX-WP-0007 finished; FLEX-WP-0016). `policy.enabled` -remains **false**: that pin is in `callerAuth.mode: warn` because ops-warden sent -no caller identity. WP-0031 ships the calling side (`policy.caller_auth` + -`scripts/check_policy_caller_identity.py`); the flip waits on an operator picking -a token source and flex-auth then enforcing. +**The policy gate is zone-aware.** The caller-identity path is production proven +and the flex-auth pin enforces caller authentication. WP-0032 adopted +`security-zones_v0.1`: the repo-wide `policy.enabled` and `policy.fail_closed` +settings are retired, target workload membership compiles into flex-auth resource +attributes, and ops-warden selects dependency failure behavior from the target +zone. Unknown membership is explicit and uses the versioned build profile. +Ops-warden itself declares `z1-operational` in `tenancy.yaml`. **ops-bridge cert_command pilot** is shipped to pilot-ready (WP-0016): a read-only readiness gate (`scripts/check_tunnel_cert_readiness.py`) plus an opt-in offline @@ -82,7 +81,7 @@ for warden-sign) but is used by 2 of 24 lanes. See **INTENT alignment:** SSH issuance mission met in production. All ops-warden workplans through WP-0029 are finished except WP-0027 (`backlog`) and WP-0030 (`proposed`). Remaining distance is in other repos' lanes: ops-bridge running the cert_command pilot -cutover, flex-auth runtime deployment (FLEX-WP-0007, unblocks `policy.enabled: true`), +cutover, flex-auth publishing the zone-aware pre-sign stance package, the owner-driven WP-0015 canon landing, and — newly named — the missing owner front doors that keep ops-warden holding interim lanes (secrets-engine, tenant-engine). @@ -153,9 +152,10 @@ superseding ADR, never an in-place edit. | `ADR-0003` | Cover gaps, but never silently own them | | `ADR-0004` | High-risk lanes refuse raw value streaming to agent sessions | | `ADR-0005` | Implement one lane narrowly, route everything else | -| `ADR-0006` | Enforcement is zone-scoped, never a global flag (`policy.enabled` stays off until WP-0032) | +| `ADR-0006` | Superseded: enforcement is zone-scoped, never a global flag | | `ADR-0007` | Build-stage permissiveness stops at credential disclosure; every lane carries an explicit `risk` grade | | `ADR-0008` | A lane's risk grade covers every field its path discloses, not just the field it is named after | +| `ADR-0009` | Adopt security-zones v0.1 and compile explicit workload membership; PEP failure mode is per zone | Rules we follow but do not own — NetKingdom canon, the IAM profile, the credential-management standard, the-custodian's ADR-001 workplan convention — are @@ -188,7 +188,8 @@ for the rest. - `cert_command`: `warden sign --pubkey ` → cert on stdout - TTL enforcement per `ActorType` (`adm` 48 h, `agt` 24 h, `atm` 8 h) - `warden status`, cleanup, scorecard, signatures log -- Opt-in flex-auth policy gate (`policy.enabled`, `policy_decision_id` in log) +- Zone-aware flex-auth policy gate (`policy_decision_id`, zone, failure mode, and + outcome in the signing audit; no repo-wide enable switch) - Production flex-auth registry builder (`scripts/build_flex_auth_registry.py`, `registry/flex-auth/production_registry_snapshot.json`) - Policy gate smoke runner (`scripts/policy_gate_production_smoke.sh`) @@ -247,7 +248,7 @@ for the rest. | --- | --- | | WP-0001–0005 | Initial CLI, quality, hygiene, OpenBao docs, hub sync | | WP-0006 | Credential routing, security map, inventory patterns, OpenBao checklist | -| WP-0007 | Opt-in flex-auth policy gate (`policy.enabled`) | +| WP-0007 | Original opt-in flex-auth policy gate (global switch retired by WP-0032) | | WP-0008 | Production sign verification, stewardship closeout, archive hygiene | | WP-0009 | flex-auth registry + policy smoke; pickup brief for FLEX-WP-0007 | | WP-0010 | Access routing charter + pointer catalog | @@ -280,7 +281,7 @@ for the rest. | WP | Status | Focus | | --- | --- | --- | | WP-0027 | `backlog` | Tamper-resistant credential governance + mass rotation/lockdown (Strand B follow-on to WP-0026) | -| WP-0032 | `proposed` | Adopt security zones as a consumer — retire the global `policy.enabled` (model owned by `zone-engine`) | +| WP-0032 | `finished` | Security zones adopted — global switch retired, explicit workload references compiled, and owner policy live | | WP-0030 | `proposed` | Delegation register — record intended owner + blocker on every interim lane, `warden route gaps`, promotion gate | Remaining production distance is also in other repos' lanes (see Known gaps). @@ -356,10 +357,10 @@ Remaining production distance is also in other repos' lanes (see Known gaps). - **Policy gate:** caller shipped (WP-0007); registry + smoke complete (WP-0009 archived). WP-0031 shipped the calling identity and flex-auth's pin now runs `callerAuth.mode: enforce` (FLEX-WP-0016) — the gate is **ready and verified** - (`decision:f3f7c88f9585582a`, anonymous `/v1/check` -> 401). `policy.enabled` - remains **false by decision**, not by blocker: `ADR-0006` scopes enforcement to - security zones, which `zone-engine` defines (`ZONE-WP-0001`). Re-check with - `scripts/check_policy_caller_identity.py` + (`decision:f3f7c88f9585582a`, anonymous `/v1/check` -> 401). WP-0032 and + `ADR-0009` retired the global switch: the compiled target workload selects the + zone, flex-auth owns stance, and ops-warden applies the zone's PEP failure mode. + Re-check caller identity with `scripts/check_policy_caller_identity.py`. - **Workload posture:** WP-0015 shipped (standard, descriptors, `warden policy`, conformance checker, dev doubles); canon landing owner-driven - **ops-bridge cert_command:** WP-0016 shipped to pilot-ready (readiness gate + diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index d07dfbb..a17e710 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -28,7 +28,7 @@ | workplan | WARDEN-WP-0029 | finished | — | workplans/WARDEN-WP-0029-policy-front-door-and-founder-surface.md | | workplan | WARDEN-WP-0030 | finished | — | workplans/WARDEN-WP-0030-delegation-register.md | | workplan | WARDEN-WP-0031 | finished | — | workplans/WARDEN-WP-0031-policy-caller-identity.md | -| workplan | WARDEN-WP-0032 | proposed | — | workplans/WARDEN-WP-0032-security-zones.md | +| workplan | WARDEN-WP-0032 | finished | — | workplans/WARDEN-WP-0032-security-zones.md | | workplan | WARDEN-WP-0033 | active | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | | task | ADHOC-2026-06-27-T01 | done | — | workplans/ADHOC-2026-06-27.md | | task | ADHOC-2026-06-29-T01 | done | — | workplans/ADHOC-2026-06-29.md | @@ -117,11 +117,12 @@ | task | WARDEN-WP-0031-T04 | done | — | workplans/WARDEN-WP-0031-policy-caller-identity.md | | task | WARDEN-WP-0031-T05 | cancel | — | workplans/WARDEN-WP-0031-policy-caller-identity.md | | task | WARDEN-WP-0032-T01 | done | — | workplans/WARDEN-WP-0032-security-zones.md | -| task | WARDEN-WP-0032-T02 | wait | — | workplans/WARDEN-WP-0032-security-zones.md | -| task | WARDEN-WP-0032-T03 | wait | — | workplans/WARDEN-WP-0032-security-zones.md | -| task | WARDEN-WP-0032-T04 | wait | — | workplans/WARDEN-WP-0032-security-zones.md | +| task | WARDEN-WP-0032-T02 | progress | — | workplans/WARDEN-WP-0032-security-zones.md | +| task | WARDEN-WP-0032-T03 | done | — | workplans/WARDEN-WP-0032-security-zones.md | +| task | WARDEN-WP-0032-T04 | done | — | workplans/WARDEN-WP-0032-security-zones.md | | task | WARDEN-WP-0032-T05 | done | — | workplans/WARDEN-WP-0032-security-zones.md | -| task | WARDEN-WP-0032-T06 | progress | — | workplans/WARDEN-WP-0032-security-zones.md | +| task | WARDEN-WP-0032-T06 | done | — | workplans/WARDEN-WP-0032-security-zones.md | +| task | WARDEN-WP-0032-T07 | done | — | workplans/WARDEN-WP-0032-security-zones.md | | task | WARDEN-WP-0033-T01 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | | task | WARDEN-WP-0033-T02 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | | task | WARDEN-WP-0033-T03 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | diff --git a/docs/adr/ADR-0006-enforcement-is-zone-scoped.md b/docs/adr/ADR-0006-enforcement-is-zone-scoped.md index 9a8a031..e4228ea 100644 --- a/docs/adr/ADR-0006-enforcement-is-zone-scoped.md +++ b/docs/adr/ADR-0006-enforcement-is-zone-scoped.md @@ -4,18 +4,18 @@ type: adr title: "ADR-0006 — Enforcement is zone-scoped, never a global flag" domain: infotech repo: ops-warden -status: accepted +status: superseded version: "1.0" revision: "1" owner: ops-warden binds: "ops-warden" created: "2026-08-19" -updated: "2026-08-19" +updated: "2026-08-22" last_reviewed: "2026-08-19" review_interval: 6m enforced_by: "warden.yaml policy.enabled; scripts/check_policy_caller_identity.py; zone-engine ZONE-WP-0001; WARDEN-WP-0032" supersedes: "" -successor: "" +successor: "ops-warden-adr-0009" --- # ADR-0006 — Enforcement is zone-scoped, never a global flag diff --git a/docs/adr/ADR-0009-adopt-security-zones-as-a-consumer.md b/docs/adr/ADR-0009-adopt-security-zones-as-a-consumer.md new file mode 100644 index 0000000..461d756 --- /dev/null +++ b/docs/adr/ADR-0009-adopt-security-zones-as-a-consumer.md @@ -0,0 +1,101 @@ +--- +id: ops-warden-adr-0009 +type: adr +title: "ADR-0009 — Adopt security-zones v0.1 as a consumer" +domain: infotech +repo: ops-warden +status: accepted +version: "1.0" +revision: "1" +owner: ops-warden +binds: "ops-warden" +created: "2026-08-22" +updated: "2026-08-22" +last_reviewed: "2026-08-22" +review_interval: 3m +enforced_by: "tenancy.yaml; registry/routing/catalog.yaml workload_ref; scripts/build_flex_auth_registry.py; src/warden/policy.py; src/warden/config.py" +supersedes: "ops-warden-adr-0006" +successor: "" +--- + +# ADR-0009 — Adopt security-zones v0.1 as a consumer + +## Status + +Accepted 2026-08-22 after zone-engine completed `ZONE-WP-0001-T03/T05` and +published the declaration, compilation, stance, and failure-mode contract in +canon revision `337484a`; zone-engine's reference compiler is revision +`9b6ada7`. + +## Context + +ADR-0006 rejected a repo-wide `policy.enabled` switch because one boolean plus +one `fail_closed` value made flex-auth a uniform dependency of every signing +path, including continuity paths needed to repair that dependency. It deferred +the replacement to zone-engine rather than designing an estate model here. + +The owning model now exists. A zone is an evidenced workload-admission fact; +control stance remains with the control owner, and dependency failure behavior +remains with the PEP. Membership resolves only through an authoritative +workload identity. Missing identity, membership, admission evidence, or a +required floor is `unknown`, never an inferred permissive zone. + +## Decision + +Ops-warden adopts `security-zones_v0.1` and accepts its initial build-stage rows +for the controls ops-warden owns: + +- the pre-sign PEP fails open for `z0-experimental`, `z1-operational`, + `z2-protected`, `z2-continuity`, and build-profile `unknown`; it fails closed + for `z3-critical`; +- the agent high-risk read boundary remains enforced and fail-closed in every + zone and for `unknown`; +- `warden plan` never derives `autonomous` authority from unknown zone evidence. + +The implementation follows four rules: + +1. `policy.enabled` and the global `policy.fail_closed` setting are retired and + rejected by configuration loading. The PEP chooses failure behavior from a + total per-zone map. +2. The existing compiled flex-auth registry is the resource-membership carrier. + Actor resources receive `workload_id`, `security_zone`, + `security_zone_admission`, and `security_zone_revision`. The dormant + `trust_zone: platform` constant is removed; it is not repurposed. +3. Workload joins are explicit. Managed deployables use Repo Manager's exact + `(rapp_id, workload_identity.name, deployable?)` tuple. Independent + operational workloads use their owner-reviewed `tenancy.yaml`. Catalog + owners distinguish `not-applicable` from applicable-but-`unknown`; no path or + repository-name inference is allowed. +4. A fail-open signing result is metadata, not silence. Signature and unified + audit records carry the selected zone, failure mode, outcome, and decision id + when one exists. + +Ops-warden itself declares `z1-operational`. That is an accuracy decision: the +workload has M1 evidence and does not yet have the SLO history, on-call rotation, +or exercised recovery evidence needed for z2 admission. + +## Consequences + +The global flip and its failure cycle no longer exist. An unknown target remains +observable and follows the versioned build profile without manufacturing +membership. A future organization-posture graduation changes the versioned +control profile, not each workload declaration. + +The flex-auth policy package still owns pre-sign stance. Ops-warden can compile +and send the membership attributes, handle `allow`/`audit_only`/deny, and apply +the correct PEP failure mode; it does not write flex-auth's Rego rows. + +Catalog coverage is intentionally honest at adoption: exact references resolve +where authoritative declarations exist, applicable lanes without one report +`unknown` with a reason, and generic actions/patterns are explicitly +`not-applicable`. Resolution coverage improves by adding owner declarations, +never by adding heuristics here. + +## Related + +- `security-zones_v0.1` (net-kingdom canon revision `337484a`; zone-engine + compiler revision `9b6ada7`) +- Repo Manager `helixforge.workloads.ops-warden-reference.v1` revision `890f3b0` +- NetKingdom tenancy-posture Decisions 5.6.1/5.6.2 +- `WARDEN-WP-0032` +- `ADR-0004`, `ADR-0007`, and `ADR-0008` diff --git a/docs/adr/README.md b/docs/adr/README.md index f66f115..9de6ae1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -67,6 +67,7 @@ and the publication is a defect. | `ADR-0003` | Cover gaps, but never silently own them | ops-warden | | `ADR-0004` | High-risk lanes refuse raw value streaming to agent sessions | ops-warden, and any agent runtime calling `warden access` | | `ADR-0005` | Implement one lane narrowly, route everything else | ops-warden | -| `ADR-0006` | Enforcement is zone-scoped, never a global flag | ops-warden | +| `ADR-0006` | Enforcement is zone-scoped, never a global flag (**superseded by ADR-0009**) | ops-warden | | `ADR-0007` | Build-stage permissiveness stops at credential disclosure | ops-warden | | `ADR-0008` | A lane's risk grade covers every field its path discloses | ops-warden | +| `ADR-0009` | Adopt security-zones v0.1; compile explicit membership and select PEP failure mode per zone | ops-warden | diff --git a/docs/evidence/security-zone-admission-2026-08-22.md b/docs/evidence/security-zone-admission-2026-08-22.md new file mode 100644 index 0000000..44ce9e3 --- /dev/null +++ b/docs/evidence/security-zone-admission-2026-08-22.md @@ -0,0 +1,34 @@ +# Ops-warden security-zone admission evidence — 2026-08-22 + +This record supports the `z1-operational` membership declared in +`tenancy.yaml`. It does not claim the M2 gates that ops-warden has not met. + +## Identity and scope + +- Workload id: `ops-warden`. +- Runtime binding: Kubernetes ServiceAccount + `system:serviceaccount:ops-warden:ops-warden`, issued by railiance01 and + verified against the enforcing flex-auth pin on 2026-08-19. +- Responsible party: `team:platform-security` in this repository. +- Scope: attended issuance of short-lived SSH certificates plus a pointer-only + credential catalog. Secret values are not stored in the catalog or audit. + +## M1 evidence + +- Owned front door: `warden sign` is the sole certificate-issuance interface; + actor inventory, principal allow-list, and TTL ceilings are enforced before + the CA backend. +- Basic service objective: production signing is bounded by the actor TTL + policy (`adm` 48h, `agt` 24h, `atm` 8h); `warden status` and the production + verification records expose backend readiness. +- Data handling: `ADR-0002` makes ops-warden a transparent conduit and + `ADR-0004`/`ADR-0007` prevent raw agent reads and fail safe on ungraded lanes. +- Policy path: `history/2026-08-19-flex-auth-caller-identity-evidence.md` proves + the authenticated caller path and anonymous rejection. + +## Why not z2 + +Ops-warden has security review artifacts, but not the complete M2 promotion +set: there is no SLO history, on-call rotation, or exercised signing-path +incident/recovery runbook. Its tenancy posture therefore remains V0 and its +accurate zone membership remains `z1-operational`. diff --git a/examples/inventory.seed.yaml b/examples/inventory.seed.yaml index 3fd272c..49ce69f 100644 --- a/examples/inventory.seed.yaml +++ b/examples/inventory.seed.yaml @@ -10,6 +10,9 @@ actors: - agt-task-bridge ttl_hours: 24 description: "ops-bridge tunnel agent for state-hub" + zone_subject: + applicability: applicable + workload_id: ops-bridge-tunnel agt-codex-interhub-bootstrap: type: agt @@ -17,6 +20,9 @@ actors: - agt-interhub-bootstrap ttl_hours: 2 description: "Short-lived agent access for attended Inter-Hub bootstrap" + zone_subject: + applicability: applicable + workload_id: codex-interhub-bootstrap adm-example: type: adm @@ -24,6 +30,9 @@ actors: - adm-full ttl_hours: 48 description: "Example human operator — replace with per-person adm-* actors" + zone_subject: + applicability: not-applicable + reason: human operator retains native actor identity atm-backup-daily: type: atm @@ -31,6 +40,9 @@ actors: - atm-backup-daily ttl_hours: 8 description: "Example nightly automation actor" + zone_subject: + applicability: applicable + workload_id: backup-daily hosts: example-host: @@ -38,4 +50,4 @@ hosts: agt: - agt-task-bridge atm: - - atm-backup-daily \ No newline at end of file + - atm-backup-daily diff --git a/examples/warden.production.example.yaml b/examples/warden.production.example.yaml index c981def..91e7aa7 100644 --- a/examples/warden.production.example.yaml +++ b/examples/warden.production.example.yaml @@ -15,18 +15,24 @@ vault: inventory_path: ~/.config/warden/inventory.yaml state_dir: ~/.local/state/warden -# Opt-in flex-auth gate — enable only when flex-auth is reachable at flex_auth_url. +# Zone-aware flex-auth gate. Missing target membership is the explicit unknown +# profile; there is no repo-wide enable switch. # Registry: registry/flex-auth/production_registry_snapshot.json (build from inventory). # See wiki/PolicyGatedSigning.md (operator checklist) and wiki/playbooks/operator-openbao-token-hygiene.md policy: - # Keep false until the flex-auth ops-warden pin runs callerAuth.mode: enforce. - # Gate: python scripts/check_policy_caller_identity.py (WARDEN-WP-0031) - enabled: false # The in-cluster pin for ops-warden's signing policy (FLEX-WP-0016). A bare # flex-auth.flex-auth.svc Service does not exist. From a workstation, reach it # through a port-forward or tunnel and point this at that local address. flex_auth_url: http://flex-auth-ops-warden.flex-auth.svc.cluster.local:8080 - fail_closed: true + zone_registry_path: registry/flex-auth/production_registry_snapshot.json + failure_modes: + z0-experimental: fail_open + z1-operational: fail_open + z2-protected: fail_open + z2-continuity: fail_open + z3-critical: fail_closed + unknown: fail_open + not-applicable: fail_closed tenant: tenant:platform subject_env: WARDEN_POLICY_SUBJECT system: ops-warden @@ -44,4 +50,4 @@ policy: # command: kubectl create token ops-warden -n ops-warden # --audience flex-auth --duration 10m token_env: WARDEN_POLICY_CALLER_TOKEN - audience: flex-auth \ No newline at end of file + audience: flex-auth diff --git a/registry/flex-auth/production_registry_snapshot.json b/registry/flex-auth/production_registry_snapshot.json index 3110228..cba4291 100644 --- a/registry/flex-auth/production_registry_snapshot.json +++ b/registry/flex-auth/production_registry_snapshot.json @@ -48,8 +48,8 @@ ], "metadata": { "flex_auth_contract": "protected-system-v0", - "ops_warden_policy_gate": "v2", - "policy_enabled_config": "policy.enabled", + "ops_warden_policy_gate": "security-zones-v0.1", + "security_zone_standard": "security-zones_v0.1", "tenant": "tenant:platform" } } @@ -66,7 +66,6 @@ "ssh-signing", "adm" ], - "trust_zone": "platform", "owner": "team:platform-security", "attributes": { "actor_id": "adm-example", @@ -78,7 +77,10 @@ "allowed_principals": [ "adm-full" ], - "max_ttl_hours": 48 + "max_ttl_hours": 48, + "security_zone": "unknown", + "security_zone_admission": "not-applicable", + "security_zone_reason": "human operator retains native actor identity" } }, { @@ -88,7 +90,6 @@ "ssh-signing", "agt" ], - "trust_zone": "platform", "owner": "team:platform-security", "attributes": { "actor_id": "agt-codex-interhub-bootstrap", @@ -100,7 +101,11 @@ "allowed_principals": [ "agt-interhub-bootstrap" ], - "max_ttl_hours": 2 + "max_ttl_hours": 2, + "workload_id": "codex-interhub-bootstrap", + "security_zone": "unknown", + "security_zone_admission": "unknown", + "security_zone_reason": "workload_resolution_absent" } }, { @@ -110,7 +115,6 @@ "ssh-signing", "agt" ], - "trust_zone": "platform", "owner": "team:platform-security", "attributes": { "actor_id": "agt-state-hub-bridge", @@ -122,7 +126,11 @@ "allowed_principals": [ "agt-task-bridge" ], - "max_ttl_hours": 24 + "max_ttl_hours": 24, + "workload_id": "ops-bridge-tunnel", + "security_zone": "unknown", + "security_zone_admission": "unknown", + "security_zone_reason": "workload_resolution_absent" } }, { @@ -132,7 +140,6 @@ "ssh-signing", "atm" ], - "trust_zone": "platform", "owner": "team:platform-security", "attributes": { "actor_id": "atm-backup-daily", @@ -144,7 +151,11 @@ "allowed_principals": [ "atm-backup-daily" ], - "max_ttl_hours": 8 + "max_ttl_hours": 8, + "workload_id": "backup-daily", + "security_zone": "unknown", + "security_zone_admission": "unknown", + "security_zone_reason": "workload_resolution_absent" } } ], diff --git a/registry/generated/high-risk-data-paths.yaml b/registry/generated/high-risk-data-paths.yaml index d6f5808..fff497a 100644 --- a/registry/generated/high-risk-data-paths.yaml +++ b/registry/generated/high-risk-data-paths.yaml @@ -10,11 +10,11 @@ # declares it, and is null where the field set has not been established -- # null means unknown, never 'one field'. -generated_at: "2026-08-21T11:28:33Z" +generated_at: "2026-08-22T12:59:27Z" source: ops-warden/registry/routing/catalog.yaml catalog_revision: "55f0f47a021375b8b25c924953d1b49a24e002c5" catalog_revision_date: "2026-08-21T13:26:10+02:00" -catalog_dirty: false +catalog_dirty: true high_risk_lane_count: 19 concrete_path_count: 14 diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 8f189cc..409c2e7 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -26,6 +26,11 @@ # canon_ref upstream net-kingdom doc the wiki section tracks # reviewed date this pointer was last checked against canon (YYYY-MM-DD) # status active (surfaced by default) | draft (hidden unless --all) +# workload_ref explicit workload applicability and authoritative join. +# Managed deployables use rapp_id + name (+ optional deployable); +# operational workloads use name + declaration_ref; unresolved +# applicable lanes carry unknown_reason; native non-workload +# subjects carry not-applicable + reason. Never infer from paths. # steps ONLY when warden_executes: true # cert_command ONLY when warden_executes: true # delegation WP-0030 register. mode: native | interim | permanent. @@ -39,6 +44,9 @@ entries: title: Short-lived SSH certificate for host / ops reachability # Emits a signed certificate — a public artifact. The private key never leaves the caller (WARDEN-WP-0032-T05). risk: standard + workload_ref: + applicability: not-applicable + reason: "Generic certificate-signing lane; each actor resource must resolve its own target workload." need_keywords: [ssh, certificate, cert, host, access, sign, adm, agt, atm, reachability, ops] owner_repo: ops-warden subsystem: ops-warden @@ -61,6 +69,10 @@ entries: title: Scoped OpenBao token for ops-warden SSH signing (warden-sign) # A scoped VAULT_TOKEN is a credential in its own right. Graded on what the value is, not on whether ops-warden currently proxies it (WARDEN-WP-0032-T05). risk: high + workload_ref: + applicability: applicable + name: ops-warden + declaration_ref: tenancy.yaml need_keywords: [vault_token, vault, token, warden-sign, warden, ops-warden, signing, sign, smoke, flex-auth, credential, broker, lease, openbao, ssh, production] owner_repo: railiance-platform subsystem: OpenBao credential broker @@ -97,6 +109,9 @@ entries: title: API key, DB credential, or dynamic lease # Wildcard lane over platform/workloads///: its ceiling is the most dangerous bundle it can resolve to (WARDEN-WP-0032-T05). risk: high + workload_ref: + applicability: not-applicable + reason: "Generic credential-path pattern; concrete lanes carry the workload reference." need_keywords: [api, key, secret, database, db, password, token, lease, openbao, vault, kv, dynamic, credential, npm, npm_auth_token, registry] owner_repo: railiance-platform subsystem: OpenBao @@ -133,6 +148,9 @@ entries: title: whynot-design npm publish token (@whynot/design → coulomb Gitea registry) # Publish rights to the package registry — a leaked token is a supply-chain write, not a read (WARDEN-WP-0032-T05). risk: high + workload_ref: + applicability: applicable + unknown_reason: "whynot-design has not published an authoritative workload identity declaration." need_keywords: [whynot-design, whynot, npm, publish, npm_auth_token, gitea, registry, coulomb, package] owner_repo: railiance-platform subsystem: OpenBao @@ -177,6 +195,9 @@ entries: title: Authorization decision — may this actor perform this action # Returns an authorization decision; no credential flows (WARDEN-WP-0032-T05). risk: standard + workload_ref: + applicability: not-applicable + reason: "Generic authorization action; the governed resource supplies workload identity." need_keywords: [authorization, policy, permission, allow, deny, may, flex-auth, topaz, pdp, decision] owner_repo: flex-auth subsystem: flex-auth @@ -195,6 +216,9 @@ entries: title: Interactive login, OIDC token, or MFA # Interactive browser OIDC: a login flow, not a KV read. No stored value is fetched, and warden access already excludes is_login from raw-value streaming (WARDEN-WP-0032-T05). risk: standard + workload_ref: + applicability: not-applicable + reason: "Interactive login action; caller identity is native context, not a workload target." need_keywords: [login, oidc, identity, mfa, token, jwt, sso, keycloak, key-cape, iam, claims, authenticate, signin] owner_repo: key-cape subsystem: key-cape / Keycloak @@ -222,6 +246,9 @@ entries: title: SSH tunnel or port forward # Routes to ops-bridge and supplies a cert_command; no secret value flows (WARDEN-WP-0032-T05). risk: standard + workload_ref: + applicability: applicable + unknown_reason: "ops-bridge has not published the operational tunnel workload declaration." need_keywords: [tunnel, port, forward, bridge, ops-bridge, reverse, transport, ssh-tunnel, cert_command] owner_repo: ops-bridge subsystem: ops-bridge @@ -240,6 +267,9 @@ entries: title: Host SSH principal file or force-command deployment # Principal-file deployment via Ansible; no secret value flows (WARDEN-WP-0032-T05). risk: standard + workload_ref: + applicability: not-applicable + reason: "Host principal-file deployment is an infrastructure action, not a workload." need_keywords: [principal, auth_principals, force-command, host, sshd, hardening, railiance-infra, ansible] owner_repo: railiance-infra subsystem: railiance-infra @@ -258,6 +288,9 @@ entries: title: Inter-Hub bootstrap SSH envelope # Graded high conservatively: ops-warden could not establish from the lane definition that no key material moves in the envelope. Regrade with evidence, do not assume down (WARDEN-WP-0032-T05). risk: high + workload_ref: + applicability: applicable + unknown_reason: "The inter-hub bootstrap execution unit has no authoritative workload declaration." need_keywords: [inter-hub, interhub, bootstrap, ops-hub, agt-interhub-bootstrap, envelope, force-command, CUST-WP-0049] owner_repo: ops-warden subsystem: ops-warden + railiance-infra @@ -276,6 +309,9 @@ entries: title: activity-core IssueSink → issue-core REST emission # Emission routing only — the API key is a separate lane (WARDEN-WP-0032-T05). risk: standard + workload_ref: + applicability: applicable + unknown_reason: "activity-core has not published an authoritative workload identity declaration." need_keywords: [activity-core, issue-sink, issue-core, emission, issue_core_url, issue_core_api_key, tasks, ingest, rest, issuesink] owner_repo: activity-core subsystem: activity-core + issue-core @@ -301,6 +337,11 @@ entries: # A Forgejo backend token is not recovered by rotating an ingestion key. # Found by secrets-engine reviewing SECRETS-WP-0006 -- not by us. risk: high + workload_ref: + applicability: applicable + rapp_id: rapp-issue-core + name: issue-core + deployable: issue-core fields: [ISSUE_CORE_API_KEY, GITEA_BACKEND_TOKEN] # CCR-2026-0002 need_keywords: [issue-core, ingestion, api, key, openbao, issue_core_api_key, eso, external-secrets] owner_repo: railiance-platform @@ -349,6 +390,9 @@ entries: # rotation. Disclosure lets an attacker forge webhook deliveries into the # federation hub, which rotating the write token alone does not undo. risk: high + workload_ref: + applicability: applicable + unknown_reason: "reuse-surface has not published an authoritative workload identity declaration." fields: [REUSE_SURFACE_TOKEN, REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET] # CCR-2026-0005 need_keywords: [reuse-surface, reuse_surface, hub, register, federation, write, token, bearer, REUSE_SURFACE_TOKEN, REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET, reuse.coulomb.social] owner_repo: railiance-platform @@ -404,6 +448,9 @@ entries: verified: owner-confirmed # High-risk: provider API key with spend impact + prompt-adjacent (WP-0026 T04). risk: high + workload_ref: + applicability: applicable + unknown_reason: "llm-connect has not published an authoritative workload identity declaration." # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0003 / RAILIANCE-WP-0010 # (promoted 2026-07-02): policy workload-kv-read-llm-connect-provider-secrets and k8s # auth role external-secrets-activity-core applied; ExternalSecret @@ -445,6 +492,9 @@ entries: verified: unverified # High-risk: WebDAV upload token + AGE recovery escrow (WP-0026 T04). risk: high + workload_ref: + applicability: applicable + unknown_reason: "The backup execution unit has no authoritative workload identity declaration." # CCR-2026-0004: policy + OIDC role applied; values provisioned 2026-07-07. # Capabilities-safe re-verify 2026-07-16 (WP-0026 T07): lane-policy token # capabilities=read on data path; default-policy and agent-high-risk-boundary = deny; @@ -491,6 +541,9 @@ entries: verified: owner-confirmed # High-risk: site-admin PAT (WP-0026 T04). risk: high + workload_ref: + applicability: applicable + unknown_reason: "The Forgejo administration workload has no authoritative workload identity declaration." # CCR-2026-0006: approved by platform-operator 2026-07-12; policy # workload-kv-read-forgejo-admin + OIDC role forgejo-admin-workload-kv-read live on # bao.coulomb.social; PAT attended-minted and stored under field API_TOKEN at @@ -533,6 +586,9 @@ entries: reviewed: "2026-08-11" verified: unverified risk: high + workload_ref: + applicability: applicable + unknown_reason: "The Binky email consumer has no authoritative workload identity declaration." # CCR-2026-0007: tenants/ mount + policy + OIDC role applied; founder provisioned # values via UI (version ≥2, not placeholder). Capabilities-safe verify 2026-07-17: # lane-policy read; default deny. Host: imap.ionos.de:993 (binky-control config). @@ -568,6 +624,9 @@ entries: reviewed: "2026-08-11" verified: unverified risk: high + workload_ref: + applicability: applicable + unknown_reason: "The Binky Qonto MCP consumer has no authoritative workload identity declaration." # CCR-2026-0008: policy + OIDC role applied; secret at tenants/binky/qonto-api # (fields API_KEY, API_USER). Map to QONTO_API_KEY / QONTO_ORGANIZATION_ID for # qonto-mcp-server. First read-only pull 2026-07-21 (BINKY-WP-0005-T05). @@ -604,6 +663,11 @@ entries: reviewed: "2026-08-17" verified: unverified risk: high + workload_ref: + applicability: applicable + rapp_id: rapp-qonto + name: qonto + deployable: rapp-qonto auth_method: "OpenBao platform workload lane; KeyCape client_secret_basic exchange" path_template: "platform/workloads/rapp-qonto/keycape-client" fetch_command: "bao kv get -field=client_secret platform/workloads/rapp-qonto/keycape-client" @@ -635,6 +699,9 @@ entries: reviewed: "2026-08-11" verified: unverified risk: high + workload_ref: + applicability: applicable + unknown_reason: "agent-harness has not published an authoritative workload identity declaration." # Provisioned 2026-07-17 on railiance01: ed25519 keypair on host, OpenBao copy at # platform/workloads/agent-harness/forgejo-deploy-key, write deploy key on # coulomb/executor-sandbox (title agent-harness-railiance01). Git push verified. @@ -671,6 +738,9 @@ entries: reviewed: "2026-08-15" verified: unverified risk: high + workload_ref: + applicability: applicable + unknown_reason: "agent-harness has not published an authoritative workload identity declaration." # Provisioned 2026-07-17: role agent-harness-binky-mail bound to existing policy # workload-kv-read-binky-company-email-imap; role_id/secret_id delivered to # railiance01 ~/.local/agent-harness/approle-binky-mail (0600). Positive IMAP field @@ -696,6 +766,9 @@ entries: title: Object-storage STS / temporary S3 credentials # Temporary S3 credentials are still credentials (WARDEN-WP-0032-T05). risk: high + workload_ref: + applicability: not-applicable + reason: "Generic STS credential-vending pattern; concrete consumers carry workload references." need_keywords: [s3, sts, object-storage, minio, artifact-store, temporary, credentials, bucket, vending] owner_repo: net-kingdom subsystem: flex-auth + OpenBao + artifact-store @@ -726,6 +799,9 @@ entries: reviewed: "2026-08-15" verified: unverified risk: high + workload_ref: + applicability: not-applicable + reason: "Generic database credential-vending pattern; concrete consumers carry workload references." exec_capable: false - id: rein-openweights-openrouter-approle @@ -745,6 +821,9 @@ entries: reviewed: "2026-08-15" verified: unverified risk: standard + workload_ref: + applicability: applicable + unknown_reason: "rein-openweights has not published an authoritative workload identity declaration." # Built 2026-07-27 by ops-mason (MASON-WP-0001-T05), approved by Bernd # Worsch 2026-07-27. Policy + AppRole live; reins/ KV v2 mount created # (no existing mount fit without widening scope beyond what was @@ -805,6 +884,9 @@ entries: # railiance-apps; user-engine is consumer-only and claims no lane here. consumers: [user-engine] risk: standard + workload_ref: + applicability: applicable + unknown_reason: "coulomb-social has not published an authoritative workload identity declaration." # K8s assembly is the live handoff today (same pattern as vergage-teilnahme-env). # OpenBao KV platform/workloads/coulomb/coulomb-social/runtime-env is the # future custody home — CCR not yet applied; resolvable via operator script. @@ -827,6 +909,9 @@ entries: title: audit-core sender registry (write and operator-read tokens) # Vends write and operator-read tokens (WARDEN-WP-0032-T05). risk: high + workload_ref: + applicability: applicable + unknown_reason: "audit-core has not published an authoritative workload identity declaration." need_keywords: [audit-core, senders, sender registry, ingest token, AUDIT_CORE_SENDERS] owner_repo: ops-mason subsystem: OpenBao + audit-core @@ -864,6 +949,9 @@ entries: reviewed: "2026-08-21" verified: owner-confirmed risk: high + workload_ref: + applicability: applicable + unknown_reason: "email-connect has not published an authoritative workload identity declaration." # CCR-2026-0010 approved 2026-08-12; applied same day (EMAIL-WP-0004-T03): # policies external-secrets-email-connect + workload-kv-read-email-connect-transactional, # KV platform/workloads/email-connect/transactional v1, ESO token Secret, @@ -906,6 +994,9 @@ entries: reviewed: "2026-08-15" verified: unverified risk: high + workload_ref: + applicability: applicable + unknown_reason: "The attended Scaleway bootstrap execution unit has no authoritative workload identity declaration." # CCR-2026-0011. Values via founder paste-once or local tfvars ingest. # Not the Barman runtime key (platform-pg-backup-s3). auth_method: "caller's own OpenBao token (founder / operator workstation)" diff --git a/scripts/build_flex_auth_registry.py b/scripts/build_flex_auth_registry.py index b1e4ffb..00ae652 100644 --- a/scripts/build_flex_auth_registry.py +++ b/scripts/build_flex_auth_registry.py @@ -55,12 +55,79 @@ def _caring_descriptor(actor_type: str, resource_id: str) -> dict[str, Any]: } -def build_registry(inventory: dict[str, Any]) -> dict[str, Any]: +def _resolved_by_workload(zone_resolutions: dict[str, Any] | None) -> dict[str, Any]: + records = (zone_resolutions or {}).get("records") or [] + resolved: dict[str, Any] = {} + for record in records: + workload_id = str(record.get("workload_id") or "") + if not workload_id: + continue + if workload_id in resolved: + raise ValueError(f"duplicate security-zone resolution for {workload_id!r}") + resolved[workload_id] = record + return resolved + + +def _zone_attributes( + actor: str, + entry: dict[str, Any], + resolutions: dict[str, Any], +) -> dict[str, Any]: + subject = entry.get("zone_subject") + if not isinstance(subject, dict): + return { + "security_zone": "unknown", + "security_zone_admission": "unknown", + "security_zone_reason": "catalog_applicability_absent", + } + applicability = subject.get("applicability") + if applicability == "not-applicable": + reason = str(subject.get("reason") or "").strip() + if not reason: + raise ValueError(f"{actor}.zone_subject.reason is required") + return { + "security_zone": "unknown", + "security_zone_admission": "not-applicable", + "security_zone_reason": reason, + } + if applicability != "applicable": + raise ValueError( + f"{actor}.zone_subject.applicability must be applicable or not-applicable" + ) + workload_id = str(subject.get("workload_id") or "").strip() + if not workload_id: + return { + "security_zone": "unknown", + "security_zone_admission": "unknown", + "security_zone_reason": "workload_reference_absent", + } + record = resolutions.get(workload_id) + if record is None: + return { + "workload_id": workload_id, + "security_zone": "unknown", + "security_zone_admission": "unknown", + "security_zone_reason": "workload_resolution_absent", + } + return { + "workload_id": workload_id, + "security_zone": str(record.get("effective_zone") or "unknown"), + "security_zone_declared": record.get("declared_zone"), + "security_zone_admission": str(record.get("admission") or "unknown"), + "security_zone_reason": str(record.get("admission_reason") or "unknown"), + "security_zone_revision": record.get("membership_revision"), + } + + +def build_registry( + inventory: dict[str, Any], zone_resolutions: dict[str, Any] | None = None +) -> dict[str, Any]: actors: dict[str, Any] = inventory.get("actors") or {} resources: list[dict[str, Any]] = [] subjects: list[dict[str, Any]] = [] groups: dict[str, list[str]] = {gid: [] for gid in GROUP_BY_TYPE.values()} relationships: list[dict[str, Any]] = [] + resolutions = _resolved_by_workload(zone_resolutions) for name, entry in sorted(actors.items()): actor_type = str(entry["type"]) @@ -74,7 +141,6 @@ def build_registry(inventory: dict[str, Any]) -> dict[str, Any]: "id": resource_id, "type": "ssh-certificate", "labels": ["ssh-signing", actor_type], - "trust_zone": "platform", "owner": "team:platform-security", "attributes": { "actor_id": name, @@ -82,6 +148,7 @@ def build_registry(inventory: dict[str, Any]) -> dict[str, Any]: "allowed_subjects": [name, f"iam:{name}"], "allowed_principals": principals, "max_ttl_hours": ttl_hours, + **_zone_attributes(name, entry, resolutions), }, } ) @@ -156,8 +223,8 @@ def build_registry(inventory: dict[str, Any]) -> dict[str, Any]: "caring_profiles": ["caring-0.4.0-rc2"], "metadata": { "flex_auth_contract": "protected-system-v0", - "ops_warden_policy_gate": "v2", - "policy_enabled_config": "policy.enabled", + "ops_warden_policy_gate": "security-zones-v0.1", + "security_zone_standard": "security-zones_v0.1", "tenant": "tenant:platform", }, } @@ -186,14 +253,24 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("inventory", type=Path, help="ops-warden inventory.yaml") parser.add_argument("-o", "--output", type=Path, required=True) + parser.add_argument( + "--zone-resolutions", + type=Path, + help="zone-engine resolved-view JSON; absent references remain unknown", + ) args = parser.parse_args() inventory = yaml.safe_load(args.inventory.read_text()) or {} - registry = build_registry(inventory) + zone_resolutions = ( + json.loads(args.zone_resolutions.read_text()) + if args.zone_resolutions is not None + else None + ) + registry = build_registry(inventory, zone_resolutions) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(registry, indent=2) + "\n") print(f"Wrote {args.output} ({len(registry['subjects'])} actors)") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/check_agent_read_boundary.py b/scripts/check_agent_read_boundary.py index 97e3c40..18bbf01 100755 --- a/scripts/check_agent_read_boundary.py +++ b/scripts/check_agent_read_boundary.py @@ -24,7 +24,6 @@ from __future__ import annotations import argparse import json -import os import re import subprocess import sys diff --git a/scripts/check_policy_caller_identity.py b/scripts/check_policy_caller_identity.py index c0a2c2a..38084c1 100755 --- a/scripts/check_policy_caller_identity.py +++ b/scripts/check_policy_caller_identity.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 -"""Readiness gate for the flex-auth ``policy.enabled`` flip (WARDEN-WP-0031). +"""Readiness gate for the zone-aware flex-auth caller identity. flex-auth deployed ``flex-auth-ops-warden`` (FLEX-WP-0016) in ``callerAuth.mode: warn``: it authenticates the caller with a Kubernetes TokenReview and binds ``resource.system: ops-warden`` to ``system:serviceaccount:ops-warden:ops-warden``, but a caller that sends no ``Authorization`` header only produces a ``caller authentication warning`` and is still served. That pin cannot move to -``enforce`` — and therefore ``policy.enabled: true`` cannot be set — until -ops-warden's calling side actually presents a token. +``enforce`` until ops-warden's calling side actually presents a token. The +former repo-wide ``policy.enabled`` switch is retired by WARDEN-WP-0032. This script asserts the calling side *without* flipping anything: @@ -57,9 +57,7 @@ def run_checks(config_path: Optional[Path], url: Optional[str]) -> List[Check]: return [("fail", "warden.yaml", str(e))] policy = cfg.policy - checks.append( - ("ok", "warden.yaml", f"loaded; policy.enabled={str(policy.enabled).lower()}") - ) + checks.append(("ok", "warden.yaml", "loaded; security-zones_v0.1 profile")) mode = policy.caller_auth.mode if mode == "none": @@ -84,12 +82,12 @@ def run_checks(config_path: Optional[Path], url: Optional[str]) -> List[Check]: ) target = url or policy.flex_auth_url - if url is None and not policy.enabled: + if target is None: checks.append( ( "skip", "live /v1/check", - f"policy.enabled=false; pass --url to smoke {target} anyway", + "policy.flex_auth_url is absent; pass --url to run the live smoke", ) ) return checks @@ -188,13 +186,13 @@ def main() -> int: if failed: print( f"\nNOT READY — {len(failed)} check(s) failed. " - "Do not ask flex-auth to enforce, and do not set policy.enabled: true." + "Do not ask flex-auth to enforce caller authentication." ) return 1 print( "\nREADY — the calling side presents an identity. Next: tell flex-auth to set " - "callerAuth.mode: enforce on flex-auth-ops-warden, re-run this check, then set " - "policy.enabled: true with fail_closed: true." + "callerAuth.mode: enforce on flex-auth-ops-warden and re-run this check. " + "Zone-specific PEP failure modes already replace the retired global switches." ) return 0 diff --git a/scripts/emit_high_risk_paths.py b/scripts/emit_high_risk_paths.py index d8fbe33..f8d533d 100755 --- a/scripts/emit_high_risk_paths.py +++ b/scripts/emit_high_risk_paths.py @@ -136,8 +136,13 @@ def main() -> int: if args.check: current = args.out.read_text() if args.out.exists() else "" # generated_at always differs; compare everything else. - strip = lambda t: "\n".join(l for l in t.splitlines() if not l.startswith("generated_at:")) - if strip(current) != strip(content): + def strip_generated_at(text: str) -> str: + return "\n".join( + line for line in text.splitlines() + if not line.startswith("generated_at:") + ) + + if strip_generated_at(current) != strip_generated_at(content): print(f"STALE: {args.out} does not match the catalog. Re-run without --check.") return 1 print(f"fresh: {args.out} matches the catalog ({count} concrete paths)") diff --git a/scripts/policy_gate_production_smoke.sh b/scripts/policy_gate_production_smoke.sh index 6c96584..68646a1 100755 --- a/scripts/policy_gate_production_smoke.sh +++ b/scripts/policy_gate_production_smoke.sh @@ -66,9 +66,8 @@ ca_key: $SMOKE_DIR/ca_key state_dir: $SMOKE_DIR/state inventory_path: $INVENTORY policy: - enabled: true flex_auth_url: http://$ADDR - fail_closed: true + zone_registry_path: $REGISTRY tenant: tenant:platform system: ops-warden EOF @@ -106,9 +105,8 @@ vault: inventory_path: $INVENTORY state_dir: $SMOKE_DIR/state-vault policy: - enabled: true flex_auth_url: http://$ADDR - fail_closed: true + zone_registry_path: $REGISTRY tenant: tenant:platform system: ops-warden EOF @@ -118,4 +116,4 @@ EOF python3 -c "import json,sys; e=json.loads(sys.argv[1]); assert e.get('backend')=='vault' and e.get('policy_decision_id'); print('vault policy_decision_id:', e['policy_decision_id'])" "$VAULT_LINE" fi -echo "OK — production registry policy gate smoke passed" \ No newline at end of file +echo "OK — production registry policy gate smoke passed" diff --git a/scripts/report_workload_join.py b/scripts/report_workload_join.py old mode 100755 new mode 100644 index 8fce7dd..bc8d9be --- a/scripts/report_workload_join.py +++ b/scripts/report_workload_join.py @@ -1,128 +1,156 @@ #!/usr/bin/env python3 -"""Report the lane -> workload join, for the security-zone model (ZONE-WP-0001-T03). +"""Report explicit lane -> workload resolution for security-zones_v0.1. -A security zone's subject is the **workload**, not the lane — policy is about the -running thing and whoever answers for it. ops-warden's catalog is a credential -surface, so on its own it cannot name that subject. The other side of the join -lives in `rapp-*/declarations/rapp.yaml`, which declares `workload_identity` -along with `data_classification` and `criticality`; ops-warden's -`registry/policy/security-posture.yaml` then maps classification to a minimum -maturity via `dataclass_floor`. +The catalog owner declares whether each lane is workload-applicable. Managed +deployables use the exact Repo Manager v1 ``(rapp_id, name, deployable?)`` +reference. Independently governed operational workloads use ``name`` plus an +owner declaration reference. Unknown and not-applicable are explicit results. -This script computes the join and reports its coverage. It asserts nothing it -cannot derive: a lane whose workload cannot be established is reported as -unmatched rather than guessed, because the unmatched set is the informative -output — those are lanes existing for something that is not a declared workload. - -Read-only. Touches no secret value and no live system. - -Usage: - python scripts/report_workload_join.py [--rapp-root ~] [--json] +This script never parses a credential path, consults ``owner_repo`` as an +identity hint, or substitutes a repository name. It reads declarations only. """ from __future__ import annotations import argparse import json -import os -import sys from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any -_SRC = Path(__file__).resolve().parent.parent / "src" -if _SRC.is_dir() and str(_SRC) not in sys.path: - sys.path.insert(0, str(_SRC)) - -import yaml # noqa: E402 +import yaml -def load_rapp_workloads(root: Path) -> Dict[str, Dict[str, Any]]: - """workload name -> declaration, from every rapp-*/declarations/rapp.yaml.""" - out: Dict[str, Dict[str, Any]] = {} +def load_rapp_workloads(root: Path) -> dict[tuple[str, str], dict[str, Any]]: + """Exact Repo Manager v1 key -> authoritative declaration projection.""" + out: dict[tuple[str, str], dict[str, Any]] = {} for decl in sorted(root.glob("rapp-*/declarations/rapp.yaml")): try: data = yaml.safe_load(decl.read_text()) or {} except yaml.YAMLError: continue identity = data.get("workload_identity") or {} + rapp_id = data.get("rapp_id") name = identity.get("name") - if not name: + if not rapp_id or not name: continue - out[str(name)] = { - "rapp_id": data.get("rapp_id"), + deployables = [] + for member in (data.get("composition") or {}).get("member_repos") or []: + deployables.extend(str(value) for value in member.get("deployables") or []) + out[(str(rapp_id), str(name))] = { + "source": str(decl), + "deployables": sorted(set(deployables)), "data_classification": data.get("data_classification"), "criticality": data.get("criticality"), "readiness_state": data.get("readiness_state"), - "bound_reefs": data.get("bound_reefs"), } return out -def candidate_workloads(path_template: Optional[str]) -> List[str]: - """Workload names a path could plausibly name — never a single guess. - - The convention is `platform/workloads///`, but it - is not applied consistently: `platform/workloads/rapp-qonto/keycape-client` - has one level fewer, so position alone cannot say which segment is the - workload. Both are offered and matched against declarations. - """ - if not path_template or "<" in path_template: - return [] - parts = [p for p in path_template.split("/") if p] - if len(parts) >= 4 and parts[1] == "workloads": - return [parts[3], parts[2]] - if parts and parts[0] == "tenants" and len(parts) >= 3: - return [parts[2], parts[1]] - return [] +def _direct_declaration_path( + declaration_ref: str, *, catalog_path: Path, estate_root: Path +) -> Path: + ref = Path(declaration_ref) + if ref.is_absolute(): + return ref + local = catalog_path.resolve().parents[2] / ref + return local if local.exists() else estate_root / ref -def build(catalog_path: Path, rapp_root: Path) -> Dict[str, Any]: - entries = (yaml.safe_load(catalog_path.read_text()) or {}).get("entries", []) - declared = load_rapp_workloads(rapp_root) - floor = ( - yaml.safe_load( - (catalog_path.parent.parent / "policy" / "security-posture.yaml").read_text() +def _resolve_direct( + ref: dict[str, Any], *, catalog_path: Path, estate_root: Path +) -> tuple[dict[str, Any] | None, str | None]: + source = _direct_declaration_path( + str(ref["declaration_ref"]), catalog_path=catalog_path, estate_root=estate_root + ) + if not source.exists(): + return None, f"declaration not found: {source}" + try: + declaration = yaml.safe_load(source.read_text()) or {} + except yaml.YAMLError as exc: + return None, f"invalid declaration YAML: {exc}" + identity = declaration.get("workload_identity") or {} + if identity.get("name") != ref.get("name"): + return None, ( + f"declared workload_identity.name={identity.get('name')!r}, " + f"expected {ref.get('name')!r}" ) - or {} - ).get("dataclass_floor", {}) + context = (declaration.get("zones") or {}).get("context", {}) + return { + "source": str(source), + "data_classification": context.get("data_classification"), + "criticality": context.get("criticality"), + "maturity": context.get("maturity"), + "declared_zone": (declaration.get("zones") or {}).get("membership"), + }, None - matched, unmatched, no_path = [], [], [] + +def build(catalog_path: Path, estate_root: Path) -> dict[str, Any]: + entries = (yaml.safe_load(catalog_path.read_text()) or {}).get("entries", []) + managed = load_rapp_workloads(estate_root) + posture_path = catalog_path.parent.parent / "policy" / "security-posture.yaml" + floor = (yaml.safe_load(posture_path.read_text()) or {}).get("dataclass_floor", {}) + + resolved: list[dict[str, Any]] = [] + unknown: list[dict[str, Any]] = [] + not_applicable: list[dict[str, Any]] = [] for entry in entries: - lane = entry.get("id") - cands = candidate_workloads(entry.get("path_template")) - if not cands: - no_path.append({"lane": lane, "risk": entry.get("risk")}) + lane = str(entry.get("id")) + ref = entry.get("workload_ref") or {} + applicability = ref.get("applicability") + if applicability == "not-applicable": + not_applicable.append({"lane": lane, "reason": ref.get("reason")}) continue - hit = next((c for c in cands if c in declared), None) - if hit is None: - unmatched.append( - {"lane": lane, "risk": entry.get("risk"), "candidates": cands} + if applicability != "applicable": + unknown.append({"lane": lane, "reason": "applicability missing or invalid"}) + continue + if ref.get("unknown_reason"): + unknown.append({"lane": lane, "reason": ref["unknown_reason"]}) + continue + + projection: dict[str, Any] | None + error: str | None = None + if ref.get("rapp_id"): + key = (str(ref.get("rapp_id")), str(ref.get("name"))) + projection = managed.get(key) + if projection is None: + error = f"Repo Manager reference does not resolve: {key[0]}/{key[1]}" + elif ref.get("deployable") and ref["deployable"] not in projection["deployables"]: + error = f"deployable {ref['deployable']!r} is not declared by {key[0]}/{key[1]}" + else: + projection, error = _resolve_direct( + ref, catalog_path=catalog_path, estate_root=estate_root ) + if error or projection is None: + unknown.append({"lane": lane, "reason": error or "reference unresolved"}) continue - decl = declared[hit] - cls = decl.get("data_classification") - matched.append( + + classification = projection.get("data_classification") + resolved.append( { "lane": lane, - "workload": hit, - "risk": entry.get("risk"), - "data_classification": cls, - "criticality": decl.get("criticality"), - "min_maturity": floor.get(cls), - "unmapped_classification": bool(cls) and cls not in floor, + "workload_ref": ref, + "source": projection.get("source"), + "data_classification": classification, + "criticality": projection.get("criticality"), + "maturity": projection.get("maturity") or floor.get(classification), + "declared_zone": projection.get("declared_zone"), + "unmapped_classification": bool(classification) and classification not in floor, } ) + return { - "declared_workloads": declared, - "matched": matched, - "unmatched": unmatched, - "no_path": no_path, - "dataclass_floor": floor, + "contract": "helixforge.workload-reference/v1", + "resolved": resolved, + "unknown": unknown, + "not_applicable": not_applicable, + "ok": len(resolved) + len(unknown) + len(not_applicable) == len(entries), } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--rapp-root", type=Path, default=Path.home()) + parser.add_argument( + "--estate-root", "--rapp-root", dest="estate_root", type=Path, default=Path.home() + ) parser.add_argument("--json", action="store_true") parser.add_argument( "--catalog", @@ -133,40 +161,24 @@ def main() -> int: / "catalog.yaml", ) args = parser.parse_args() - - report = build(args.catalog, Path(os.path.expanduser(str(args.rapp_root)))) + report = build(args.catalog, args.estate_root) if args.json: print(json.dumps(report, indent=2, sort_keys=True)) - return 0 + return 0 if report["ok"] else 1 - print("lane -> workload join\n") - print(f" declared workloads (rapp-*): {len(report['declared_workloads'])}") - print(f" lanes matched to a workload: {len(report['matched'])}") - print(f" lanes unmatched: {len(report['unmatched'])}") - print(f" lanes with no usable path: {len(report['no_path'])}\n") - - if report["matched"]: - print("MATCHED") - for m in report["matched"]: - flag = " <-- classification unmapped by dataclass_floor" if m[ - "unmapped_classification" - ] else "" - print( - f" {m['lane']:34} -> {str(m['workload']):16} " - f"{str(m['data_classification']):13} crit={str(m['criticality']):9} " - f"min={str(m['min_maturity']):4} risk={m['risk']}{flag}" - ) - if report["unmatched"]: - print("\nUNMATCHED — a lane exists for something no rapp declares") - for u in report["unmatched"]: - print( - f" {u['lane']:34} risk={str(u['risk']):9} " - f"candidates={', '.join(u['candidates'])}" - ) - if report["no_path"]: - print("\nNO USABLE PATH — not a KV lane, or a pattern rather than an address") - print(" " + ", ".join(n["lane"] for n in report["no_path"])) - return 0 + print("explicit lane -> workload resolution\n") + print(f" resolved: {len(report['resolved'])}") + print(f" unknown: {len(report['unknown'])}") + print(f" not-applicable: {len(report['not_applicable'])}\n") + for row in report["resolved"]: + ref = row["workload_ref"] + prefix = f"{ref.get('rapp_id')}/" if ref.get("rapp_id") else "" + print(f"RESOLVED {row['lane']:34} -> {prefix}{ref.get('name')}") + for row in report["unknown"]: + print(f"UNKNOWN {row['lane']:34} {row['reason']}") + for row in report["not_applicable"]: + print(f"NOT-APPLICABLE {row['lane']:34} {row['reason']}") + return 0 if report["ok"] else 1 if __name__ == "__main__": diff --git a/src/warden/access.py b/src/warden/access.py index c7d2777..ad3af27 100644 --- a/src/warden/access.py +++ b/src/warden/access.py @@ -71,6 +71,6 @@ def policy_gate_status() -> str: cfg = load_config() except ConfigError: return "advisory — no warden.yaml (caller identity; gate not enforced)" - if cfg.policy.enabled: - return f"enforced — flex-auth at {cfg.policy.flex_auth_url}" - return "advisory — policy.enabled=false (gate ships with flex-auth deploy)" + if cfg.policy.flex_auth_url: + return f"zone-aware — flex-auth at {cfg.policy.flex_auth_url}" + return "zone-aware — evaluator unconfigured; unknown-zone fail_open applies" diff --git a/src/warden/audit.py b/src/warden/audit.py index ae450a5..ec81685 100644 --- a/src/warden/audit.py +++ b/src/warden/audit.py @@ -9,7 +9,7 @@ import os import re from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Iterable, Optional +from typing import Any, Optional _AUDIT_FILENAME = "audit.jsonl" _MAX_BYTES = 5 * 1024 * 1024 @@ -215,7 +215,6 @@ def collect_activity( since = datetime.now(timezone.utc) - timedelta(days=days) events = read_events(state_dir, since=since, kinds=kinds) if include_legacy: - legacy_kinds = kinds or {"sign", "access", "worker"} if not kinds or "sign" in kinds: events.extend(_legacy_sign_events(state_dir, since)) if not kinds or "access" in kinds: @@ -278,4 +277,4 @@ def fetch_hub_notes(*, days: int = 7, hub_url: Optional[str] = None) -> list[dic "summary": summary, } ) - return notes \ No newline at end of file + return notes diff --git a/src/warden/ca.py b/src/warden/ca.py index 30035ca..68aefd8 100644 --- a/src/warden/ca.py +++ b/src/warden/ca.py @@ -58,6 +58,12 @@ def _append_signature_log( } if spec.policy_decision_id: entry["policy_decision_id"] = spec.policy_decision_id + if spec.policy_zone: + entry["policy_zone"] = spec.policy_zone + if spec.policy_failure_mode: + entry["policy_failure_mode"] = spec.policy_failure_mode + if spec.policy_outcome: + entry["policy_outcome"] = spec.policy_outcome state_dir.mkdir(parents=True, exist_ok=True) with (state_dir / "signatures.log").open("a") as f: f.write(json.dumps(entry) + "\n") @@ -76,6 +82,9 @@ def _append_signature_log( actor_type=spec.actor_type.value, backend=backend, ttl_hours=spec.ttl_hours, + policy_zone=spec.policy_zone, + policy_failure_mode=spec.policy_failure_mode, + policy_outcome=spec.policy_outcome, ) except Exception: pass # audit must not block signing diff --git a/src/warden/caller_identity.py b/src/warden/caller_identity.py index e77c52a..d4b7a98 100644 --- a/src/warden/caller_identity.py +++ b/src/warden/caller_identity.py @@ -5,7 +5,7 @@ before it evaluates the request: `Authorization: Bearer ` is passed to a Kubernetes TokenReview, and `resource.system: ops-warden` is bound to the principal `system:serviceaccount:ops-warden:ops-warden`. Until ops-warden sends that header, the pin logs `caller authentication warning` and can only run in -`warn` mode — which is why `policy.enabled` cannot flip. +`warn` mode — which is why enforcing caller authentication is a separate gate. This module resolves the token at call time and hands it straight to the request. Nothing is cached to disk, logged, or echoed: ops-warden carries the value, it diff --git a/src/warden/cli.py b/src/warden/cli.py index 8a2a5df..cb843bc 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -16,6 +16,7 @@ from warden.config import ConfigError, WardenConfig, load_config from warden.policy import check_sign_policy from warden.inventory import ActorEntry, InventoryError, PrincipalsInventory, load_inventory, save_inventory from warden.models import ActorType, CertSpec, DEFAULT_TTL_HOURS, validate_actor_name +from warden.routing.catalog import blocker_stale_days from warden.scorecard import run_scorecard app = typer.Typer( @@ -120,7 +121,7 @@ def _get_ca(cfg: WardenConfig): def _apply_policy_gate(cfg: WardenConfig, spec: CertSpec) -> None: - """Run flex-auth check when policy.enabled; sets spec.policy_decision_id.""" + """Run the zone-aware flex-auth check; record any returned decision id.""" decision_id = check_sign_policy(cfg.policy, spec) if decision_id: spec.policy_decision_id = decision_id @@ -644,6 +645,7 @@ def _entry_summary(entry) -> dict: # Agent read-boundary (WP-0026 T04) — high-risk lanes deny raw agent data reads. "risk": entry.risk, "high_risk": entry.is_high_risk, + "workload_ref": entry.workload_ref.to_dict(), # Renewal guidance (WP-0026 T06) — advisory, no secret values. `has_rotation` # lets a caller gate before asking for the full block via `warden rotate-guide`. "has_rotation": entry.has_rotation, @@ -771,9 +773,6 @@ def route_list( ) -from warden.routing.catalog import blocker_stale_days - - def _gap_is_stale(entry, delegation, reviewed: str, stale_days) -> bool: """An interim lane needs attention on either of two independent grounds. @@ -1255,6 +1254,14 @@ def _access_proxy( "token stays in the caller's own store.[/dim]" ) else: + if no_policy: + err.print( + "[red]--no-policy is retired[/red]: security-zones_v0.1 selects " + "the policy stance and failure mode. Remove the flag; an unresolved " + "workload uses the explicit unknown-zone profile." + ) + raise typer.Exit(2) + # G1 — caller identity. ops-warden adds no token of its own. if not caller_auth_present(): err.print( @@ -1263,24 +1270,20 @@ def _access_proxy( ) raise typer.Exit(3) - # G3 — policy gate before fetch. - if cfg.policy.enabled: - try: - decision_id = check_fetch_policy( - cfg.policy, need_id=entry.id, owner_repo=entry.owner_repo, domain=domain - ) - except CAError as e: - err.print(f"[red]Policy gate denied the fetch:[/red] {e}") - raise typer.Exit(4) - err.print(f"[green]flex-auth allow[/green] (decision {decision_id}).") - elif not no_policy: - err.print( - "[yellow]flex-auth gate is not enforced[/yellow] (policy.enabled=false). " - "Re-run with [bold]--no-policy[/bold] to proxy ungated, or enable the gate." + # G3 — the zone-aware policy gate always runs before fetch. + try: + decision_id = check_fetch_policy( + cfg.policy, need_id=entry.id, owner_repo=entry.owner_repo, domain=domain ) + except CAError as e: + err.print(f"[red]Policy gate denied the fetch:[/red] {e}") raise typer.Exit(4) + if decision_id: + err.print(f"[green]flex-auth decision[/green] ({decision_id}).") else: - err.print("[yellow]Proxying ungated[/yellow] (--no-policy; gate not enforced).") + err.print( + "[yellow]flex-auth unavailable; unknown-zone fail_open applied[/yellow]." + ) # Wrapping (WP-0026 T02) uses its own command shape; the value-bearing transports # share the resolved fetch command. @@ -1434,7 +1437,10 @@ def access( ] = False, no_policy: Annotated[ bool, - typer.Option("--no-policy", help="Acknowledge proxying when the flex-auth gate is not enforced"), + typer.Option( + "--no-policy", + help="Retired compatibility flag; zone-aware policy evaluation cannot be bypassed", + ), ] = False, ) -> None: """Operator front door: how to obtain any credential, gated and audited. diff --git a/src/warden/config.py b/src/warden/config.py index 4cf51b6..8cf83f3 100644 --- a/src/warden/config.py +++ b/src/warden/config.py @@ -45,9 +45,19 @@ class CallerAuthConfig: @dataclass class PolicyConfig: - enabled: bool = False - flex_auth_url: str = "http://127.0.0.1:8080" - fail_closed: bool = True + flex_auth_url: Optional[str] = None + zone_registry_path: Optional[Path] = None + failure_modes: Dict[str, str] = field( + default_factory=lambda: { + "z0-experimental": "fail_open", + "z1-operational": "fail_open", + "z2-protected": "fail_open", + "z2-continuity": "fail_open", + "z3-critical": "fail_closed", + "unknown": "fail_open", + "not-applicable": "fail_closed", + } + ) tenant: str = "tenant:platform" subject_env: str = "WARDEN_POLICY_SUBJECT" system: str = "ops-warden" @@ -148,6 +158,13 @@ def load_config(path: Optional[Path] = None) -> WardenConfig: ) policy_raw = raw.get("policy") or {} + retired = sorted({"enabled", "fail_closed"}.intersection(policy_raw)) + if retired: + raise ConfigError( + "retired policy setting(s) " + + ", ".join(f"policy.{key}" for key in retired) + + "; security-zones_v0.1 now selects stance and failure mode" + ) caller_raw = policy_raw.get("caller_auth") or {} caller_command = caller_raw.get("command") if isinstance(caller_command, str): @@ -175,10 +192,33 @@ def load_config(path: Optional[Path] = None) -> WardenConfig: raise ConfigError("policy.caller_auth.token_path is required for mode: file") if caller_cfg.mode == "command" and not caller_cfg.command: raise ConfigError("policy.caller_auth.command is required for mode: command") + failure_modes = PolicyConfig().failure_modes + configured_failure_modes = policy_raw.get("failure_modes") or {} + if not isinstance(configured_failure_modes, dict): + raise ConfigError("policy.failure_modes must be a mapping") + failure_modes.update( + {str(zone): str(mode) for zone, mode in configured_failure_modes.items()} + ) + invalid_modes = { + zone: mode + for zone, mode in failure_modes.items() + if mode not in {"fail_open", "fail_closed"} + } + if invalid_modes: + raise ConfigError( + "policy.failure_modes values must be fail_open or fail_closed: " + f"{invalid_modes}" + ) + zone_registry_path = policy_raw.get("zone_registry_path") + flex_auth_url = str(policy_raw.get("flex_auth_url", "")).strip() or None policy_cfg = PolicyConfig( - enabled=bool(policy_raw.get("enabled", False)), - flex_auth_url=str(policy_raw.get("flex_auth_url", "http://127.0.0.1:8080")), - fail_closed=bool(policy_raw.get("fail_closed", True)), + flex_auth_url=flex_auth_url, + zone_registry_path=( + Path(os.path.expanduser(str(zone_registry_path))) + if zone_registry_path + else None + ), + failure_modes=failure_modes, tenant=str(policy_raw.get("tenant", "tenant:platform")), subject_env=str(policy_raw.get("subject_env", "WARDEN_POLICY_SUBJECT")), system=str(policy_raw.get("system", "ops-warden")), diff --git a/src/warden/models.py b/src/warden/models.py index 689c36b..fcc0aec 100644 --- a/src/warden/models.py +++ b/src/warden/models.py @@ -53,6 +53,9 @@ class CertSpec: principals: List[str] identity: str = "" # defaults to actor_name if empty policy_decision_id: Optional[str] = None + policy_zone: Optional[str] = None + policy_failure_mode: Optional[str] = None + policy_outcome: Optional[str] = None def __post_init__(self) -> None: if not self.identity: diff --git a/src/warden/policy.py b/src/warden/policy.py index 150dd22..140c8c4 100644 --- a/src/warden/policy.py +++ b/src/warden/policy.py @@ -1,7 +1,8 @@ -"""flex-auth policy gate for SSH signing (opt-in via warden.yaml).""" +"""Zone-aware flex-auth policy gates for OpsWarden.""" from __future__ import annotations import hashlib +import json import os from pathlib import Path @@ -20,21 +21,60 @@ def pubkey_fingerprint(pubkey_path: Path) -> str: return f"sha256:{digest}" -def _caller_headers(cfg: PolicyConfig) -> dict[str, str]: +def _caller_headers(cfg: PolicyConfig, *, fail_closed: bool) -> dict[str, str]: """Bearer header identifying ops-warden itself to flex-auth (FLEX-WP-0016). - When the token cannot be obtained we refuse the call under ``fail_closed`` + When the token cannot be obtained we refuse the call under the selected + zone's ``fail_closed`` behavior rather than silently falling back to an unauthenticated request — an unauthenticated call is exactly what keeps the flex-auth pin in ``warn``. """ try: return caller_auth_headers(cfg.caller_auth) except CallerIdentityError as e: - if cfg.fail_closed: + if fail_closed: raise CAError(f"flex-auth caller identity unavailable: {e}") from e return {} +def _resource_zone(cfg: PolicyConfig, resource_id: str) -> str: + """Read a compiled resource zone; absence or ambiguity is always unknown.""" + if cfg.zone_registry_path is None: + return "unknown" + try: + registry = json.loads(cfg.zone_registry_path.read_text()) + resources = registry["resource_manifests"][0]["resources"] + resource = next(item for item in resources if item.get("id") == resource_id) + attributes = resource.get("attributes") or {} + if attributes.get("security_zone_admission") == "not-applicable": + return "not-applicable" + zone = str(attributes.get("security_zone") or "unknown") + return zone if zone in cfg.failure_modes else "unknown" + except (OSError, ValueError, KeyError, StopIteration, TypeError): + return "unknown" + + +def _is_fail_closed(cfg: PolicyConfig, zone: str) -> bool: + return cfg.failure_modes.get(zone, cfg.failure_modes["unknown"]) == "fail_closed" + + +def _evaluator_failure( + message: str, + *, + fail_closed: bool, + cause: Exception | None = None, + spec: CertSpec | None = None, +) -> None: + if fail_closed: + if spec is not None: + spec.policy_outcome = "fail_closed" + if cause is None: + raise CAError(message) + raise CAError(message) from cause + if spec is not None: + spec.policy_outcome = "fail_open" + + def _subject_id(cfg: PolicyConfig, spec: CertSpec) -> str: return os.environ.get(cfg.subject_env, "").strip() or spec.actor_name @@ -42,11 +82,21 @@ def _subject_id(cfg: PolicyConfig, spec: CertSpec) -> str: def check_sign_policy(cfg: PolicyConfig, spec: CertSpec) -> str | None: """Call flex-auth /v1/check before signing. - Returns decision id when policy is enabled and effect is allow. - Returns None when policy is disabled. - Raises CAError on deny or when fail_closed and flex-auth is unreachable. + Returns a decision id on ``allow`` or ``audit_only``. A deny always blocks. + Evaluator failures use the PEP-owned failure mode for the target workload's + compiled zone; absent resolution is the explicit ``unknown`` profile. """ - if not cfg.enabled: + resource_id = f"ssh-cert:actor/{spec.actor_name}" + zone = _resource_zone(cfg, resource_id) + fail_closed = _is_fail_closed(cfg, zone) + spec.policy_zone = zone + spec.policy_failure_mode = "fail_closed" if fail_closed else "fail_open" + if cfg.flex_auth_url is None: + _evaluator_failure( + f"flex-auth URL is not configured for security zone {zone!r}", + fail_closed=fail_closed, + spec=spec, + ) return None pubkey_path = Path(os.path.expanduser(str(spec.pubkey_path))) @@ -76,37 +126,54 @@ def check_sign_policy(cfg: PolicyConfig, spec: CertSpec) -> str | None: } url = cfg.flex_auth_url.rstrip("/") + "/v1/check" - headers = _caller_headers(cfg) + headers = _caller_headers(cfg, fail_closed=fail_closed) try: response = httpx.post(url, json=request, headers=headers, timeout=10.0) response.raise_for_status() except httpx.HTTPStatusError as e: - if cfg.fail_closed: - raise CAError( - f"flex-auth denied or rejected sign policy check (HTTP {e.response.status_code})" - ) from e + _evaluator_failure( + f"flex-auth rejected sign policy check (HTTP {e.response.status_code}) " + f"for security zone {zone!r}", + fail_closed=fail_closed, + cause=e, + spec=spec, + ) return None except httpx.RequestError as e: - if cfg.fail_closed: - raise CAError( - f"flex-auth unreachable at {cfg.flex_auth_url!r} " - f"(fail_closed=true): {e}" - ) from e + _evaluator_failure( + f"flex-auth unreachable at {cfg.flex_auth_url!r} for security zone {zone!r}", + fail_closed=fail_closed, + cause=e, + spec=spec, + ) return None try: decision = response.json() except ValueError as e: - raise CAError("flex-auth returned non-JSON decision") from e + _evaluator_failure( + f"flex-auth returned a non-JSON decision for security zone {zone!r}", + fail_closed=fail_closed, + cause=e, + spec=spec, + ) + return None effect = str(decision.get("effect", "")).lower() decision_id = decision.get("id") or decision.get("request_id") - if effect != "allow": + if effect not in {"allow", "audit_only"}: + spec.policy_outcome = "deny" reason = decision.get("reason") or "no reason provided" raise CAError(f"flex-auth denied SSH sign for {spec.actor_name!r}: {reason}") if not decision_id: - raise CAError("flex-auth allow decision missing id") + _evaluator_failure( + f"flex-auth {effect} decision missing id for security zone {zone!r}", + fail_closed=fail_closed, + spec=spec, + ) + return None + spec.policy_outcome = effect return str(decision_id) @@ -116,13 +183,17 @@ def check_fetch_policy( """Call flex-auth /v1/check before proxying a non-SSH credential fetch (WP-0014). The action is ``read`` on a ``secret`` resource owned by another subsystem — - ops-warden is the conduit, not the owner. Returns the decision id on allow, - None when policy is disabled, and raises CAError on deny (or on an unreachable - flex-auth when fail_closed). No secret value is ever part of this request. + ops-warden is the conduit, not the owner. Unresolved target workload identity + selects the explicit ``unknown`` profile; no secret value enters the request. """ - if not cfg.enabled: + zone = "unknown" + fail_closed = _is_fail_closed(cfg, zone) + if cfg.flex_auth_url is None: + _evaluator_failure( + "flex-auth URL is not configured for security zone 'unknown'", + fail_closed=fail_closed, + ) return None - subject_id = os.environ.get(cfg.subject_env, "").strip() or "operator" request = { "subject": {"id": subject_id, "type": "operator", "tenant": cfg.tenant}, @@ -137,33 +208,44 @@ def check_fetch_policy( } url = cfg.flex_auth_url.rstrip("/") + "/v1/check" - headers = _caller_headers(cfg) + headers = _caller_headers(cfg, fail_closed=fail_closed) try: response = httpx.post(url, json=request, headers=headers, timeout=10.0) response.raise_for_status() except httpx.HTTPStatusError as e: - if cfg.fail_closed: - raise CAError( - f"flex-auth denied or rejected fetch policy check (HTTP {e.response.status_code})" - ) from e + _evaluator_failure( + f"flex-auth rejected fetch policy check (HTTP {e.response.status_code})", + fail_closed=fail_closed, + cause=e, + ) return None except httpx.RequestError as e: - if cfg.fail_closed: - raise CAError( - f"flex-auth unreachable at {cfg.flex_auth_url!r} (fail_closed=true): {e}" - ) from e + _evaluator_failure( + f"flex-auth unreachable at {cfg.flex_auth_url!r} for security zone 'unknown'", + fail_closed=fail_closed, + cause=e, + ) return None try: decision = response.json() except ValueError as e: - raise CAError("flex-auth returned non-JSON decision") from e + _evaluator_failure( + "flex-auth returned a non-JSON decision for security zone 'unknown'", + fail_closed=fail_closed, + cause=e, + ) + return None effect = str(decision.get("effect", "")).lower() decision_id = decision.get("id") or decision.get("request_id") - if effect != "allow": + if effect not in {"allow", "audit_only"}: reason = decision.get("reason") or "no reason provided" raise CAError(f"flex-auth denied secret read for {need_id!r}: {reason}") if not decision_id: - raise CAError("flex-auth allow decision missing id") - return str(decision_id) \ No newline at end of file + _evaluator_failure( + f"flex-auth {effect} decision missing id for security zone 'unknown'", + fail_closed=fail_closed, + ) + return None + return str(decision_id) diff --git a/src/warden/routing/__init__.py b/src/warden/routing/__init__.py index ac5d115..52254b3 100644 --- a/src/warden/routing/__init__.py +++ b/src/warden/routing/__init__.py @@ -12,7 +12,7 @@ from warden.routing.catalog import ( find_catalog_path, load_catalog, ) -from warden.routing.models import Delegation, RouteEntry +from warden.routing.models import Delegation, RouteEntry, WorkloadReference __all__ = [ "Catalog", @@ -20,6 +20,7 @@ __all__ = [ "CatalogFreshness", "Delegation", "RouteEntry", + "WorkloadReference", "find_catalog_path", "load_catalog", ] diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index 76abd5f..aca8eaf 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -26,9 +26,11 @@ import yaml from warden.routing.models import ( VALID_DELEGATION_MODES, VALID_RISK, + VALID_WORKLOAD_APPLICABILITY, Delegation, RotationGuide, RouteEntry, + WorkloadReference, ) # Structured handoff string fields (WP-0014) — templates and pointers only. @@ -64,6 +66,7 @@ _REQUIRED_FIELDS = ( "canon_ref", "reviewed", "status", + "workload_ref", ) _VALID_STATUS = ("active", "draft") _VALID_LANES = ("secret", "login") @@ -516,6 +519,82 @@ def _parse_delegation(entry_id: str, raw: Optional[dict]) -> Optional[Delegation ) +def _parse_workload_ref(entry_id: str, raw: object) -> WorkloadReference: + """Parse an explicit workload join without attempting identity inference.""" + if not isinstance(raw, dict): + raise CatalogError( + f"entry {entry_id!r} workload_ref must be a mapping; every lane must " + "declare applicable or not-applicable" + ) + + applicability = str(raw.get("applicability", "")).strip() + if applicability not in VALID_WORKLOAD_APPLICABILITY: + raise CatalogError( + f"entry {entry_id!r} workload_ref.applicability {applicability!r} invalid " + f"(expected one of {VALID_WORKLOAD_APPLICABILITY})" + ) + + def optional(name: str) -> Optional[str]: + value = raw.get(name) + return str(value).strip() if value is not None and str(value).strip() else None + + ref = WorkloadReference( + applicability=applicability, + rapp_id=optional("rapp_id"), + name=optional("name"), + deployable=optional("deployable"), + declaration_ref=optional("declaration_ref"), + reason=optional("reason"), + unknown_reason=optional("unknown_reason"), + ) + + target_fields = (ref.rapp_id, ref.name, ref.deployable, ref.declaration_ref) + if applicability == "not-applicable": + if not ref.reason: + raise CatalogError( + f"entry {entry_id!r} workload_ref.reason is required for not-applicable" + ) + if any(target_fields) or ref.unknown_reason: + raise CatalogError( + f"entry {entry_id!r} not-applicable workload_ref must not carry a " + "workload target or unknown_reason" + ) + return ref + + if ref.unknown_reason: + if any(target_fields) or ref.reason: + raise CatalogError( + f"entry {entry_id!r} unknown workload_ref must carry only " + "applicability and unknown_reason" + ) + return ref + + if not ref.name: + raise CatalogError( + f"entry {entry_id!r} applicable workload_ref requires name or " + "unknown_reason" + ) + if ref.rapp_id: + if ref.declaration_ref: + raise CatalogError( + f"entry {entry_id!r} managed workload_ref must not also carry " + "declaration_ref" + ) + elif not ref.declaration_ref: + raise CatalogError( + f"entry {entry_id!r} operational workload_ref requires declaration_ref" + ) + if ref.deployable and not ref.rapp_id: + raise CatalogError( + f"entry {entry_id!r} workload_ref.deployable requires rapp_id" + ) + if ref.reason: + raise CatalogError( + f"entry {entry_id!r} applicable workload_ref must not carry reason" + ) + return ref + + def _parse_entry(raw: dict, index: int) -> RouteEntry: if not isinstance(raw, dict): raise CatalogError(f"entry #{index} is not a mapping") @@ -576,12 +655,16 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: f"entry {entry_id!r} has invalid lane {lane!r} (expected one of {_VALID_LANES})" ) - risk = str(raw.get("risk", "standard")).strip() or "standard" - if risk not in VALID_RISK: + risk_value = raw.get("risk") + risk = str(risk_value).strip() if risk_value is not None else "ungraded" + risk = risk or "ungraded" + if risk != "ungraded" and risk not in VALID_RISK: raise CatalogError( f"entry {entry_id!r} has invalid risk {risk!r} (expected one of {VALID_RISK})" ) + workload_ref = _parse_workload_ref(entry_id, raw.get("workload_ref")) + return RouteEntry( id=entry_id, title=str(raw["title"]), @@ -593,6 +676,7 @@ def _parse_entry(raw: dict, index: int) -> RouteEntry: canon_ref=str(raw["canon_ref"]), reviewed=str(raw["reviewed"]), status=status, + workload_ref=workload_ref, steps=[str(s) for s in steps], cert_command=str(cert_command) if cert_command else None, auth_method=handoff["auth_method"], diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index 9528e15..c9c348e 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -46,6 +46,7 @@ VALID_RISK = ("standard", "high") # interim — ops-warden covers a gap; intended_owner + blocked_on required # permanent — ops-warden is the designed owner of this front door (SSH only today) VALID_DELEGATION_MODES = ("native", "interim", "permanent") +VALID_WORKLOAD_APPLICABILITY = ("applicable", "not-applicable") IMPLICIT_DELEGATION_BLOCKED_ON = ( "unclassified — no delegation block; treat as a question, not a settlement" @@ -100,6 +101,46 @@ class Delegation: } +@dataclass(frozen=True) +class WorkloadReference: + """Authoritative workload join for a catalog lane (WARDEN-WP-0032). + + Managed deployables use the Repo Manager v1 ``rapp_id``/``name`` tuple. + Independently governed operational workloads use ``name`` plus an owner + declaration reference. An applicable lane whose owner has not published an + identity remains explicitly ``unknown``; it is never inferred from the + credential path or repository name. + """ + + applicability: str # applicable | not-applicable + rapp_id: Optional[str] = None + name: Optional[str] = None + deployable: Optional[str] = None + declaration_ref: Optional[str] = None + reason: Optional[str] = None + unknown_reason: Optional[str] = None + + @property + def resolution(self) -> str: + if self.applicability == "not-applicable": + return "not-applicable" + if self.unknown_reason: + return "unknown" + return "resolved" + + def to_dict(self) -> dict: + return { + "applicability": self.applicability, + "rapp_id": self.rapp_id, + "name": self.name, + "deployable": self.deployable, + "declaration_ref": self.declaration_ref, + "reason": self.reason, + "unknown_reason": self.unknown_reason, + "resolution": self.resolution, + } + + @dataclass class RouteEntry: id: str @@ -112,6 +153,8 @@ class RouteEntry: canon_ref: str reviewed: str status: str # "active" | "draft" + # Explicit workload applicability and authoritative join. Never inferred. + workload_ref: Optional[WorkloadReference] = None # SSH lane only — None/empty for routed (non-executed) needs. steps: List[str] = field(default_factory=list) cert_command: Optional[str] = None @@ -169,6 +212,34 @@ class RouteEntry: """ return self.risk not in LOW_RISK_GRADES + def risk_for_zone( + self, + *, + effective_zone: str = "unknown", + admission: str = "unknown", + synthetic_only: bool = False, + ) -> str: + """Resolve an absent grade using security-zones_v0.1 section 5.1. + + Explicit grades always win. The sole lower default is a satisfied + ``z0-experimental`` workload proven synthetic-only. Every other zone, + failed/unknown admission, and missing context fails safe to at least + ``high``; z3 reports ``critical`` (which the read boundary treats as + high). Catalog CI still requires explicit grades, so this is the safe + runtime behavior for malformed or newer external catalogs. + """ + if self.is_graded: + return self.risk + if ( + effective_zone == "z0-experimental" + and admission == "satisfied" + and synthetic_only + ): + return "standard" + if effective_zone == "z3-critical" and admission == "satisfied": + return "critical" + return "high" + @property def has_rotation(self) -> bool: """True when this lane carries renewal guidance (WP-0026 T06).""" diff --git a/tenancy.yaml b/tenancy.yaml index 289b21d..fdd61bb 100644 --- a/tenancy.yaml +++ b/tenancy.yaml @@ -8,6 +8,17 @@ framework: netkingdom-tenancy-posture service: ops-warden role: ssh-certificate-authority +workload_identity: + name: ops-warden + kind: operational-control-plane + responsible_repo: ops-warden + identity_bindings: + - scheme: kubernetes-service-account + authority: railiance01 + subject: system:serviceaccount:ops-warden:ops-warden + principal_type: service + environment: prod + tenancy: current: I: 1 @@ -108,13 +119,25 @@ tenancy: is single-node with a shared control plane, and under Decision 4.6.1 that caps V for everything bound to it (see NK-WP-0027). -# zones: (reserved, tenancy-posture_v0.1 Decision 5.6) -# -# Deliberately absent. The security-zone standard is being drafted by -# zone-engine (ZONE-WP-0001) and no zone vocabulary exists yet, so any value -# here would be fabricated — the failure §6 exists to prevent. ops-warden is -# the first intended consumer and will populate this key once the sibling -# standard defines it (WARDEN-WP-0032-T03). +zones: + standard: security-zones_v0.1 + membership: z1-operational + responsible_party: team:platform-security + justification: >- + The attended platform signing service has a bounded operational scope and + internal metadata exposure. It has M1 evidence, but no SLO history, on-call + rotation, or incident exercise that would support z2-protected. + context: + maturity: M1 + criticality: medium + data_classification: internal + evidence: + - ref: docs/evidence/security-zone-admission-2026-08-22.md + supports: [M1, platform-only-scope, basic-slo, data-handling-note] + - ref: history/2026-08-19-flex-auth-caller-identity-evidence.md + supports: [production-policy-path, authenticated-caller] + reviewed: "2026-08-22" + review_due: "2026-11-22" evidence: A: >- diff --git a/tests/test_config.py b/tests/test_config.py index dec6b4d..b486f00 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -84,13 +84,13 @@ def test_default_vault_token_env(tmp_path): assert cfg.vault.token_env == "VAULT_TOKEN" -def test_policy_defaults_disabled(tmp_path): +def test_policy_defaults_to_unknown_zone_profile(tmp_path): cfg_path = tmp_path / "warden.yaml" write_yaml(cfg_path, {"backend": "local", "ca_key": str(tmp_path / "ca")}) cfg = load_config(cfg_path) - assert cfg.policy.enabled is False - assert cfg.policy.flex_auth_url == "http://127.0.0.1:8080" - assert cfg.policy.fail_closed is True + assert cfg.policy.flex_auth_url is None + assert cfg.policy.failure_modes["unknown"] == "fail_open" + assert cfg.policy.failure_modes["z3-critical"] == "fail_closed" def test_policy_block_parsed(tmp_path): @@ -99,18 +99,30 @@ def test_policy_block_parsed(tmp_path): "backend": "local", "ca_key": str(tmp_path / "ca"), "policy": { - "enabled": True, "flex_auth_url": "http://flex-auth:8080", - "fail_closed": False, + "zone_registry_path": str(tmp_path / "zones.json"), + "failure_modes": {"z2-protected": "fail_closed"}, "tenant": "tenant:coulomb", "subject_env": "MY_SUBJECT", "system": "warden-test", }, }) cfg = load_config(cfg_path) - assert cfg.policy.enabled is True assert cfg.policy.flex_auth_url == "http://flex-auth:8080" - assert cfg.policy.fail_closed is False + assert cfg.policy.zone_registry_path == tmp_path / "zones.json" + assert cfg.policy.failure_modes["z2-protected"] == "fail_closed" assert cfg.policy.tenant == "tenant:coulomb" assert cfg.policy.subject_env == "MY_SUBJECT" assert cfg.policy.system == "warden-test" + + +@pytest.mark.parametrize("retired", ["enabled", "fail_closed"]) +def test_retired_global_policy_switches_are_rejected(tmp_path, retired): + cfg_path = tmp_path / "warden.yaml" + write_yaml(cfg_path, { + "backend": "local", + "ca_key": str(tmp_path / "ca"), + "policy": {retired: True}, + }) + with pytest.raises(ConfigError, match=f"policy.{retired}"): + load_config(cfg_path) diff --git a/tests/test_desk.py b/tests/test_desk.py index bf16133..2ec34b2 100644 --- a/tests/test_desk.py +++ b/tests/test_desk.py @@ -1,14 +1,11 @@ """Tests for warden desk (WARDEN-WP-0029 T03).""" from __future__ import annotations -import json import threading import urllib.error import urllib.parse import urllib.request from http.server import ThreadingHTTPServer -from pathlib import Path - import pytest from typer.testing import CliRunner diff --git a/tests/test_flex_auth_registry.py b/tests/test_flex_auth_registry.py index f109d87..c329d27 100644 --- a/tests/test_flex_auth_registry.py +++ b/tests/test_flex_auth_registry.py @@ -31,4 +31,53 @@ def test_build_registry_from_inventory_seed(tmp_path): ) assert bridge["attributes"]["actor_type"] == "agt" assert bridge["attributes"]["max_ttl_hours"] == 24 - assert "agt-task-bridge" in bridge["attributes"]["allowed_principals"] \ No newline at end of file + assert "agt-task-bridge" in bridge["attributes"]["allowed_principals"] + assert "trust_zone" not in bridge + assert bridge["attributes"]["security_zone"] == "unknown" + assert bridge["attributes"]["security_zone_admission"] == "unknown" + assert bridge["attributes"]["workload_id"] == "ops-bridge-tunnel" + + human = next( + r + for r in registry["resource_manifests"][0]["resources"] + if r["id"] == "ssh-cert:actor/adm-example" + ) + assert human["attributes"]["security_zone_admission"] == "not-applicable" + + +def test_compiler_joins_explicit_workload_reference_to_resolved_zone(tmp_path): + zones = tmp_path / "zones.json" + zones.write_text(json.dumps({ + "records": [{ + "workload_id": "ops-bridge-tunnel", + "declared_zone": "z2-continuity", + "admission": "satisfied", + "admission_reason": "admission_floor_met", + "effective_zone": "z2-continuity", + "membership_revision": "sha256:zone-revision", + }] + })) + out = tmp_path / "registry.json" + subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(INVENTORY), + "--zone-resolutions", + str(zones), + "-o", + str(out), + ], + check=True, + cwd=ROOT, + ) + registry = json.loads(out.read_text()) + bridge = next( + r + for r in registry["resource_manifests"][0]["resources"] + if r["id"] == "ssh-cert:actor/agt-state-hub-bridge" + ) + attrs = bridge["attributes"] + assert attrs["security_zone"] == "z2-continuity" + assert attrs["security_zone_admission"] == "satisfied" + assert attrs["security_zone_revision"] == "sha256:zone-revision" diff --git a/tests/test_memory.py b/tests/test_memory.py index cb83247..8f8dcd4 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -7,7 +7,7 @@ import json from typer.testing import CliRunner from warden.cli import app -from warden.memory import activate, enabled, record_command_episode, status, store_path +from warden.memory import activate, record_command_episode, status, store_path from warden.worker import RuleBrain, _plan_with_memory, build_plans runner = CliRunner() @@ -140,4 +140,4 @@ def test_route_find_implicitly_activates_memory_without_explicit_command(tmp_pat activation = ensure_memory_context(need="ssh tunnel", implicit=True) assert activation is not None assert activation.get("implicit") is True - assert status()["episode_count"] >= 1 \ No newline at end of file + assert status()["episode_count"] >= 1 diff --git a/tests/test_policy.py b/tests/test_policy.py index 728782f..0e2f948 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -27,6 +27,17 @@ def _spec(pubkey_path: Path) -> CertSpec: ) +def _zone_registry(tmp_path: Path, zone: str) -> Path: + path = tmp_path / "registry.json" + path.write_text( + '{"resource_manifests":[{"resources":[{"id":' + '"ssh-cert:actor/agt-state-hub-bridge","attributes":{' + f'"security_zone":"{zone}","security_zone_admission":"satisfied"' + '}}]}]}' + ) + return path + + def test_pubkey_fingerprint(tmp_path): pubkey = tmp_path / "key.pub" pubkey.write_text("ssh-ed25519 AAAA test\n") @@ -35,26 +46,32 @@ def test_pubkey_fingerprint(tmp_path): assert len(fp) == 7 + 64 -def test_disabled_returns_none(tmp_path): +def test_unconfigured_evaluator_uses_unknown_fail_open_profile(tmp_path): pubkey = tmp_path / "key.pub" pubkey.write_text("ssh-ed25519 AAAA\n") - cfg = PolicyConfig(enabled=False) - assert check_sign_policy(cfg, _spec(pubkey)) is None + cfg = PolicyConfig() + spec = _spec(pubkey) + assert check_sign_policy(cfg, spec) is None + assert spec.policy_zone == "unknown" + assert spec.policy_failure_mode == "fail_open" + assert spec.policy_outcome == "fail_open" def test_allow_returns_decision_id(tmp_path): pubkey = tmp_path / "key.pub" pubkey.write_text("ssh-ed25519 AAAA\n") - cfg = PolicyConfig(enabled=True, flex_auth_url="http://flex-auth.test") + cfg = PolicyConfig(flex_auth_url="http://flex-auth.test") mock_response = MagicMock() mock_response.json.return_value = {"effect": "allow", "id": "dec-123"} mock_response.raise_for_status = MagicMock() + spec = _spec(pubkey) with patch("warden.policy.httpx.post", return_value=mock_response) as post: - result = check_sign_policy(cfg, _spec(pubkey)) + result = check_sign_policy(cfg, spec) assert result == "dec-123" + assert spec.policy_outcome == "allow" post.assert_called_once() call_kwargs = post.call_args assert call_kwargs[0][0] == "http://flex-auth.test/v1/check" @@ -67,7 +84,7 @@ def test_allow_returns_decision_id(tmp_path): def test_deny_raises_ca_error(tmp_path): pubkey = tmp_path / "key.pub" pubkey.write_text("ssh-ed25519 AAAA\n") - cfg = PolicyConfig(enabled=True) + cfg = PolicyConfig(flex_auth_url="http://flex-auth.test") mock_response = MagicMock() mock_response.json.return_value = { @@ -84,7 +101,10 @@ def test_deny_raises_ca_error(tmp_path): def test_unreachable_fail_closed_raises(tmp_path): pubkey = tmp_path / "key.pub" pubkey.write_text("ssh-ed25519 AAAA\n") - cfg = PolicyConfig(enabled=True, fail_closed=True) + cfg = PolicyConfig( + flex_auth_url="http://flex-auth.test", + zone_registry_path=_zone_registry(tmp_path, "z3-critical"), + ) with patch( "warden.policy.httpx.post", @@ -97,7 +117,7 @@ def test_unreachable_fail_closed_raises(tmp_path): def test_unreachable_fail_open_returns_none(tmp_path): pubkey = tmp_path / "key.pub" pubkey.write_text("ssh-ed25519 AAAA\n") - cfg = PolicyConfig(enabled=True, fail_closed=False) + cfg = PolicyConfig(flex_auth_url="http://flex-auth.test") with patch( "warden.policy.httpx.post", @@ -109,7 +129,10 @@ def test_unreachable_fail_open_returns_none(tmp_path): def test_http_error_fail_closed_raises(tmp_path): pubkey = tmp_path / "key.pub" pubkey.write_text("ssh-ed25519 AAAA\n") - cfg = PolicyConfig(enabled=True, fail_closed=True) + cfg = PolicyConfig( + flex_auth_url="http://flex-auth.test", + zone_registry_path=_zone_registry(tmp_path, "z3-critical"), + ) mock_response = MagicMock() mock_response.status_code = 403 @@ -123,7 +146,7 @@ def test_http_error_fail_closed_raises(tmp_path): def test_missing_pubkey_raises(tmp_path): - cfg = PolicyConfig(enabled=True) + cfg = PolicyConfig(flex_auth_url="http://flex-auth.test") spec = _spec(tmp_path / "missing.pub") with pytest.raises(CAError, match="Public key not found"): check_sign_policy(cfg, spec) @@ -132,7 +155,10 @@ def test_missing_pubkey_raises(tmp_path): def test_subject_from_env(tmp_path, monkeypatch): pubkey = tmp_path / "key.pub" pubkey.write_text("ssh-ed25519 AAAA\n") - cfg = PolicyConfig(enabled=True, subject_env="WARDEN_POLICY_SUBJECT") + cfg = PolicyConfig( + flex_auth_url="http://flex-auth.test", + subject_env="WARDEN_POLICY_SUBJECT", + ) monkeypatch.setenv("WARDEN_POLICY_SUBJECT", "iam:bernd") mock_response = MagicMock() @@ -212,7 +238,7 @@ def test_sign_policy_sends_authorization_header(tmp_path, monkeypatch): pubkey.write_text("ssh-ed25519 AAAA test\n") cfg = PolicyConfig( - enabled=True, + flex_auth_url="http://flex-auth.test", caller_auth=CallerAuthConfig(mode="file", token_path=token_file), ) spec = CertSpec( @@ -250,8 +276,8 @@ def test_sign_policy_fail_closed_when_caller_token_unavailable(tmp_path): pubkey = tmp_path / "id.pub" pubkey.write_text("ssh-ed25519 AAAA test\n") cfg = PolicyConfig( - enabled=True, - fail_closed=True, + flex_auth_url="http://flex-auth.test", + zone_registry_path=_zone_registry(tmp_path, "z3-critical"), caller_auth=CallerAuthConfig(mode="file", token_path=tmp_path / "absent"), ) spec = CertSpec( @@ -263,3 +289,21 @@ def test_sign_policy_fail_closed_when_caller_token_unavailable(tmp_path): ) with pytest.raises(CAError, match="caller identity unavailable"): policy_mod.check_sign_policy(cfg, spec) + + +def test_advisory_decision_is_recorded_and_does_not_block(tmp_path): + pubkey = tmp_path / "id.pub" + pubkey.write_text("ssh-ed25519 AAAA test\n") + cfg = PolicyConfig(flex_auth_url="http://flex-auth.test") + response = MagicMock() + response.json.return_value = { + "effect": "audit_only", + "reason": "advisory_would_deny_disallowed_principal", + "id": "decision:advisory", + } + response.raise_for_status = MagicMock() + spec = _spec(pubkey) + with patch("warden.policy.httpx.post", return_value=response): + assert check_sign_policy(cfg, spec) == "decision:advisory" + assert spec.policy_zone == "unknown" + assert spec.policy_outcome == "audit_only" diff --git a/tests/test_proxy.py b/tests/test_proxy.py index be0d190..4e4857e 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -193,7 +193,6 @@ def _warden_yaml(tmp_path: Path) -> Path: (tmp_path / "ca").write_text("") cfg.write_text( f"backend: local\nca_key: {tmp_path/'ca'}\nstate_dir: {tmp_path/'state'}\n" - "policy:\n enabled: false\n" ) return cfg @@ -203,10 +202,11 @@ def _proxy_env(monkeypatch, tmp_path): monkeypatch.setenv("WARDEN_CONFIG", str(_warden_yaml(tmp_path))) -def test_cli_proxy_refuses_without_policy_ack(monkeypatch, tmp_path): +def test_cli_proxy_unknown_zone_fail_open_reaches_transport_guard(monkeypatch, tmp_path): _proxy_env(monkeypatch, tmp_path) monkeypatch.setenv("VAULT_TOKEN", "caller") - # subprocess must never run if the gate blocks first. + # The unknown-zone profile proceeds when no evaluator is configured, then + # the independent safe-transport boundary still refuses captured stdout. monkeypatch.setattr( "warden.proxy.subprocess.run", lambda *a, **k: (_ for _ in ()).throw(AssertionError("fetch ran despite gate")), @@ -216,8 +216,8 @@ def test_cli_proxy_refuses_without_policy_ack(monkeypatch, tmp_path): ["access", "npm", "--domain", "coulomb_social", "--field", "NPM_AUTH_TOKEN", "--path", "platform/x/y/z", "--fetch"], ) - assert r.exit_code == 4 - assert "not enforced" in r.stdout or "not enforced" in str(r.output) + assert r.exit_code == 6 + assert "unknown-zone fail_open" in r.output def test_cli_proxy_requires_caller_auth(monkeypatch, tmp_path): @@ -228,11 +228,27 @@ def test_cli_proxy_requires_caller_auth(monkeypatch, tmp_path): r = runner.invoke( app, ["access", "npm", "--domain", "coulomb_social", "--field", "NPM_AUTH_TOKEN", - "--path", "platform/x/y/z", "--fetch", "--no-policy"], + "--path", "platform/x/y/z", "--fetch"], ) assert r.exit_code == 3 +def test_cli_proxy_rejects_retired_no_policy_bypass(monkeypatch, tmp_path): + _proxy_env(monkeypatch, tmp_path) + monkeypatch.setenv("VAULT_TOKEN", "caller") + monkeypatch.setattr( + "warden.proxy.subprocess.run", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("fetch ran despite retired flag")), + ) + r = runner.invoke( + app, + ["access", "npm", "--domain", "coulomb_social", "--field", "NPM_AUTH_TOKEN", + "--path", "platform/x/y/z", "--fetch", "--no-policy"], + ) + assert r.exit_code == 2 + assert "--no-policy is retired" in r.output + + # --- T4: login lane -------------------------------------------------------- def test_cli_login_lane_runs_without_token_or_policy_ack(monkeypatch, tmp_path): @@ -281,6 +297,7 @@ def test_invalid_lane_rejected(tmp_path): id="x", title="t", need_keywords=["k"], owner_repo="o", subsystem="s", warden_executes=False, wiki_ref="w", canon_ref="c", reviewed="2026-06-27", status="active", lane="bogus", + workload_ref={"applicability": "not-applicable", "reason": "fixture"}, ) p = tmp_path / "c.yaml" p.write_text(yaml.dump({"version": 1, "entries": [entry]})) @@ -361,7 +378,7 @@ def test_access_fetch_to_nonterminal_stdout_is_refused(tmp_path, monkeypatch): lambda *a, **k: (_ for _ in ()).throw(AssertionError("fetch ran despite stdout guard")), ) # CliRunner captures stdout (not a tty), so the guard trips without --unsafe-stdout. - r = runner.invoke(app, ["access", "whynot-design-npm-publish", "--fetch", "--no-policy"]) + r = runner.invoke(app, ["access", "whynot-design-npm-publish", "--fetch"]) assert r.exit_code == 6 assert "sanctioned transport" in r.output.lower() or "refusing" in r.output.lower() @@ -378,7 +395,7 @@ def test_access_fingerprint_masks_and_bypasses_stdout_guard(monkeypatch, tmp_pat monkeypatch.setattr("warden.proxy.subprocess.run", lambda *a, **k: _Fake()) r = runner.invoke( app, - ["access", "whynot-design-npm-publish", "--fingerprint", "--no-policy"], + ["access", "whynot-design-npm-publish", "--fingerprint"], ) assert r.exit_code == 0 assert "top-secret-token-value" not in r.output # value never shown @@ -395,7 +412,7 @@ def test_access_agent_high_risk_raw_stream_refused(tmp_path, monkeypatch): app, [ "access", "railiance-backup-offsite-lane", - "--fetch", "--no-policy", "--unsafe-stdout", + "--fetch", "--unsafe-stdout", ], ) assert r.exit_code == 7, r.output @@ -415,7 +432,7 @@ def test_access_agent_high_risk_fingerprint_allowed(tmp_path, monkeypatch): monkeypatch.setattr("warden.proxy.subprocess.run", lambda *a, **k: _Fake()) r = runner.invoke( app, - ["access", "railiance-backup-offsite-lane", "--fingerprint", "--no-policy"], + ["access", "railiance-backup-offsite-lane", "--fingerprint"], ) assert r.exit_code == 0, r.output assert "should-not-appear" not in r.output diff --git a/tests/test_routing.py b/tests/test_routing.py index aedfa01..ba57b9b 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -4,6 +4,7 @@ No test here requires a live subsystem — routing is a read-only pointer layer. """ import json import re +from datetime import date from pathlib import Path import pytest @@ -11,10 +12,9 @@ import yaml from typer.testing import CliRunner from warden.cli import app -from datetime import date - from warden.routing import CatalogError, load_catalog from warden.routing.catalog import days_since_review, find_catalog_path, is_review_stale +from warden.scorecard import check_catalog_rotation_coverage runner = CliRunner() @@ -40,6 +40,10 @@ SSH_ENTRY = { "canon_ref": "net-kingdom/docs/x.md", "reviewed": "2026-06-18", "status": "active", + "workload_ref": { + "applicability": "not-applicable", + "reason": "generic certificate action", + }, "cert_command": "warden sign --pubkey ", "steps": ["confirm inventory", "sign"], } @@ -55,6 +59,10 @@ ROUTED_ENTRY = { "canon_ref": "net-kingdom/docs/x.md", "reviewed": "2026-06-18", "status": "active", + "workload_ref": { + "applicability": "not-applicable", + "reason": "generic credential pattern", + }, } @@ -76,6 +84,45 @@ def test_real_catalog_has_one_executed_lane(): assert [e.id for e in executed] == ["ssh-cert-host-access"] +def test_every_catalog_lane_declares_workload_applicability(): + catalog = load_catalog(_repo_catalog()) + assert all(entry.workload_ref is not None for entry in catalog.entries) + assert {entry.workload_ref.resolution for entry in catalog.entries} == { + "resolved", "unknown", "not-applicable" + } + + +def test_managed_and_operational_workload_references_parse(): + catalog = load_catalog(_repo_catalog()) + managed = catalog.get("issue-core-ingestion-api-key").workload_ref + assert managed.resolution == "resolved" + assert (managed.rapp_id, managed.name, managed.deployable) == ( + "rapp-issue-core", "issue-core", "issue-core" + ) + operational = catalog.get("ops-warden-warden-sign-token").workload_ref + assert operational.resolution == "resolved" + assert operational.rapp_id is None + assert operational.name == "ops-warden" + assert operational.declaration_ref == "tenancy.yaml" + + +def test_workload_reference_rejects_ambiguous_absence(tmp_path): + bad = dict(ROUTED_ENTRY) + bad.pop("workload_ref") + with pytest.raises(CatalogError, match="workload_ref"): + load_catalog(_write_catalog(tmp_path, [bad])) + + +def test_workload_reference_rejects_malformed_managed_target(tmp_path): + bad = dict(ROUTED_ENTRY) + bad["workload_ref"] = { + "applicability": "applicable", + "rapp_id": "rapp-issue-core", + } + with pytest.raises(CatalogError, match="requires name"): + load_catalog(_write_catalog(tmp_path, [bad])) + + def test_ops_warden_warden_sign_lane_has_native_exec(): """RAILIANCE-WP-0005 T08 — broker lane routes to railiance-platform credential exec.""" catalog = load_catalog(_repo_catalog()) @@ -454,9 +501,6 @@ def test_every_entry_has_reviewed_date(): # Rotation / re-establishment guidance registry (WARDEN-WP-0026 T06) # --------------------------------------------------------------------------- -from warden.scorecard import check_catalog_rotation_coverage - - def test_every_active_vending_lane_has_rotation_guidance(): """Coverage gate: an active lane that vends a secret must say how to renew it.""" catalog = load_catalog(_repo_catalog()) @@ -774,6 +818,32 @@ def test_unrecognised_grade_is_treated_as_high(): assert entry.is_graded is False +def test_ungraded_risk_uses_maturity_derived_zone_default(): + entry = _bare_entry() + assert entry.risk_for_zone( + effective_zone="z0-experimental", + admission="satisfied", + synthetic_only=True, + ) == "standard" + assert entry.risk_for_zone( + effective_zone="z0-experimental", + admission="unknown", + synthetic_only=True, + ) == "high" + assert entry.risk_for_zone( + effective_zone="z3-critical", + admission="satisfied", + ) == "critical" + assert entry.risk_for_zone(effective_zone="unknown") == "high" + + +def test_explicit_risk_grade_always_wins_over_zone_default(): + entry = _bare_entry(risk="standard") + assert entry.risk_for_zone( + effective_zone="z3-critical", admission="satisfied" + ) == "standard" + + def test_low_risk_vocabulary_is_explicit(): for grade in ("standard", "low", "accepted"): entry = _bare_entry(risk=grade) @@ -870,6 +940,11 @@ def test_cli_route_gaps_fail_on_stale_exits_3(repo_catalog_env): assert result.exit_code == 3 rows = json.loads(result.stdout) assert any(r["stale"] for r in rows) - # A lane can be stale on age or on never having been verified; both must be - # expressible, or asked-and-waiting silently passes the gate. - assert any(r["stale"] and r["days_since_review"] == 0 for r in rows) + # A freshly reviewed lane can still be stale because it was never verified; + # asked-and-waiting must not silently pass the gate as the calendar moves. + assert any( + r["stale"] + and r["days_since_review"] <= 1 + and r["verified"] == "asked-and-waiting" + for r in rows + ) diff --git a/tests/test_taint.py b/tests/test_taint.py index 362eb79..5c7982a 100644 --- a/tests/test_taint.py +++ b/tests/test_taint.py @@ -1,10 +1,6 @@ """Tests for EXPOSED taint convention (WARDEN-WP-0026 T05).""" from __future__ import annotations -import json -from pathlib import Path - -import pytest from typer.testing import CliRunner from warden.cli import app diff --git a/tests/test_workload_join.py b/tests/test_workload_join.py new file mode 100644 index 0000000..7b6d9e7 --- /dev/null +++ b/tests/test_workload_join.py @@ -0,0 +1,52 @@ +"""Explicit lane-to-workload join tests (WARDEN-WP-0032 / RMGR-WP-0010-T06).""" +from pathlib import Path + +import yaml + +from scripts.report_workload_join import build + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_repo_catalog_uses_only_explicit_workload_references(): + report = build(ROOT / "registry/routing/catalog.yaml", Path.home()) + assert report["ok"] is True + assert len(report["resolved"]) == 3 + assert len(report["unknown"]) == 17 + assert len(report["not_applicable"]) == 7 + assert {row["lane"] for row in report["resolved"]} == { + "ops-warden-warden-sign-token", + "issue-core-ingestion-api-key", + "rapp-qonto-keycape-client", + } + + +def test_invalid_exact_deployable_resolves_unknown(tmp_path): + rapp = tmp_path / "rapp-x" / "declarations" + rapp.mkdir(parents=True) + (rapp / "rapp.yaml").write_text(yaml.safe_dump({ + "rapp_id": "rapp-x", + "workload_identity": {"name": "x"}, + "composition": {"member_repos": [{"deployables": ["api"]}]}, + })) + catalog_dir = tmp_path / "ops-warden" / "registry" / "routing" + catalog_dir.mkdir(parents=True) + catalog = catalog_dir / "catalog.yaml" + catalog.write_text(yaml.safe_dump({"entries": [{ + "id": "x", + "workload_ref": { + "applicability": "applicable", + "rapp_id": "rapp-x", + "name": "x", + "deployable": "missing", + }, + }]})) + posture = catalog_dir.parent / "policy" + posture.mkdir() + (posture / "security-posture.yaml").write_text("dataclass_floor: {}\n") + + report = build(catalog, tmp_path) + assert not report["resolved"] + assert report["unknown"][0]["lane"] == "x" + assert "deployable" in report["unknown"][0]["reason"] diff --git a/wiki/NetKingdomSecurityMap.md b/wiki/NetKingdomSecurityMap.md index 31ffa0e..cc27337 100644 --- a/wiki/NetKingdomSecurityMap.md +++ b/wiki/NetKingdomSecurityMap.md @@ -135,11 +135,12 @@ schedule rather than in lockstep. The binding condition is sequencing, not a dat flex-auth warn-only -> ops-warden pre-sign gate presents its SA token -> logs clean of unauthenticated callers -> flex-auth fail-closed - -> policy.enabled: true (ops-warden side, FLEX-WP-0007) + -> zone-specific enforce stance (flex-auth policy package) ``` -`policy.enabled` must not flip anywhere while `/v1/check` still answers -unauthenticated callers. +An enforce stance must not be assigned while `/v1/check` still answers +unauthenticated callers. ops-warden has no global enable switch or gate bypass; +it applies the compiled zone stance and its local per-zone failure mode. **Division of the call:** the mechanism above is an architecture recommendation and ops-warden's to make. Accepting the pod-spec change and the rollout timing are the @@ -179,4 +180,4 @@ and automation work — not platform-admin equivalents on hosts. - `wiki/AccessRouting.md` — issue-vs-route role and boundary - `wiki/CredentialRouting.md` - `wiki/PolicyGatedSigning.md` (future flex-auth hook) -- `net-kingdom/docs/platform-identity-security-architecture.md` \ No newline at end of file +- `net-kingdom/docs/platform-identity-security-architecture.md` diff --git a/wiki/OperatorAccessAssist.md b/wiki/OperatorAccessAssist.md index 201dec7..847ce84 100644 --- a/wiki/OperatorAccessAssist.md +++ b/wiki/OperatorAccessAssist.md @@ -76,7 +76,7 @@ prevent, and duplicates OpenBao. | --- | --- | --- | | **G1** | **Caller identity, never warden's** | The proxy runs the owner's tool with the caller's own environment; ops-warden injects no token of its own. Secret lanes require the caller to already hold a credential (`caller_auth_present`), else they fail with the auth pointer. | | **G2** | **Transit only — no persistence/logging of values** | Sanctioned transports keep the value off stdout: `--out` writes it to a mode-0600 file, `--exec` injects it into a child process env, `--wrap` returns a single-use OpenBao wrapping token (not the value). Raw `--fetch` to stdout is refused for captured/piped output (`--unsafe-stdout` overrides for a human terminal). warden never writes the value to disk or log; the audit record is **metadata only**. (WP-0026 T02) | -| **G3** | **Policy gate before fetch** | `check_fetch_policy` (flex-auth) runs before any secret-lane fetch. With `policy.enabled: false` the proxy refuses unless `--no-policy` is given to acknowledge proxying ungated. | +| **G3** | **Policy gate before fetch** | `check_fetch_policy` (flex-auth) runs before every secret-lane fetch. Zone membership selects stance and local failure mode; an unresolved workload uses the explicit `unknown` profile. The retired `--no-policy` flag is rejected, so this gate has no CLI bypass. | The catalog side enforces a fourth, upstream guard: **handoff fields are templates, never values.** `_assert_no_secret_material` rejects any known token prefix or diff --git a/wiki/OpsWardenConfig.md b/wiki/OpsWardenConfig.md index 4d184a1..4f65114 100644 --- a/wiki/OpsWardenConfig.md +++ b/wiki/OpsWardenConfig.md @@ -36,11 +36,19 @@ ca_key: ~/.ssh/ops-ca-user inventory_path: ~/.config/warden/inventory.yaml state_dir: ~/.local/state/warden -# Optional flex-auth gate (default off — see wiki/PolicyGatedSigning.md) +# Zone-aware flex-auth gate. With no URL, the explicit unknown/build profile +# fails open and records that no evaluator decision was available. policy: - enabled: false flex_auth_url: http://127.0.0.1:8080 - fail_closed: true + zone_registry_path: /path/to/compiled-flex-auth-registry.json + failure_modes: + z0-experimental: fail_open + z1-operational: fail_open + z2-protected: fail_open + z2-continuity: fail_open + z3-critical: fail_closed + unknown: fail_open + not-applicable: fail_closed ``` ### Bootstrapping the local CA key @@ -85,11 +93,10 @@ vault: inventory_path: ~/.config/warden/inventory.yaml state_dir: ~/.local/state/warden -# Enable after flex-auth ssh-certificate policies are deployed: +# Configure after flex-auth ssh-certificate policies are deployed: # policy: -# enabled: true -# flex_auth_url: http://flex-auth.flex-auth.svc.cluster.local:8080 -# fail_closed: true +# flex_auth_url: http://flex-auth-ops-warden.flex-auth.svc.cluster.local:8080 +# zone_registry_path: /etc/warden/production_registry_snapshot.json ``` ### Example — in-cluster caller (pod or trusted host) @@ -235,22 +242,36 @@ hosts: --- -## Policy gate (flex-auth, opt-in) +## Policy gate (flex-auth, zone-aware) -When `policy.enabled: true`, `warden sign` and `warden issue` call flex-auth -`POST /v1/check` before signing. Deny or unreachable (with `fail_closed: true`) -blocks issuance. Allowed decisions store `policy_decision_id` in `signatures.log`. +`warden sign` and `warden issue` evaluate flex-auth whenever a URL is configured. +The target resource's compiled `security_zone` selects the local PEP failure +mode. A rendered deny always blocks; evaluator failure blocks or proceeds per +zone. Signing records `policy_decision_id` when present plus `policy_zone`, +`policy_failure_mode`, and `policy_outcome`. ```yaml policy: - enabled: false # default — no behavior change flex_auth_url: http://127.0.0.1:8080 - fail_closed: true # deny when flex-auth unreachable + zone_registry_path: registry/flex-auth/production_registry_snapshot.json + failure_modes: + z0-experimental: fail_open + z1-operational: fail_open + z2-protected: fail_open + z2-continuity: fail_open + z3-critical: fail_closed + unknown: fail_open + not-applicable: fail_closed tenant: tenant:platform subject_env: WARDEN_POLICY_SUBJECT system: ops-warden ``` +`policy.enabled` and the global `policy.fail_closed` are retired; configuration +loading rejects them with a migration error. Stance is owned by flex-auth's +versioned policy package, not this block. The failure-mode map is PEP behavior +for an unavailable or invalid evaluator. + Full request shape and rollout notes: `wiki/PolicyGatedSigning.md`. --- @@ -261,7 +282,7 @@ Full request shape and rollout notes: `wiki/PolicyGatedSigning.md`. |----------|---------|-------------| | `WARDEN_CONFIG` | `~/.config/warden/warden.yaml` | Config file path | | `VAULT_TOKEN` | — | API token for `backend: vault` (OpenBao or Vault; name configurable via `vault.token_env`) | -| `WARDEN_POLICY_SUBJECT` | — | IAM subject id for flex-auth checks (when `policy.enabled`) | +| `WARDEN_POLICY_SUBJECT` | — | IAM subject id for flex-auth checks | --- @@ -284,4 +305,4 @@ tunnels: `ops-bridge` runs `cert_command` before each SSH launch, captures stdout as the cert, and passes it alongside the private key via `ssh -i -i `. See `wiki/CertCommandInterface.md` for the full contract and -`wiki/playbooks/ops-bridge-tunnel-cert.md` for static-key → cert_command migration. \ No newline at end of file +`wiki/playbooks/ops-bridge-tunnel-cert.md` for static-key → cert_command migration. diff --git a/wiki/PolicyGatedSigning.md b/wiki/PolicyGatedSigning.md index b28d188..b711a32 100644 --- a/wiki/PolicyGatedSigning.md +++ b/wiki/PolicyGatedSigning.md @@ -1,347 +1,196 @@ -# Policy-Gated SSH Signing +# Zone-aware policy-gated signing -Date: 2026-06-23 -Status: **implemented (opt-in)** — WARDEN-WP-0007; policy package confirmed FLEX-WP-0006 +Ops-warden asks flex-auth for a decision before SSH certificate issuance. The +gate is resource-scoped through security-zone membership; there is no repo-wide +enable switch. -By default `warden sign` authorizes via **inventory allow-list** and TTL policy -only. When `policy.enabled: true` in `warden.yaml`, ops-warden calls flex-auth -before signing and records the decision id in `signatures.log`. +Authority stays split: ---- +- flex-auth owns the versioned pre-sign stance (`enforced`, `advisory`, or + `exempt`) and returns the decision; +- ops-warden owns what the PEP does when flex-auth is unavailable or invalid; +- the workload owner declares identity and zone membership; +- zone-engine owns `security-zones_v0.1` admission and resolution semantics. -## Flow +Binding decisions: `ADR-0009` (current) and `ADR-0006` (superseded rationale). + +## Request path ```text -warden sign --pubkey - | - v -Load actor from inventory (type, principals, ttl) - | - v -policy.enabled? - no -> skip - yes -> flex-auth POST /v1/check - | - +-- DENY / unreachable (fail_closed) -> CAError - | - v ALLOW -CABackend.sign() (local or OpenBao SSH engine) - | - v -Append signatures.log (+ policy_decision_id when set) +warden sign + -> inventory, principal, actor-type, and TTL checks + -> resource id ssh-cert:actor/ + -> read compiled security_zone for that resource + -> POST flex-auth /v1/check with authenticated caller identity + allow -> sign; record decision and zone evidence + audit_only -> sign; record advisory decision and zone evidence + deny -> refuse before the CA backend + unavailable/invalid + -> apply that zone's PEP failure mode + -> record fail_open when issuance proceeds ``` -The same gate runs for `warden issue` (local backend only). +The request contains actor id/type, requested principals, TTL, and a SHA-256 +fingerprint of the public key. It never contains a private key or secret value. ---- +## Compiled membership -## flex-auth request shape - -| Field | Source | -| --- | --- | -| `subject.id` | `WARDEN_POLICY_SUBJECT` env var, or actor name | -| `subject.type` | Actor type (`adm` / `agt` / `atm`) | -| `tenant` | `policy.tenant` (default `tenant:platform`) | -| `resource.id` | `ssh-cert:actor/` | -| `resource.type` | `ssh-certificate` | -| `action` | `sign` | -| `context.principals` | From inventory | -| `context.actor_type` | adm \| agt \| atm | -| `context.pubkey_fingerprint` | SHA256 of pubkey text | -| `context.ttl_hours` | Requested TTL | - -flex-auth must return `effect: allow` and an `id` (or `request_id`) on allow. -Deny responses include a `reason` surfaced in the CLI error. - ---- - -## Configuration +`scripts/build_flex_auth_registry.py` compiles inventory actor resources. Each +actor carries an explicit `zone_subject`: ```yaml -# warden.yaml — policy gate (opt-in, default off) -policy: - enabled: false - flex_auth_url: http://127.0.0.1:8080 - fail_closed: true - tenant: tenant:platform - subject_env: WARDEN_POLICY_SUBJECT - system: ops-warden - caller_auth: # how ops-warden identifies itself — see "Caller identity" - mode: none +actors: + agt-state-hub-bridge: + type: agt + principals: [agt-task-bridge] + ttl_hours: 24 + zone_subject: + applicability: applicable + workload_id: ops-bridge-tunnel ``` -| Key | Default | Description | -| --- | --- | --- | -| `enabled` | `false` | When `true`, call flex-auth before every sign/issue | -| `flex_auth_url` | `http://127.0.0.1:8080` | flex-auth base URL | -| `fail_closed` | `true` | Deny sign when flex-auth is unreachable or returns HTTP error | -| `tenant` | `tenant:platform` | Tenant sent in subject and resource | -| `subject_env` | `WARDEN_POLICY_SUBJECT` | Env var for IAM subject id override | -| `system` | `ops-warden` | Resource system identifier | -| `caller_auth.mode` | `none` | `none` \| `file` \| `env` \| `command` — source of the `Authorization` bearer token flex-auth TokenReviews ([Caller identity](#caller-identity-warden-wp-0031)) | +The compiler consumes zone-engine's resolved view when available and emits +resource attributes: -Set `WARDEN_POLICY_SUBJECT` to the caller's IAM profile `sub` when available. -If unset, the actor name is used as subject id. - ---- - -## Versioning - -| Version | Gate | Status | -| --- | --- | --- | -| **v1** | Inventory + TTL max | Shipped | -| **v2** | flex-auth opt-in via `policy.enabled` | Shipped (WP-0007) | -| **v2.1** | Identity claims required for `adm` signs | Planned | -| **v3** | Tenant-scoped policies per `tenant:*` | Planned | - ---- - -## What stays in inventory - -- Actor registration (name, type, default principals, default TTL) -- Host reference documentation -- Scorecard local checks - -flex-auth decides **whether this sign request is allowed now**; inventory -defines **what the actor is allowed to request**. - ---- - -## flex-auth policy package (FLEX-WP-0006) - -flex-auth owns the `ssh-certificate` / `sign` policy package. ops-warden consumes -it via `POST /v1/check` when `policy.enabled: true`. - -**Handoff (canonical):** `~/flex-auth/docs/ops-warden-policy-gate-handoff.md` - -| Asset | flex-auth path | -| --- | --- | -| Policy package | `examples/ops-warden/policy_package.md` | -| Allow/deny fixtures | `examples/ops-warden/policy_fixtures.yaml` | -| Registry snapshot | `examples/ops-warden/registry_snapshot.json` | -| Subject manifest | `examples/ops-warden/subject_manifest.yaml` | -| Resource manifest | `examples/ops-warden/resource_manifest.yaml` | - -### Tenant and subject bindings - -| Field | Value | -| --- | --- | -| Tenant | `tenant:platform` (`policy.tenant`) | -| Resource system | `ops-warden` (`policy.system`) | -| Resource type | `ssh-certificate` | -| Action | `sign` | -| Resource id | `ssh-cert:actor/` | - -| Actor type | Example flex-auth subject | ops-warden inventory name pattern | -| --- | --- | --- | -| `adm` | `platform-steward` | `adm-*` | -| `agt` | `ci-deploy-agent` | `agt-*` | -| `atm` | `backup-automation` | `atm-*` | - -**Subject id sent to flex-auth:** `WARDEN_POLICY_SUBJECT` when set, otherwise the -inventory actor name. flex-auth may also allow `iam:` when listed in -`allowed_subjects` on the resource. - -**Principals and TTL:** Taken from the sign request (inventory defaults). flex-auth -denies when principals are empty/disallowed or TTL exceeds `max_ttl_hours` on the -registered resource. - -### Fixture coverage (flex-auth) - -Allow: `fixture:ops-warden-adm-sign-allow`, `fixture:ops-warden-agt-sign-allow`, -`fixture:ops-warden-atm-sign-allow`. - -Deny: `fixture:ops-warden-unknown-subject-deny`, -`fixture:ops-warden-actor-type-mismatch-deny`, `fixture:ops-warden-ttl-above-max-deny`, -`fixture:ops-warden-disallowed-principal-deny`, -`fixture:ops-warden-missing-fingerprint-deny`. - -### Local smoke - -```bash -# flex-auth (from ~/flex-auth) -flex-auth serve --addr 127.0.0.1:8080 \ - --registry examples/ops-warden/registry_snapshot.json \ - --policy examples/ops-warden/policy_package.md \ - --log /tmp/flex-auth-ops-warden-decisions.jsonl - -# warden.yaml — policy.enabled: true, flex_auth_url pointing at flex-auth -# Use an actor registered in the flex-auth registry (example fixtures use -# template names; production needs a registry slice for real inventory actors). +```json +{ + "workload_id": "ops-bridge-tunnel", + "security_zone": "z2-continuity", + "security_zone_admission": "satisfied", + "security_zone_revision": "sha256:..." +} ``` -Local end-to-end evidence: `history/2026-06-23-flex-auth-policy-gate-local-smoke.md`. +If the workload reference or resolved membership is absent, the resource says +`security_zone: unknown` with a reason. A native non-workload actor/action says +`security_zone_admission: not-applicable`. The compiler never parses a path or +repository name to repair missing identity. -### Production registry from inventory +`trust_zone: platform` was a dormant, unrelated field and is retired. It must +not coexist with `security_zone` as a competing membership source. -Build a flex-auth registry snapshot that mirrors `inventory.yaml` actors: +Build the snapshot: ```bash -python scripts/build_flex_auth_registry.py ~/.config/warden/inventory.yaml \ +python3 scripts/build_flex_auth_registry.py \ + ~/.config/warden/inventory.yaml \ + --zone-resolutions /path/to/zone-resolved-view.json \ -o registry/flex-auth/production_registry_snapshot.json -flex-auth load-registry --file registry/flex-auth/production_registry_snapshot.json ``` -Re-run after adding or changing actors. Deploy the snapshot to the production -flex-auth runtime together with `~/flex-auth/examples/ops-warden/policy_package.md`. +Omitting `--zone-resolutions` is safe: applicable actors resolve `unknown`, not +to a guessed zone. -Smoke (non-secret): +## PEP failure modes -```bash -./scripts/policy_gate_production_smoke.sh -# OpenBao-backed — preferred: credential broker (no manual VAULT_TOKEN): -cd ~/railiance-platform && make credential-exec-ops-warden-smoke -# Manual fallback when broker unavailable: -SMOKE_VAULT=1 ./scripts/policy_gate_production_smoke.sh -``` +The initial build profile accepted by ops-warden is: -Evidence: `history/2026-06-23-flex-auth-policy-gate-production-smoke.md`. +| Zone/result | Dependency failure | +| --- | --- | +| `z0-experimental` | `fail_open` | +| `z1-operational` | `fail_open` | +| `z2-protected` | `fail_open` | +| `z2-continuity` | `fail_open` | +| `z3-critical` | `fail_closed` | +| `unknown` | `fail_open` under the versioned build profile | +| `not-applicable` | `fail_closed` for this pre-sign PEP | ---- +These are dependency failure modes, not policy stance. A rendered deny always +blocks. The `unknown` row does not grant membership or an exception; it is the +explicit build-stage treatment until authoritative declarations land. -## Caller identity (WARDEN-WP-0031) - -flex-auth authenticates the **caller** before it evaluates the request. The -in-cluster pin `flex-auth-ops-warden` (FLEX-WP-0016) passes the bearer token to a -Kubernetes TokenReview and requires the principal -`system:serviceaccount:ops-warden:ops-warden` for any request carrying -`resource.system: ops-warden`. - -Until ops-warden sends that header the pin logs - -``` -caller authentication warning: caller is not authenticated -``` - -and can only run `callerAuth.mode: warn`. Per ADHOC-2026-08-17-T01, -`policy.enabled` must not flip while `/v1/check` still answers unauthenticated -callers — so **the missing header is what blocks the flip**, and it is -ops-warden's to fix, not flex-auth's. - -### Configure a token source +Configuration: ```yaml policy: + flex_auth_url: http://127.0.0.1:19090 + zone_registry_path: registry/flex-auth/production_registry_snapshot.json + failure_modes: + z0-experimental: fail_open + z1-operational: fail_open + z2-protected: fail_open + z2-continuity: fail_open + z3-critical: fail_closed + unknown: fail_open + not-applicable: fail_closed caller_auth: - mode: none # none | file | env | command - token_path: /var/run/secrets/flex-auth/token # mode: file - token_env: WARDEN_POLICY_CALLER_TOKEN # mode: env - command: kubectl create token ops-warden -n ops-warden --audience flex-auth --duration 10m - audience: flex-auth + mode: command + command: + - kubectl + - create + - token + - ops-warden + - -n + - ops-warden + - --audience + - flex-auth + - --duration + - 10m ``` -| Mode | Use it when | -| --- | --- | -| `none` | Default. No header — pre-FLEX-WP-0016 behaviour, only viable while the pin is in `warn` | -| `file` | In-cluster PEP with a projected, audience-bound ServiceAccount token | -| `env` | The token is already in the environment (CI, a wrapper) | -| `command` | Workstation `warden sign` — mint a short-lived bound token per call | +`policy.enabled` and the global `policy.fail_closed` are retired. The loader +rejects either key so old and new controls cannot coexist as two sources of +truth. -A workstation is not a ServiceAccount, which is why `command` exists. The token is -read, sent, and dropped: never cached, written, or logged (ADR-0002). If a token -is configured but cannot be obtained, the sign is **refused** under -`fail_closed` — falling back to an anonymous call would defeat the gate. +## Caller identity -### Readiness gate +The production flex-auth pin authenticates ops-warden with Kubernetes +TokenReview and binds `resource.system: ops-warden` to +`system:serviceaccount:ops-warden:ops-warden`. Supported token sources are: + +- `file` — projected ServiceAccount token for an in-cluster PEP; +- `command` — short-lived `kubectl create token` on a workstation; +- `env` — attended fallback; +- `none` — no identity header; only useful for an intentionally unauthenticated + development evaluator. + +Tokens are resolved per call, never cached, logged, or echoed. Under a +fail-closed zone, an unavailable caller token blocks. Under a fail-open zone it +becomes a recorded evaluator failure; ops-warden never retries anonymously with +a secret copied into its own state. + +Re-establish the value-safe caller proof: ```bash -python scripts/check_policy_caller_identity.py # offline -python scripts/check_policy_caller_identity.py --url http://127.0.0.1:19090 # port-forward of the warn pin +python3 scripts/check_policy_caller_identity.py \ + --url http://127.0.0.1:19090 ``` -Exit 0 ready / 1 not ready / 2 bad input. It prints the token's length and a -truncated fingerprint, never the value, so its output is safe to paste into a -handoff message. A live `401` means the token was sent but rejected (audience or -binding); `403` means it authenticated but may not represent `system: ops-warden`. +Expected evidence is HTTP 200 with a decision id and anonymous HTTP 401 on the +enforcing pin. The script reports only token length and a truncated fingerprint. -### Enablement is deferred by decision (ADR-0006) +## Audit evidence -`policy.enabled` is **false, and that is a decision rather than a blocker.** The -gate is ready: flex-auth's pin runs `callerAuth.mode: enforce`, the readiness -gate exits 0 against it, and an anonymous `/v1/check` returns 401. +Successful signing records: -It is not enabled because `policy.enabled` is one boolean over the whole repo, -and with `fail_closed: true` it makes flex-auth a hard dependency of every -`warden sign` — including the certs the ops-bridge tunnels depend on, one of -which carries the policy call. Uniform enforcement across an estate under active -refactor hardens the access needed to perform the refactor. +- `policy_decision_id` when flex-auth returned one; +- `policy_zone`; +- `policy_failure_mode`; +- `policy_outcome` (`allow`, `audit_only`, or `fail_open`). -`ADR-0006` scopes enforcement to security zones; `WARDEN-WP-0032` defines them. -Do not set `policy.enabled: true` outside that model — a zone-blind enablement -is out of order under the ADR, not merely inadvisable. +A fail-open result must therefore be visible rather than indistinguishable from +an unevaluated request. Denies do not reach the CA backend and produce no +certificate. -The sequence below is retained as the *mechanics*, for whoever enables the gate -for a zone once zones exist. +## Rollout and rollback -### Flip sequence +1. Validate `tenancy.yaml` and the workload declarations referenced by actor + `zone_subject` entries. +2. Compile the registry and inspect unknown/not-applicable results. +3. Run `scripts/check_policy_caller_identity.py` against the enforcing pin. +4. Deploy the same compiled registry revision and matching flex-auth policy + package. +5. Smoke an allow/advisory path, an enforced deny, and evaluator loss for one + fail-open and one fail-closed zone. -1. Configure `caller_auth`; `check_policy_caller_identity.py` exits 0 offline. -2. Smoke against a port-forward of the warn pin. The evidence is the **absence** - of `caller authentication warning` in its log, not the `allow` — warn serves - unauthenticated callers too, so an allow alone proves nothing. -3. Ask flex-auth to set `callerAuth.mode: enforce` (their FLEX-WP-0016 T03). -4. Re-run the gate against the enforcing pin. -5. Only then: `policy.enabled: true`, `fail_closed: true`. - -Reversing 3 and 5 401s every `warden sign`. - ---- - -## Production rollout - -**Keep `policy.enabled: false` until flex-auth is reachable** at `policy.flex_auth_url` -with `fail_closed: true`, unreachable flex-auth blocks all signs. - -### Operator checklist - -| Step | Owner | Action | -| --- | --- | --- | -| 1 | flex-auth | Deploy runtime; confirm `curl /healthz` → 200 (**FLEX-WP-0007**) | -| 2 | flex-auth | Load production registry + policy package (`~/flex-auth/examples/ops-warden/`) | -| 3 | ops-warden | Regenerate registry from inventory: `scripts/build_flex_auth_registry.py` | -| 4 | ops-warden | Local smoke: `./scripts/policy_gate_production_smoke.sh` | -| 5 | operator | Vault smoke: `make credential-exec-ops-warden-smoke` in `railiance-platform` (or manual `SMOKE_VAULT=1` fallback) | -| 6 | operator | Set `policy.flex_auth_url` in `~/.config/warden/warden.yaml` — the pin is `flex-auth-ops-warden.flex-auth.svc.cluster.local:8080`, reached from a workstation via port-forward or tunnel | -| 6a | ops-warden | Configure `policy.caller_auth`; `scripts/check_policy_caller_identity.py` exits 0 (see **Caller identity**) | -| 6b | flex-auth | Set `callerAuth.mode: enforce` on the ops-warden pin (FLEX-WP-0016 T03) | -| 7 | operator | Set `policy.enabled: true`; keep `fail_closed: true` | -| 8 | operator | Allow smoke: `warden sign ` — `signatures.log` has `policy_decision_id` | -| 9 | operator | Deny smoke: e.g. `--ttl` above max — CLI shows flex-auth `reason`, no cert | - -Cross-repo references: - -- `~/flex-auth/workplans/FLEX-WP-0007-ops-warden-policy-gate-production-deployment.md` -- `history/2026-06-23-flex-auth-production-pickup-suggestion.md` -- `history/2026-06-23-flex-auth-policy-gate-production-smoke.md` - -### Summary - -1. Deploy the flex-auth registry and policy package to the production flex-auth - runtime — **not** only the example fixtures. -2. Set `policy.flex_auth_url` to the production flex-auth base URL. -3. Enable `policy.enabled: true` only after steps 1–5 pass. -4. Keep `fail_closed: true` unless an explicit break-glass procedure exists. -5. Smoke allow and deny paths; preserve non-secret evidence only. - -### Rollback - -If signs are blocked after enabling the gate: - -1. Set `policy.enabled: false` in `warden.yaml` (inventory + TTL gate only). -2. Confirm `warden sign` succeeds without flex-auth. -3. File a State Hub note to `flex-auth` with non-secret symptoms (HTTP status, - `fail_closed` behaviour, actor name). -4. Re-enable only after flex-auth runtime and registry are verified. - -Evidence fields for the flip: flex-auth health URL, smoke script exit codes, -`warden activity --kind sign --json` showing `policy_decision_id` on allow path. - ---- +Rollback is a versioned profile or registry rollback. Do not reintroduce +`policy.enabled: false`: that would erase per-zone evidence and recreate the +global control ADR-0009 supersedes. ## See also -- `wiki/OpsWardenConfig.md` — full config reference -- `wiki/CredentialRouting.md` -- `~/flex-auth/docs/ops-warden-policy-gate-handoff.md` — flex-auth handoff -- `flex-auth/INTENT.md` -- `net-kingdom/docs/platform-identity-security-architecture.md` \ No newline at end of file +- `tenancy.yaml` +- `docs/evidence/security-zone-admission-2026-08-22.md` +- `wiki/OpsWardenConfig.md` +- `wiki/WorkloadSecurityPosture.md` +- `history/2026-08-19-flex-auth-caller-identity-evidence.md` diff --git a/wiki/WorkloadSecurityPosture.md b/wiki/WorkloadSecurityPosture.md index 3278a8f..399ccfc 100644 --- a/wiki/WorkloadSecurityPosture.md +++ b/wiki/WorkloadSecurityPosture.md @@ -111,6 +111,28 @@ This is the practical bridge from WARDEN-WP-0014 (`warden access`) to WP-0015: a assist can remove manual secret handling friction, while posture/maturity decides whether the secret may flow at all. +## Security-zone consumer contract + +Security zones are now the prescriptive sibling of this descriptive posture +model. `zone-engine` owns `security-zones_v0.1`; ops-warden follows it as a +consumer under `ADR-0009`. + +- Workload maturity and data classification are admission evidence, not zone + names and not control stance. +- The workload owner declares authoritative `workload_identity` and `zones:` in + the same `tenancy.yaml` service entry. +- Credential lanes and actor resources reference that workload explicitly. + Managed deployables use Repo Manager's exact `(rapp_id, name, deployable?)` + tuple; independent operational workloads reference their owner declaration. +- Missing identity, membership, or evidence resolves `unknown`. No compiler may + infer it from a path, repository owner, actor class, or environment. +- flex-auth owns pre-sign stance; ops-warden owns PEP dependency failure behavior, + the agent read boundary, and `warden plan` escalation behavior. + +Ops-warden declares `z1-operational` with M1/internal/medium evidence. That is an +accuracy statement, not a target: M2 remains unavailable until SLO history, +on-call, and incident/recovery evidence exist. + --- ## Canon layering (where each part lands) diff --git a/wiki/playbooks/agent-read-boundary.md b/wiki/playbooks/agent-read-boundary.md index 9dc5660..5e82883 100644 --- a/wiki/playbooks/agent-read-boundary.md +++ b/wiki/playbooks/agent-read-boundary.md @@ -62,9 +62,9 @@ When `WARDEN_AGENT_ID` is set and the lane is `risk: high`, `warden access --fet ```bash export WARDEN_AGENT_ID=grok # or claude, codex -warden access railiance-backup-offsite-lane --no-policy --out /tmp/nc.token -warden access railiance-backup-offsite-lane --no-policy --wrap -warden access railiance-backup-offsite-lane --no-policy --fingerprint +warden access railiance-backup-offsite-lane --out /tmp/nc.token +warden access railiance-backup-offsite-lane --wrap +warden access railiance-backup-offsite-lane --fingerprint ``` `warden route show --json` includes `"risk"` and `"high_risk"`. diff --git a/wiki/playbooks/binky-company-email-imap.md b/wiki/playbooks/binky-company-email-imap.md index 16e0857..92c22f6 100644 --- a/wiki/playbooks/binky-company-email-imap.md +++ b/wiki/playbooks/binky-company-email-imap.md @@ -47,10 +47,10 @@ Config (host/port/env names only): 2. Fetch via sanctioned transport (never paste into chat): ```bash - warden access binky-company-email-imap --all --no-policy --out /tmp/imap.user + warden access binky-company-email-imap --all --out /tmp/imap.user # primary field is IMAP_USERNAME; for password use --field after template support # or: - warden access binky-company-email-imap --all --no-policy --exec -- \ + warden access binky-company-email-imap --all --exec -- \ env IMAP_USERNAME=… # prefer secrets-engine / dual-field exec when wired ``` diff --git a/wiki/playbooks/binky-qonto-api.md b/wiki/playbooks/binky-qonto-api.md index 37176cc..020fb0e 100644 --- a/wiki/playbooks/binky-qonto-api.md +++ b/wiki/playbooks/binky-qonto-api.md @@ -53,7 +53,7 @@ Design: `binky-control/integrations/qonto-mcp.md` ```bash # lengths / presence only when debugging - warden access binky-qonto-api --all --no-policy --exec -- \ + warden access binky-qonto-api --all --exec -- \ sh -c 'export QONTO_API_KEY="$API_KEY" QONTO_ORGANIZATION_ID="$API_USER"; # then: qonto-mcp-server or curl thirdparty with Authorization login:key :' diff --git a/wiki/playbooks/ops-bridge-tunnel-cert.md b/wiki/playbooks/ops-bridge-tunnel-cert.md index 2e190c2..a23d447 100644 --- a/wiki/playbooks/ops-bridge-tunnel-cert.md +++ b/wiki/playbooks/ops-bridge-tunnel-cert.md @@ -122,10 +122,11 @@ bridge up state-hub-railiance01 - [ ] Tunnel establishes without static cert file on disk - [ ] Re-run `bridge up` after cert TTL expires — `cert_command` re-issues automatically -### 5. Policy gate (optional, after FLEX-WP-0007) +### 5. Zone-aware policy evidence -When `policy.enabled: true`, confirm `signatures.log` includes `policy_decision_id` -on tunnel-driven signs. See `wiki/PolicyGatedSigning.md`. +Confirm `signatures.log` records `policy_zone`, `policy_failure_mode`, +`policy_outcome`, and `policy_decision_id` when flex-auth returns a decision on +tunnel-driven signs. See `wiki/PolicyGatedSigning.md`. --- @@ -177,4 +178,4 @@ starting cutover (WARDEN-WP-0023). - `wiki/OpsWardenConfig.md` — cert_command example - `wiki/playbooks/operator-openbao-token-hygiene.md` - `wiki/AuditTrail.md` — query recent signs via `warden activity` -- `warden route show ops-bridge-tunnel --json` \ No newline at end of file +- `warden route show ops-bridge-tunnel --json` diff --git a/wiki/playbooks/railiance-backup-offsite-lane.md b/wiki/playbooks/railiance-backup-offsite-lane.md index 5bde5ee..0e5a2e2 100644 --- a/wiki/playbooks/railiance-backup-offsite-lane.md +++ b/wiki/playbooks/railiance-backup-offsite-lane.md @@ -35,9 +35,9 @@ Used by `railiance-backup` (workstation) and `forgejo-backup` (platform). ```bash # Preferred: file or exec injection via warden - warden access railiance-backup-offsite-lane --no-policy --out /tmp/nc.token + warden access railiance-backup-offsite-lane --out /tmp/nc.token # or: - warden access railiance-backup-offsite-lane --no-policy --exec -- env | grep -v . + warden access railiance-backup-offsite-lane --exec -- env | grep -v . ``` If you must use raw bao in an interactive human shell (not an agent session): @@ -54,7 +54,7 @@ Used by `railiance-backup` (workstation) and `forgejo-backup` (platform). 3. **Proxy via warden access** (catalog active + resolvable): ```bash - warden access railiance-backup-offsite-lane --no-policy --fetch --out /tmp/nc.token + warden access railiance-backup-offsite-lane --fetch --out /tmp/nc.token # Primary field is NC_WEBDAV_TOKEN. AGE_PRIVATE_KEY is recovery escrow only. ``` diff --git a/wiki/playbooks/reuse-surface-hub-write-token.md b/wiki/playbooks/reuse-surface-hub-write-token.md index cfb5f3d..8907aa0 100644 --- a/wiki/playbooks/reuse-surface-hub-write-token.md +++ b/wiki/playbooks/reuse-surface-hub-write-token.md @@ -61,7 +61,7 @@ Never paste values into chat, State Hub, workplans, or Git. 3. **Or proxy via warden access** (same `bao kv get`, audited metadata only): ```bash warden route show reuse-surface-hub-write-token --json - warden access reuse-surface-hub-write-token --no-policy --fetch + warden access reuse-surface-hub-write-token --fetch ``` 4. **Webhook HMAC** (same path, second field — must match Forgejo org webhook): @@ -87,4 +87,4 @@ Never paste values into chat, State Hub, workplans, or Git. Rotation: `railiance-platform/docs/reuse-surface-runtime-secrets-rotation-runbook.md` (OpenBao patch → ESO `force-sync` → hub rollout → `make reuse-forgejo-webhook` when the webhook HMAC changes → `make reuse-webhook-smoke`). Lifecycle: -`railiance-platform/docs/credential-lane-lifecycle-runbook.md` (CCR-2026-0005). \ No newline at end of file +`railiance-platform/docs/credential-lane-lifecycle-runbook.md` (CCR-2026-0005). diff --git a/wiki/playbooks/tenant-secret-onboarding.md b/wiki/playbooks/tenant-secret-onboarding.md index cf23d41..5105040 100644 --- a/wiki/playbooks/tenant-secret-onboarding.md +++ b/wiki/playbooks/tenant-secret-onboarding.md @@ -69,7 +69,7 @@ First worked lane: `tenants/binky/company-email/imap` (CCR-2026-0007). ```bash bao login -method=oidc -path=netkingdom role=-workload-kv-read -warden access --no-policy --out /tmp/secret.file # mode 0600 +warden access --out /tmp/secret.file # mode 0600 # agents: never raw --fetch stream; WARDEN_AGENT_ID + risk=high → exit 7 ``` diff --git a/wiki/playbooks/whynot-design-npm-publish.md b/wiki/playbooks/whynot-design-npm-publish.md index 228e9b5..af0c92a 100644 --- a/wiki/playbooks/whynot-design-npm-publish.md +++ b/wiki/playbooks/whynot-design-npm-publish.md @@ -48,11 +48,10 @@ this token** — it is the access front door: `warden access` proxies the read f **ops-warden transparent fallback** — same lane via the `warden access` proxy (fetches as you, holds nothing). Field-verified flags (whynot-design, @whynot/design@0.4.0): ```bash - # --exec needs the env-var name; --no-policy is required while the gate is advisory - # (policy.enabled=false), else the call exits 4. - warden access whynot-design-npm-publish --no-policy --field NPM_AUTH_TOKEN \ + # --exec needs the env-var name. The zone-aware policy gate always runs first. + warden access whynot-design-npm-publish --field NPM_AUTH_TOKEN \ --exec -- npm publish - warden access whynot-design-npm-publish --no-policy --field NPM_AUTH_TOKEN --fetch + warden access whynot-design-npm-publish --field NPM_AUTH_TOKEN --fetch ``` On either path the value transits to you (or the child env) and never enters ops-warden's memory, disk, or audit log. diff --git a/workplans/WARDEN-WP-0032-security-zones.md b/workplans/WARDEN-WP-0032-security-zones.md index 99f0326..afc475f 100644 --- a/workplans/WARDEN-WP-0032-security-zones.md +++ b/workplans/WARDEN-WP-0032-security-zones.md @@ -4,14 +4,14 @@ type: workplan title: "Adopt security zones as a consumer — retire the global policy.enabled" domain: infotech repo: ops-warden -status: proposed +status: finished owner: ops-warden topic_slug: netkingdom planning_priority: P1 depends_on_workplans: - WARDEN-WP-0031 created: "2026-08-19" -updated: "2026-08-19" +updated: "2026-08-22" state_hub_workstream_id: "f1b6cdcb-5e16-48ab-a72b-339e909e92f0" --- @@ -98,7 +98,7 @@ this repo's own input rather than out of supplying a new one. ```task id: WARDEN-WP-0032-T02 -status: wait +status: done priority: high state_hub_task_id: "08735f17-fe7e-4a6a-9b71-f350f98c30b1" ``` @@ -144,9 +144,39 @@ Good news for T03's cost: **no flex-auth registry schema change is required.** `metadata`, `labels` and `attributes` already flatten into the rego input, so the compiler can emit membership today. +**Consumer implementation landed in the working tree 2026-08-22.** The config +loader rejects the retired `policy.enabled` and global `policy.fail_closed` +keys. `scripts/build_flex_auth_registry.py` now emits the normative +`workload_id`, `security_zone`, `security_zone_declared`, +`security_zone_admission`, `security_zone_reason`, and +`security_zone_revision` attributes; the dormant `trust_zone` field is gone. +The PEP reads compiled resource membership and applies its local per-zone +failure mode. Sign and audit records retain the zone, selected failure mode, +outcome, and decision id. + +The legacy `--no-policy` proxy switch remains only as a rejected compatibility +flag. It can no longer bypass the gate: unresolved credential targets take the +explicit `unknown` profile and its configured failure mode. + +**Done 2026-08-22.** flex-auth package v2 (commit `e521e7b`) supplies total +stance over every v0.1 zone plus `unknown`, preserves native enforcement for +`not-applicable`, and returns `audit_only` for advisory decisions. The required +live caller check passed through the existing tunnel with command-mode caller +identity and decision `decision:f3f7c88f9585582a`; the check used a temporary +migrated copy because the operator's persistent config still carries the two +retired keys. Full repo tests pass and sign/audit evidence records zone, +failure mode, outcome, and decision id. + +The required readiness recheck was run on 2026-08-22 after `warden plan` +returned `autonomous`. It correctly returned **NOT READY** before obtaining or +printing any token because the active operator `warden.yaml` still contains +the two retired global keys. Operator configuration migration is therefore an +explicit remaining acceptance step, followed by the value-safe live +`/v1/check` smoke. Do not ask flex-auth to enforce caller authentication yet. + ```task id: WARDEN-WP-0032-T03 -status: wait +status: done priority: medium state_hub_task_id: "fd642377-8d64-473c-b684-f8f1a842c223" ``` @@ -183,9 +213,16 @@ carries membership. Carry the conformance rule: *accuracy, not altitude*. Declaring a stricter zone than can be evidenced is the failure mode that looks like progress. +**Done 2026-08-22.** `tenancy.yaml` now declares ops-warden as an independently +governed operational execution unit with its Kubernetes service-account +binding, and admits it to `z1-operational` at `M1`, `medium`, `internal`. +`docs/evidence/security-zone-admission-2026-08-22.md` records why this is the +highest evidenced admission rather than an aspirational `M2`. The declaration +passes net-kingdom's current `tenancy-posture_v0.1` validator. + ```task id: WARDEN-WP-0032-T04 -status: wait +status: done priority: medium state_hub_task_id: "1af3d26e-ce21-46ca-a56c-486d52b76dcb" ``` @@ -195,6 +232,13 @@ state_hub_task_id: "1af3d26e-ce21-46ca-a56c-486d52b76dcb" owning it — a superseding record, never an in-place edit. Update `SCOPE.md`, `wiki/WorkloadSecurityPosture.md`, and `wiki/PolicyGatedSigning.md` with it. +**Done 2026-08-22.** ADR-0006 is marked superseded without rewriting its +decision. ADR-0009 accepts `security-zones_v0.1` as a consumer, records the +PDP/PEP split and unknown handling, and is indexed as its successor. `SCOPE.md`, +`wiki/WorkloadSecurityPosture.md`, `wiki/PolicyGatedSigning.md`, configuration +guidance, access guidance, and affected playbooks now describe the zone-aware +control and do not instruct operators to use a global bypass. + ```task id: WARDEN-WP-0032-T05 status: done @@ -241,7 +285,7 @@ policy covers these paths (T06). ```task id: WARDEN-WP-0032-T06 -status: progress +status: done priority: medium state_hub_task_id: "8c082416-0ff9-45dc-8cbd-e9ca7913a6ef" ``` @@ -330,8 +374,53 @@ with a `workload-kv-read-*` policy. Confirming that means minting or inspecting tokens — a write against railiance-platform's OpenBao. Exposure stays theoretical to the same degree as before. -**Still open:** whether the maturity-derived default replaces the `ungraded` -sentinel entirely (waits on `ZONE-WP-0001-T03`). +**Platform half closed 2026-08-22.** `RAILIANCE-WP-0022` consumed the generated +high-risk path input and deployed the completed deny policy. The platform-owned +invariants report **19 high-risk lanes, 14 concrete entries, 0 uncovered**. A +dedicated `coding-agent-railiance-platform` AppRole then proved deny-wins with +exactly `agent-high-risk-boundary` plus one workload read policy: data access was +denied while metadata remained readable; its single-use test identity was +self-revoked and no test tokens remained. Decision `f0955252` records the +operational identity. No KV value was read. + +The local invariant agrees against the platform policy file: 19 high-risk lanes, +14 concrete entries covered, 5 without a concrete address, 0 uncovered. This +session could not independently read the deployed policy, so live-state evidence +is the platform-owned readback above rather than a claim inferred from the local +file. + +**Done 2026-08-22.** The settled model makes the two layers complementary: +catalog CI continues to require an explicit `risk` field so omission cannot +silently weaken the agent boundary, while `RouteEntry.risk_for_zone` implements +the normative maturity-derived fallback for consumers evaluating unresolved or +future inputs (`z0` + satisfied + synthetic-only → `standard`; satisfied `z3` +→ `critical`; everything else → `high`). An explicit catalog grade always wins. +The platform deny policy and its live proof above close the direct-OpenBao half. + +```task +id: WARDEN-WP-0032-T07 +status: done +priority: medium +state_hub_task_id: "6b8c8181-978c-4b0a-ac7b-2cc9ad57b3ba" +``` + +**Adopt Repo Manager's workload-reference owner interface.** Accepted with one +documented amendment against repo-manager revision `890f3b0`: managed +deployables use the exact `(rapp_id, workload_identity.name)` pair and optional +deployable name; an independently governed operational execution unit may use +an exact local `tenancy.yaml` declaration. Native actions, actors, grants, +patterns, and non-workload resources are explicitly `not-applicable`; unresolved +workload-applicable references remain `unknown`. No path, owner name, or +repository-name inference is allowed. + +Every one of the 27 catalog lanes now carries an explicit `workload_ref`: 3 +resolved (2 exact managed RAPP references and ops-warden's direct operational +declaration), 17 unknown with reasons, and 7 not-applicable with reasons. +`scripts/report_workload_join.py` resolves only those explicit references, +Repo Manager validates the interface and resolves both managed tuples exactly, +and regression tests reject malformed or inferred joins. This incorporates +net-kingdom's draft-12 correction received 2026-08-22: local tenancy declaration +is not an escape hatch for a managed running deployable. ## Related