ops-warden/wiki/PolicyGatedSigning.md
tegwick cbf6828061
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
ADR-0006: enforcement is zone-scoped; defer the policy.enabled flip
flex-auth enforced its ops-warden pin (FLEX-WP-0016 T03) and the gate verified
clean against it: readiness exits 0, decision:f3f7c88f9585582a, anonymous
/v1/check now 401. Everything needed to set policy.enabled: true was in place.

It stays false, by decision. policy.enabled is a single repo-wide boolean, 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 itself. Uniform enforcement across an estate being
actively rebuilt hardens the access needed to perform the rebuild.

The repo already refuses one-dimensional posture: WP-0015 shipped environment
and maturity axes, WP-0029 added organization_posture. A global flag ignores all
three. ADR-0006 records that enforcement belongs to a zone, and binds future
work — a zone-blind enforcement flag is out of order, not merely unwise.

WARDEN-WP-0032 drafts the zone model, leading with the ownership question:
whether this is ops-warden's to own or NetKingdom canon to consume (ADR-0005).
WP-0031 is finished with T05 cancelled and resuming as WP-0032-T05.

Also replaces the hand-run kubectl port-forward with a managed ops-bridge
tunnel, flex-auth-ops-warden-railiance01 (-L 19090:10.43.1.165:8080).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:31:28 +02:00

13 KiB
Raw Blame History

Policy-Gated SSH Signing

Date: 2026-06-23
Status: implemented (opt-in) — WARDEN-WP-0007; policy package confirmed FLEX-WP-0006

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.


Flow

warden sign <actor> --pubkey <path>
        |
        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)

The same gate runs for warden issue (local backend only).


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/<actor-name>
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

# 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
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)

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-name>
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:<actor-name> 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

# 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).

Local end-to-end evidence: history/2026-06-23-flex-auth-policy-gate-local-smoke.md.

Production registry from inventory

Build a flex-auth registry snapshot that mirrors inventory.yaml actors:

python scripts/build_flex_auth_registry.py ~/.config/warden/inventory.yaml \
  -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.

Smoke (non-secret):

./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

Evidence: history/2026-06-23-flex-auth-policy-gate-production-smoke.md.


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

policy:
  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 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

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.

Readiness gate

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

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.

Enablement is deferred by decision (ADR-0006)

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.

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.

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.

The sequence below is retained as the mechanics, for whoever enables the gate for a zone once zones exist.

Flip sequence

  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 <flex_auth_url>/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 <actor>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 15 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.


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