ops-warden/wiki/PolicyGatedSigning.md
tegwick 5b1a508610
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
docs: mark the unknown cell, measure the coverage we asked to publish
gate-house ruled the v0.8 assent round (GH-DEC-2026-011, net-kingdom@64394e9):
ask 1 declined, ask 2 adopted.

Ask 1's refusal is accepted without reservation and the reason is better than
the ask -- a sanctioned transitional fail_open is indistinguishable at runtime
from the stance the rule forbids, and would make the rule optional at the only
moment it costs anything.

Ask 2 gave §13.1 a Coverage column with this repo's figures as its first
entries. Since we asked for the column, we owe it accuracy:
scripts/report_coverage.py measures both populations from the artifacts the
runtime uses (reusing the workload-join build rather than re-deriving it), and
a test asserts pep-stance.yaml's published block equals what it measures.
A hand-counted number in a register that explicitly does not recompute it
decays silently, and a stale figure beside a marked cell is worse than the
blank the other four rows carry.

pep-stance.yaml marks the unknown cell inline as a declared gap -- assent, the
measured reason for not flipping, the declined ask, WARDEN-WP-0040 as route --
and a second test keeps it marked while it is fail_open, failing when it is
flipped. standard_version stays 0.7 because that is what binds; v0.8 is
proposed, so it gains standard_version_reviewed rather than pre-adopting.

Separately, gate-house corrected GH-DEC-2026-008: the claim/decision digest
comparison it originally required is unimplementable and a fail-closed
consumer obeying it would have denied permanently. We had never copied the
wording, so nothing to unwind -- but everything they have sent about this lane
was living in an inbox thread, a bad home for a correction that only matters
when someone finally wires the consume. Now wiki/ApprovalConsumption.md,
leading with "nothing is wired", carrying the corrected target and the
attribution gap that digest matching does not discharge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EPuTc18FjU5WFqoSEKH3C

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1276224@bnt-lap001
Assistant-Session: 426ec497-e1c4-4dd3-b417-dfce1ca1dbc3
2026-09-10 08:02:10 +02:00

209 lines
7 KiB
Markdown

# Zone-aware policy-gated signing
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.
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.
Binding decisions: `ADR-0009` (current) and `ADR-0006` (superseded rationale).
## Request path
```text
warden sign <actor>
-> inventory, principal, actor-type, and TTL checks
-> resource id ssh-cert:actor/<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 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
`scripts/build_flex_auth_registry.py` compiles inventory actor resources. Each
actor carries an explicit `zone_subject`:
```yaml
actors:
agt-state-hub-bridge:
type: agt
principals: [agt-task-bridge]
ttl_hours: 24
zone_subject:
applicability: applicable
workload_id: ops-bridge-tunnel
```
The compiler consumes zone-engine's resolved view when available and emits
resource attributes:
```json
{
"workload_id": "ops-bridge-tunnel",
"security_zone": "z2-continuity",
"security_zone_admission": "satisfied",
"security_zone_revision": "sha256:..."
}
```
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.
`trust_zone: platform` was a dormant, unrelated field and is retired. It must
not coexist with `security_zone` as a competing membership source.
Build the snapshot:
```bash
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
```
Omitting `--zone-resolutions` is safe: applicable actors resolve `unknown`, not
to a guessed zone.
## PEP failure modes
The initial build profile accepted by ops-warden is:
| 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.
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: command
command:
- kubectl
- create
- token
- ops-warden
- -n
- ops-warden
- --audience
- flex-auth
- --duration
- 10m
```
`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.
## Caller identity
An HTTP 401 or 403 from the policy service refuses the operation under every
outage profile. It is an explicit caller-authentication or system-binding refusal,
not evaluator unavailability. Resolve the admitted caller and resource contract;
do not retry with a different resource owner or disable caller enforcement.
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
python3 scripts/check_policy_caller_identity.py \
--url http://127.0.0.1:19090
```
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.
## Audit evidence
Successful signing records:
- `policy_decision_id` when flex-auth returned one;
- `policy_zone`;
- `policy_failure_mode`;
- `policy_outcome` (`allow`, `audit_only`, or `fail_open`).
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.
## Rollout and rollback
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.
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.
## Approval consumption (not wired)
This lane does not yet consume an approval artifact. The corrections already
received about how it must validate when it does — two artifacts never taken from
each other, the corrected `binding.approval_binding_digest` comparison target, and
the attribution gap that digest matching does not discharge — are held in
`wiki/ApprovalConsumption.md` so they are not lost between now and then.
## See also
- `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`