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>
347 lines
No EOL
13 KiB
Markdown
347 lines
No EOL
13 KiB
Markdown
# 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
|
||
|
||
```text
|
||
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
|
||
|
||
```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
|
||
```
|
||
|
||
| 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)) |
|
||
|
||
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
|
||
|
||
```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).
|
||
```
|
||
|
||
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:
|
||
|
||
```bash
|
||
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):
|
||
|
||
```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
|
||
```
|
||
|
||
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
|
||
|
||
```yaml
|
||
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
|
||
|
||
```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
|
||
```
|
||
|
||
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 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.
|
||
|
||
---
|
||
|
||
## 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` |