Harden production authorization and service auth
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
This commit is contained in:
parent
f579f3761c
commit
70371649af
20 changed files with 1268 additions and 54 deletions
|
|
@ -57,6 +57,7 @@ SECRETS_ENGINE_HUB_URL="" bash scripts/demo-e2e.sh
|
||||||
- ops-warden routing contract: [docs/ops-warden-routing-contract.md](docs/ops-warden-routing-contract.md)
|
- ops-warden routing contract: [docs/ops-warden-routing-contract.md](docs/ops-warden-routing-contract.md)
|
||||||
- Hardening backlog (exit bootstrap mode): [docs/hardening-backlog.md](docs/hardening-backlog.md)
|
- Hardening backlog (exit bootstrap mode): [docs/hardening-backlog.md](docs/hardening-backlog.md)
|
||||||
- Existing-lane catalog admission: [docs/catalog-admission.md](docs/catalog-admission.md)
|
- Existing-lane catalog admission: [docs/catalog-admission.md](docs/catalog-admission.md)
|
||||||
|
- KeyCape service-auth consumer boundary: [docs/service-auth.md](docs/service-auth.md)
|
||||||
|
|
||||||
The implementation is a Python package (`src/secrets_engine/`). OpenBao is
|
The implementation is a Python package (`src/secrets_engine/`). OpenBao is
|
||||||
reached only through the `bao` CLI adapter (`openbao.py`); the rest of the code
|
reached only through the `bao` CLI adapter (`openbao.py`); the rest of the code
|
||||||
|
|
@ -68,7 +69,8 @@ speaks in lanes and guarded plans.
|
||||||
workplans, or normal logs.
|
workplans, or normal logs.
|
||||||
- OpenBao is the backend custody and audit authority.
|
- OpenBao is the backend custody and audit authority.
|
||||||
- Build, test, and production have separate policy boundaries.
|
- Build, test, and production have separate policy boundaries.
|
||||||
- Production actions require approved decisions except explicit break-glass
|
- Production live actions fail closed until the durable State Hub
|
||||||
flows.
|
action-authorization endpoint is available; local approval mirrors are
|
||||||
|
throwaway-demo material only.
|
||||||
- Temporary bootstrap OpenBao credentials must live outside repos, use mode
|
- Temporary bootstrap OpenBao credentials must live outside repos, use mode
|
||||||
0600, be revocable, and be removed after narrower auth is working.
|
0600, be revocable, and be removed after narrower auth is working.
|
||||||
|
|
|
||||||
53
SCOPE.md
53
SCOPE.md
|
|
@ -32,10 +32,16 @@ provider, credential broker, or general secrets API.
|
||||||
### Decision-gated planning and OpenBao metadata apply
|
### Decision-gated planning and OpenBao metadata apply
|
||||||
|
|
||||||
- Resolves a catalog lane by catalog id or `approval.decision_ref`.
|
- Resolves a catalog lane by catalog id or `approval.decision_ref`.
|
||||||
- Resolves an approval from State Hub by id, with tracked local YAML mirrors as
|
- Resolves legacy lane decisions from State Hub by id, with tracked local YAML
|
||||||
a pilot/offline fallback.
|
mirrors retained only for non-production and explicit throwaway demos.
|
||||||
- Fails privileged live commands closed when a required decision is missing,
|
- Fails every production live command closed while State Hub lacks the durable
|
||||||
unapproved, or superseded. Plans and `apply --dry-run` remain non-mutating.
|
action-authorization endpoint. A local mirror can unlock a prod-labeled lane
|
||||||
|
only when an explicit unsafe-demo switch, disabled Hub URL, and loopback
|
||||||
|
OpenBao target are all present. Plans and `apply --dry-run` remain usable.
|
||||||
|
- Builds and validates the flex-auth `ActionAuthorization` profile, including
|
||||||
|
exact lane/stage/action/target/actor/purpose matching, bounded validity,
|
||||||
|
State Hub authority, request digest and decision binding, accepted policy
|
||||||
|
package/version, and an independently required distinct-approver threshold.
|
||||||
- Renders guarded OpenBao plans for exact consumer ACL policies and AppRoles.
|
- Renders guarded OpenBao plans for exact consumer ACL policies and AppRoles.
|
||||||
- Applies policy and AppRole metadata idempotently. Existing mounts render a
|
- Applies policy and AppRole metadata idempotently. Existing mounts render a
|
||||||
non-mutating check and are never created by apply.
|
non-mutating check and are never created by apply.
|
||||||
|
|
@ -107,9 +113,10 @@ cannot be recovered through that AppRole.
|
||||||
status, metadata/value-presence booleans, missing declared field names,
|
status, metadata/value-presence booleans, missing declared field names,
|
||||||
readiness, and a safe next command. Every declared field must be present.
|
readiness, and a safe next command. Every declared field must be present.
|
||||||
- Records scrubbed local JSONL evidence and posts a minimal State Hub progress
|
- Records scrubbed local JSONL evidence and posts a minimal State Hub progress
|
||||||
event on a best-effort basis. Each requested State Hub delivery receives an
|
event on a best-effort basis. Posts carry stable idempotency/source headers.
|
||||||
append-only local `delivered`, `failed`, or `skipped-no-topic` companion
|
Each requested State Hub delivery receives an append-only local `delivered`,
|
||||||
record.
|
`queued`, `failed`, or `skipped-no-topic` companion record; edge-relay queued
|
||||||
|
receipts retain only the non-secret outbox id.
|
||||||
- Every live privileged CLI handler records an attempt before lane-approval
|
- Every live privileged CLI handler records an attempt before lane-approval
|
||||||
resolution and a terminal success, verification failure, rejection,
|
resolution and a terminal success, verification failure, rejection,
|
||||||
interruption, or typed backend/input failure. Failure evidence contains the
|
interruption, or typed backend/input failure. Failure evidence contains the
|
||||||
|
|
@ -119,9 +126,25 @@ cannot be recovered through that AppRole.
|
||||||
delivery outcomes. It never re-emits arbitrary evidence detail.
|
delivery outcomes. It never re-emits arbitrary evidence detail.
|
||||||
- Keeps OpenBao audit logs as the backend source of truth.
|
- Keeps OpenBao audit logs as the backend source of truth.
|
||||||
|
|
||||||
State Hub evidence delivery is not queued or transactional; the local receipt
|
The engine recognizes edge-relay queued receipts but does not own or initiate
|
||||||
makes failure visible but does not replay it. Route and audit do not replace
|
outbox replay. Direct State Hub failure remains locally visible. Route and audit
|
||||||
exact-action authorization or OpenBao audit logs.
|
do not replace exact-action authorization or OpenBao audit logs.
|
||||||
|
|
||||||
|
### Steady-state service-auth scaffold
|
||||||
|
|
||||||
|
- Implements an explicit KeyCape `client_credentials` exchange for the
|
||||||
|
accepted `secrets-engine-openbao` service identity.
|
||||||
|
- Reads the confidential-client secret only from a mode-0600 file outside Git,
|
||||||
|
sends it through HTTP Basic authentication, and rejects ID/refresh tokens.
|
||||||
|
- Preflights the exact issuer, subject, audience, principal type, tenant, role,
|
||||||
|
scope, assurance, RS256 algorithm, and 15-minute lifetime. The in-memory JWT
|
||||||
|
is excluded from object representations and is renewed at the three-minute
|
||||||
|
boundary.
|
||||||
|
- Never retries into or falls back to bootstrap, operator, or AppRole auth.
|
||||||
|
|
||||||
|
This provider is deliberately not connected to OpenBao. Signature verification
|
||||||
|
and token issuance remain with the platform-owned exact-bound OpenBao JWT role,
|
||||||
|
whose mount/role contract is still outstanding.
|
||||||
Treat their output as operational guidance, not complete attestation for
|
Treat their output as operational guidance, not complete attestation for
|
||||||
high-risk lanes.
|
high-risk lanes.
|
||||||
|
|
||||||
|
|
@ -181,14 +204,18 @@ verification, `approle-login` is auth-capability handoff metadata, and
|
||||||
## Not Implemented
|
## Not Implemented
|
||||||
|
|
||||||
- A service API, daemon, UI, queue, scheduler, or remote multi-user service.
|
- A service API, daemon, UI, queue, scheduler, or remote multi-user service.
|
||||||
- OpenBao OIDC/service-auth login for steady-state secrets-engine operation.
|
- OpenBao JWT login and platform materialization for the implemented KeyCape
|
||||||
|
service-auth provider.
|
||||||
- Native `exec-file` or response-wrapped delivery.
|
- Native `exec-file` or response-wrapped delivery.
|
||||||
- Provider-side rotation or coordinated multi-consumer rollout.
|
- Provider-side rotation or coordinated multi-consumer rollout.
|
||||||
- First-class rotate, compromise, reactivate, lease-status, or audit report
|
- First-class rotate, compromise, reactivate, lease-status, or audit report
|
||||||
commands; lifecycle operations currently execute plans without persistent
|
commands; lifecycle operations currently execute plans without persistent
|
||||||
lane state.
|
lane state.
|
||||||
- Dual-control enforcement beyond accepting the catalog label.
|
- Resolution of a durable State Hub action authorization and wiring its
|
||||||
- Direct flex-auth evaluation, claim validation, or identity authentication.
|
validated approval threshold to each production handler.
|
||||||
|
- Direct flex-auth evaluation, JWT signature verification, or identity
|
||||||
|
authentication. KeyCape claims receive only a consumer preflight; OpenBao is
|
||||||
|
responsible for cryptographic JWT validation.
|
||||||
- Runtime tenancy isolation; `org`, `repo`, consumers, and stages are catalog
|
- Runtime tenancy isolation; `org`, `repo`, consumers, and stages are catalog
|
||||||
metadata plus local path/policy guards, not a tenant control plane.
|
metadata plus local path/policy guards, not a tenant control plane.
|
||||||
- Management or health verification of ESO, Kubernetes Secrets, deployments,
|
- Management or health verification of ESO, Kubernetes Secrets, deployments,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
| task | SECRETS-WP-0007-T01 | done | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
| task | SECRETS-WP-0007-T01 | done | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
||||||
| task | SECRETS-WP-0007-T02 | done | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
| task | SECRETS-WP-0007-T02 | done | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
||||||
| task | SECRETS-WP-0007-T03 | progress | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
| task | SECRETS-WP-0007-T03 | progress | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
||||||
| task | SECRETS-WP-0007-T04 | wait | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
| task | SECRETS-WP-0007-T04 | progress | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
||||||
| task | SECRETS-WP-0007-T05 | progress | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
| task | SECRETS-WP-0007-T05 | progress | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
||||||
| task | SECRETS-WP-0007-T06 | progress | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
| task | SECRETS-WP-0007-T06 | progress | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
||||||
| task | SECRETS-WP-0007-T07 | wait | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
| task | SECRETS-WP-0007-T07 | wait | — | workplans/SECRETS-WP-0007-production-lifecycle-hardening.md |
|
||||||
|
|
|
||||||
11
docs/cli.md
11
docs/cli.md
|
|
@ -38,6 +38,7 @@ secrets-engine --version
|
||||||
| `SECRETS_ENGINE_HUB_URL` | `http://127.0.0.1:8000` | State Hub for decisions + evidence (empty to disable) |
|
| `SECRETS_ENGINE_HUB_URL` | `http://127.0.0.1:8000` | State Hub for decisions + evidence (empty to disable) |
|
||||||
| `SECRETS_ENGINE_CATALOG` | `./catalog` | catalog directory |
|
| `SECRETS_ENGINE_CATALOG` | `./catalog` | catalog directory |
|
||||||
| `SECRETS_ENGINE_EVIDENCE` | `./.evidence` | local non-secret evidence log |
|
| `SECRETS_ENGINE_EVIDENCE` | `./.evidence` | local non-secret evidence log |
|
||||||
|
| `SECRETS_ENGINE_UNSAFE_DEMO` | _(unset)_ | allow a prod-labeled lane only when Hub is disabled and OpenBao is loopback; throwaway demos only |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
@ -67,7 +68,11 @@ var the token is injected under, per the netkingdom publication-scope policy
|
||||||
`<ref>` is a catalog id or a decision/CCR ref (matched against
|
`<ref>` is a catalog id or a decision/CCR ref (matched against
|
||||||
`approval.decision_ref`). `plan` and `apply --dry-run` never mutate OpenBao.
|
`approval.decision_ref`). `plan` and `apply --dry-run` never mutate OpenBao.
|
||||||
For decision-gated lanes they may render with `decision: <none>` when the
|
For decision-gated lanes they may render with `decision: <none>` when the
|
||||||
approval object is not reachable; non-dry-run `apply` remains decision-gated.
|
approval object is not reachable. Production live commands remain disabled
|
||||||
|
until State Hub exposes the durable exact-action authorization object. A legacy
|
||||||
|
local decision is accepted for a prod-labeled lane only with
|
||||||
|
`SECRETS_ENGINE_UNSAFE_DEMO=1`, an empty Hub URL, and loopback OpenBao; the demo
|
||||||
|
scripts set those three conditions themselves.
|
||||||
|
|
||||||
`handoff` is for `kind: auth-capability` lanes such as `warden-sign`. It mints a
|
`handoff` is for `kind: auth-capability` lanes such as `warden-sign`. It mints a
|
||||||
fresh AppRole `secret_id` and writes `role_id` plus `secret_id` to caller-chosen
|
fresh AppRole `secret_id` and writes `role_id` plus `secret_id` to caller-chosen
|
||||||
|
|
@ -109,8 +114,8 @@ and evidence stores only a short accessor fingerprint plus cleanup outcome.
|
||||||
counts, canonical decision references, session cleanup outcomes, and State Hub
|
counts, canonical decision references, session cleanup outcomes, and State Hub
|
||||||
delivery outcomes for one cataloged lane. Its parser allowlists those fields and
|
delivery outcomes for one cataloged lane. Its parser allowlists those fields and
|
||||||
does not echo arbitrary JSONL detail. State Hub failures are recorded locally as
|
does not echo arbitrary JSONL detail. State Hub failures are recorded locally as
|
||||||
append-only companion receipts; they are visible but are not automatically
|
append-only companion receipts. Edge-relay queued receipts and their non-secret
|
||||||
replayed.
|
outbox ids are recorded too; replay remains an operator/State Hub responsibility.
|
||||||
|
|
||||||
Live `apply`, `provision`, `verify`, `handoff`, `exec`, `revoke`, `suspend`, and
|
Live `apply`, `provision`, `verify`, `handoff`, `exec`, `revoke`, `suspend`, and
|
||||||
`deactivate` share one evidence guard. It records an attempt before lane approval
|
`deactivate` share one evidence guard. It records an attempt before lane approval
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,11 @@ contents in this repo.
|
||||||
|
|
||||||
## H1 — Replace bootstrap token files with OIDC / service auth
|
## H1 — Replace bootstrap token files with OIDC / service auth
|
||||||
|
|
||||||
- Stand up an OpenBao auth method (OIDC or AppRole bound to a workload identity)
|
- Implemented consumer scaffold: exact KeyCape client-credentials exchange,
|
||||||
for each stage role.
|
claim/lifetime preflight, strict client-secret file input, renewal boundary,
|
||||||
|
and no implicit fallback.
|
||||||
|
- Remaining: railiance-platform stands up the exact-bound OpenBao JWT auth
|
||||||
|
mount/role and publishes its non-secret connection contract.
|
||||||
- secrets-engine logs in via that method instead of reading a token file.
|
- secrets-engine logs in via that method instead of reading a token file.
|
||||||
- Remove `--bootstrap-token-file` from the steady-state path (keep only for true
|
- Remove `--bootstrap-token-file` from the steady-state path (keep only for true
|
||||||
break-glass, heavily audited).
|
break-glass, heavily audited).
|
||||||
|
|
@ -44,8 +47,11 @@ contents in this repo.
|
||||||
|
|
||||||
## H3 — Production dual-control
|
## H3 — Production dual-control
|
||||||
|
|
||||||
- Require two-person approval (`approval.model: dual-control`) for prod value
|
- Implemented consumer validator: exact action request, decision digest/binding,
|
||||||
provisioning before automating raw-value writes beyond the pilot.
|
validity, State Hub provenance, and caller-selected minimum distinct approval
|
||||||
|
count.
|
||||||
|
- Remaining: State Hub stores/serves the durable object and production handlers
|
||||||
|
resolve it; all production live actions currently fail closed.
|
||||||
|
|
||||||
## H4 — Rotation & lifecycle states
|
## H4 — Rotation & lifecycle states
|
||||||
|
|
||||||
|
|
@ -59,7 +65,9 @@ contents in this repo.
|
||||||
|
|
||||||
- Implemented: `secrets-engine audit <catalog-id>` summarizes allowlisted local
|
- Implemented: `secrets-engine audit <catalog-id>` summarizes allowlisted local
|
||||||
action/result, decision, session-cleanup, and State Hub delivery evidence.
|
action/result, decision, session-cleanup, and State Hub delivery evidence.
|
||||||
- Remaining: durable/queued State Hub delivery and OpenBao audit request-id
|
- Implemented: idempotency/source headers and edge-relay queued-receipt/outbox-id
|
||||||
|
visibility.
|
||||||
|
- Remaining: operator replay verification and OpenBao audit request-id
|
||||||
correlation.
|
correlation.
|
||||||
|
|
||||||
## H6 — API service mode
|
## H6 — API service mode
|
||||||
|
|
|
||||||
28
docs/service-auth.md
Normal file
28
docs/service-auth.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# KeyCape service-auth consumer boundary
|
||||||
|
|
||||||
|
secrets-engine now implements the consumer half of KeyCape's accepted
|
||||||
|
`secrets-engine-openbao` service-auth contract. It performs one explicit HTTPS
|
||||||
|
`client_credentials` exchange, keeps the resulting JWT in memory, and validates
|
||||||
|
the non-cryptographic contract before any future OpenBao login:
|
||||||
|
|
||||||
|
- subject `service:secrets-engine`, audience/client `secrets-engine-openbao`;
|
||||||
|
- service principal in `tenant:coulomb`, role `secrets-engine`;
|
||||||
|
- exact `openbao:login` scope and KeyCape AAL1 client-secret assurance;
|
||||||
|
- RS256 declaration, bounded issue/expiry timestamps, maximum 15-minute life,
|
||||||
|
and renewal when no more than three minutes remain;
|
||||||
|
- no ID token, refresh token, implicit retry provider, or fallback identity.
|
||||||
|
|
||||||
|
The confidential-client secret must be an explicitly selected mode-0600 file
|
||||||
|
outside every Git worktree. It is sent with HTTP Basic authentication and never
|
||||||
|
placed in a request body, command argument, evidence record, or object
|
||||||
|
representation.
|
||||||
|
|
||||||
|
This is a scaffold, not a live OpenBao authentication path. JWT payload parsing
|
||||||
|
does not prove a signature. railiance-platform still owns the exact OpenBao JWT
|
||||||
|
auth mount/role, issuer keys, claim bindings, token policy, TTL/use limits, and
|
||||||
|
cryptographic verification. Until that contract is materialized, the provider
|
||||||
|
is not selected by the CLI and bootstrap/AppRole behavior is not used as an
|
||||||
|
implicit fallback.
|
||||||
|
|
||||||
|
Canonical provider contract:
|
||||||
|
`key-cape/docs/openbao-service-auth-contract.md` (reviewed 2026-08-23).
|
||||||
|
|
@ -19,6 +19,8 @@ BAO_BIN="$(command -v bao)"
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
export BAO_ADDR="http://127.0.0.1:8270"
|
export BAO_ADDR="http://127.0.0.1:8270"
|
||||||
export BAO_TOKEN="se-demo-root"
|
export BAO_TOKEN="se-demo-root"
|
||||||
|
export SECRETS_ENGINE_HUB_URL="" # offline; uses local decision fixture
|
||||||
|
export SECRETS_ENGINE_UNSAFE_DEMO="1" # required for live prod-lane demos
|
||||||
# Keep secret material OUTSIDE the repo worktree (engine enforces this).
|
# Keep secret material OUTSIDE the repo worktree (engine enforces this).
|
||||||
TOKENFILE="$WORK/whynot.token"
|
TOKENFILE="$WORK/whynot.token"
|
||||||
NEGATIVE_TOKENFILE="$WORK/unrelated.token"
|
NEGATIVE_TOKENFILE="$WORK/unrelated.token"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ WORK="$(mktemp -d)"
|
||||||
export BAO_ADDR="http://127.0.0.1:8271"
|
export BAO_ADDR="http://127.0.0.1:8271"
|
||||||
export BAO_TOKEN="se-npm-demo-root"
|
export BAO_TOKEN="se-npm-demo-root"
|
||||||
export SECRETS_ENGINE_HUB_URL="" # offline; uses local decision fixture
|
export SECRETS_ENGINE_HUB_URL="" # offline; uses local decision fixture
|
||||||
|
export SECRETS_ENGINE_UNSAFE_DEMO="1" # required for live prod-lane demos
|
||||||
TOKENFILE="$WORK/whynot.token"
|
TOKENFILE="$WORK/whynot.token"
|
||||||
PKG="$WORK/pkg"
|
PKG="$WORK/pkg"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from typing import Any
|
||||||
|
|
||||||
_LABEL = re.compile(r"^[a-z][a-z0-9-]{0,79}$")
|
_LABEL = re.compile(r"^[a-z][a-z0-9-]{0,79}$")
|
||||||
_DECISION_REF = re.compile(r"^[A-Z][A-Z0-9-]{2,80}$")
|
_DECISION_REF = re.compile(r"^[A-Z][A-Z0-9-]{2,80}$")
|
||||||
_DELIVERY_RESULTS = {"delivered", "failed", "skipped-no-topic"}
|
_DELIVERY_RESULTS = {"delivered", "failed", "queued", "skipped-no-topic"}
|
||||||
_VERIFY_RESULT = re.compile(r"^(positive|negative):(pass|fail)$")
|
_VERIFY_RESULT = re.compile(r"^(positive|negative):(pass|fail)$")
|
||||||
_ERROR_RESULT = re.compile(
|
_ERROR_RESULT = re.compile(
|
||||||
r"^failed-(Catalog|Decision|PolicyGuard|Backend|Provisioning|Verification|Delivery)Error$"
|
r"^failed-(Catalog|Decision|PolicyGuard|Backend|Provisioning|Verification|Delivery)Error$"
|
||||||
|
|
|
||||||
314
src/secrets_engine/authorization.py
Normal file
314
src/secrets_engine/authorization.py
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
"""Fail-closed consumer validation for flex-auth action authorizations.
|
||||||
|
|
||||||
|
The canonical contract is flex-auth revision c473f19. State Hub does not yet
|
||||||
|
provide the durable authoritative endpoint, so this module validates supplied
|
||||||
|
objects but does not resolve or enable production actions by itself.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from secrets_engine.catalog import CatalogEntry
|
||||||
|
from secrets_engine.errors import DecisionError
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "0.1"
|
||||||
|
AUTHORITY = "state-hub"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValidatedActionAuthorization:
|
||||||
|
authorization_id: str
|
||||||
|
decision_id: str
|
||||||
|
action: str
|
||||||
|
subject_id: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
def build_action_request(
|
||||||
|
entry: CatalogEntry,
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
subject_id: str,
|
||||||
|
subject_type: str,
|
||||||
|
purpose: str,
|
||||||
|
fields: list[str] | tuple[str, ...] = (),
|
||||||
|
policy_targets: list[str] | tuple[str, ...] = (),
|
||||||
|
auth_targets: list[str] | tuple[str, ...] = (),
|
||||||
|
request_id: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the exact normalized secrets-engine profile for flex-auth."""
|
||||||
|
if not action or not subject_id or not subject_type or not purpose:
|
||||||
|
raise DecisionError(
|
||||||
|
"action request requires action, subject id/type, and purpose"
|
||||||
|
)
|
||||||
|
request: dict[str, Any] = {}
|
||||||
|
if request_id:
|
||||||
|
request["id"] = request_id
|
||||||
|
request.update(
|
||||||
|
{
|
||||||
|
"subject": {"id": subject_id, "type": subject_type},
|
||||||
|
"action": action,
|
||||||
|
"resource": {
|
||||||
|
"id": f"catalog:{entry.id}",
|
||||||
|
"type": "secret-catalog-lane",
|
||||||
|
"system": "secrets-engine",
|
||||||
|
"attributes": {
|
||||||
|
"stage": entry.stage,
|
||||||
|
"fields": sorted(set(fields)),
|
||||||
|
"policy_targets": sorted(set(policy_targets)),
|
||||||
|
"auth_targets": sorted(set(auth_targets)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"context": {"purpose": purpose},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def _required_dict(container: dict[str, Any], name: str) -> dict[str, Any]:
|
||||||
|
value = container.get(name)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise DecisionError(f"action authorization requires object '{name}'")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(container: dict[str, Any], name: str) -> str:
|
||||||
|
value = container.get(name)
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise DecisionError(f"action authorization requires non-empty '{name}'")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: object, name: str) -> datetime:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise DecisionError(f"action authorization requires timestamp '{name}'")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as e:
|
||||||
|
raise DecisionError(f"action authorization has invalid timestamp '{name}'") from e
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise DecisionError(f"action authorization timestamp '{name}' needs timezone")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_map(value: object) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
return {key: _canonical_map_value(value[key]) for key in sorted(value)}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_map_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return _sorted_map(value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_canonical_map_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_ref(value: object) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise DecisionError("action authorization subject must be an object")
|
||||||
|
subject: dict[str, Any] = {"id": _required_text(value, "id")}
|
||||||
|
for name in ("type", "tenant"):
|
||||||
|
if value.get(name):
|
||||||
|
subject[name] = _required_text(value, name)
|
||||||
|
if value.get("attributes") is not None:
|
||||||
|
subject["attributes"] = _sorted_map(value.get("attributes"))
|
||||||
|
return subject
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_ref(value: object) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise DecisionError("action authorization resource must be an object")
|
||||||
|
resource: dict[str, Any] = {"id": _required_text(value, "id")}
|
||||||
|
for name in ("type", "system", "tenant"):
|
||||||
|
if value.get(name):
|
||||||
|
resource[name] = _required_text(value, name)
|
||||||
|
if value.get("attributes") is not None:
|
||||||
|
resource["attributes"] = _sorted_map(value.get("attributes"))
|
||||||
|
return resource
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_check_request(request: object) -> dict[str, Any]:
|
||||||
|
"""Match Go encoding/json field order used by flex-auth request digests."""
|
||||||
|
if not isinstance(request, dict):
|
||||||
|
raise DecisionError("action authorization request must be an object")
|
||||||
|
canonical: dict[str, Any] = {}
|
||||||
|
if request.get("id"):
|
||||||
|
canonical["id"] = _required_text(request, "id")
|
||||||
|
if request.get("tenant"):
|
||||||
|
canonical["tenant"] = _required_text(request, "tenant")
|
||||||
|
canonical["subject"] = _subject_ref(request.get("subject"))
|
||||||
|
canonical["action"] = _required_text(request, "action")
|
||||||
|
canonical["resource"] = _resource_ref(request.get("resource"))
|
||||||
|
if request.get("context") is not None:
|
||||||
|
canonical["context"] = _sorted_map(request.get("context"))
|
||||||
|
if request.get("caring_context") is not None:
|
||||||
|
canonical["caring_context"] = _canonical_map_value(
|
||||||
|
request.get("caring_context")
|
||||||
|
)
|
||||||
|
if request.get("policy_version"):
|
||||||
|
canonical["policy_version"] = _required_text(request, "policy_version")
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
|
||||||
|
def request_digest(request: object) -> str:
|
||||||
|
canonical = canonical_check_request(request)
|
||||||
|
encoded = json.dumps(
|
||||||
|
canonical, ensure_ascii=False, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_exact_target_sets(request: dict[str, Any]) -> None:
|
||||||
|
resource = _required_dict(request, "resource")
|
||||||
|
attributes = resource.get("attributes", {})
|
||||||
|
if not isinstance(attributes, dict):
|
||||||
|
raise DecisionError("action authorization resource attributes must be an object")
|
||||||
|
for name in ("fields", "policy_targets", "auth_targets"):
|
||||||
|
values = attributes.get(name, [])
|
||||||
|
if not isinstance(values, list) or not all(
|
||||||
|
isinstance(item, str) and item for item in values
|
||||||
|
):
|
||||||
|
raise DecisionError(f"action authorization target set '{name}' is invalid")
|
||||||
|
if values != sorted(set(values)):
|
||||||
|
raise DecisionError(
|
||||||
|
f"action authorization target set '{name}' must be sorted and unique"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_action_authorization(
|
||||||
|
envelope: object,
|
||||||
|
expected_request: object,
|
||||||
|
*,
|
||||||
|
accepted_policy_packages: set[str],
|
||||||
|
accepted_policy_versions: set[str],
|
||||||
|
minimum_approval_count: int = 1,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ValidatedActionAuthorization:
|
||||||
|
"""Validate exact request binding and dual control; never parse prose."""
|
||||||
|
if minimum_approval_count < 1:
|
||||||
|
raise DecisionError("minimum approval count must be positive")
|
||||||
|
if not accepted_policy_packages or not accepted_policy_versions:
|
||||||
|
raise DecisionError("accepted flex-auth policy package/version is required")
|
||||||
|
if not isinstance(envelope, dict):
|
||||||
|
raise DecisionError("action authorization must be an object")
|
||||||
|
if envelope.get("schema_version") != SCHEMA_VERSION:
|
||||||
|
raise DecisionError("unsupported action authorization schema version")
|
||||||
|
authorization_id = _required_text(envelope, "id")
|
||||||
|
try:
|
||||||
|
parsed_authorization_id = uuid.UUID(authorization_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise DecisionError("action authorization id must be a canonical UUID") from e
|
||||||
|
if str(parsed_authorization_id) != authorization_id:
|
||||||
|
raise DecisionError("action authorization id must be a canonical UUID")
|
||||||
|
if envelope.get("status") != "approved":
|
||||||
|
raise DecisionError("action authorization status is not approved")
|
||||||
|
if envelope.get("superseded_by"):
|
||||||
|
raise DecisionError("action authorization is superseded")
|
||||||
|
provenance = _required_dict(envelope, "provenance")
|
||||||
|
if provenance.get("authority") != AUTHORITY:
|
||||||
|
raise DecisionError("action authorization authority is not State Hub")
|
||||||
|
|
||||||
|
request = canonical_check_request(envelope.get("request"))
|
||||||
|
expected = canonical_check_request(expected_request)
|
||||||
|
_require_exact_target_sets(request)
|
||||||
|
_require_exact_target_sets(expected)
|
||||||
|
if request != expected:
|
||||||
|
raise DecisionError("action authorization request does not exactly match action")
|
||||||
|
|
||||||
|
validity = _required_dict(envelope, "validity")
|
||||||
|
expires = _parse_time(validity.get("expires_at"), "expires_at")
|
||||||
|
not_before = (
|
||||||
|
_parse_time(validity.get("not_before"), "not_before")
|
||||||
|
if validity.get("not_before") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||||
|
if not_before is not None and current < not_before:
|
||||||
|
raise DecisionError("action authorization window has not started")
|
||||||
|
if current >= expires:
|
||||||
|
raise DecisionError("action authorization has expired")
|
||||||
|
|
||||||
|
approvals = _required_dict(envelope, "approvals")
|
||||||
|
required_count = approvals.get("required_count")
|
||||||
|
entries = approvals.get("entries")
|
||||||
|
if not isinstance(required_count, int) or required_count < 1:
|
||||||
|
raise DecisionError("action authorization approval count is invalid")
|
||||||
|
if required_count < minimum_approval_count:
|
||||||
|
raise DecisionError("action authorization approval threshold is insufficient")
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise DecisionError("action authorization approval entries are invalid")
|
||||||
|
approvers: set[str] = set()
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise DecisionError("action authorization approval entry is invalid")
|
||||||
|
subject_id = _required_text(entry, "subject_id")
|
||||||
|
approved_at = _parse_time(entry.get("approved_at"), "approved_at")
|
||||||
|
if approved_at > current or approved_at >= expires:
|
||||||
|
raise DecisionError("action authorization approval time is outside window")
|
||||||
|
if not_before is not None and approved_at < not_before:
|
||||||
|
raise DecisionError("action authorization approval time is outside window")
|
||||||
|
if subject_id in approvers:
|
||||||
|
raise DecisionError("action authorization contains duplicate approver")
|
||||||
|
approvers.add(subject_id)
|
||||||
|
if len(approvers) < required_count:
|
||||||
|
raise DecisionError("action authorization has insufficient distinct approvals")
|
||||||
|
|
||||||
|
decision = _required_dict(envelope, "decision")
|
||||||
|
if decision.get("effect") != "allow":
|
||||||
|
raise DecisionError("flex-auth decision effect is not allow")
|
||||||
|
decision_id = _required_text(decision, "id")
|
||||||
|
if request.get("id") and decision.get("request_id") != request["id"]:
|
||||||
|
raise DecisionError("flex-auth decision request id does not match request")
|
||||||
|
binding = _required_dict(decision, "binding")
|
||||||
|
bound_request: dict[str, Any] = {}
|
||||||
|
if binding.get("tenant"):
|
||||||
|
bound_request["tenant"] = binding["tenant"]
|
||||||
|
bound_request.update(
|
||||||
|
{
|
||||||
|
"subject": binding.get("subject"),
|
||||||
|
"action": binding.get("action"),
|
||||||
|
"resource": binding.get("resource"),
|
||||||
|
"context": binding.get("context", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expected_bound: dict[str, Any] = {}
|
||||||
|
if request.get("tenant"):
|
||||||
|
expected_bound["tenant"] = request["tenant"]
|
||||||
|
expected_bound.update(
|
||||||
|
{
|
||||||
|
"subject": request["subject"],
|
||||||
|
"action": request["action"],
|
||||||
|
"resource": request["resource"],
|
||||||
|
"context": request.get("context", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if canonical_check_request(bound_request) != canonical_check_request(
|
||||||
|
expected_bound
|
||||||
|
):
|
||||||
|
raise DecisionError("flex-auth decision binding does not match request")
|
||||||
|
if binding.get("request_digest") != request_digest(request):
|
||||||
|
raise DecisionError("flex-auth request digest does not match request")
|
||||||
|
if _subject_ref(decision.get("subject")) != request["subject"]:
|
||||||
|
raise DecisionError("flex-auth decision subject does not match request")
|
||||||
|
if _resource_ref(decision.get("resource")) != request["resource"]:
|
||||||
|
raise DecisionError("flex-auth decision resource does not match request")
|
||||||
|
decision_provenance = _required_dict(decision, "provenance")
|
||||||
|
if decision_provenance.get("policy_package") not in accepted_policy_packages:
|
||||||
|
raise DecisionError("flex-auth policy package is not accepted")
|
||||||
|
if decision_provenance.get("policy_version") not in accepted_policy_versions:
|
||||||
|
raise DecisionError("flex-auth policy version is not accepted")
|
||||||
|
|
||||||
|
return ValidatedActionAuthorization(
|
||||||
|
authorization_id=authorization_id,
|
||||||
|
decision_id=decision_id,
|
||||||
|
action=request["action"],
|
||||||
|
subject_id=request["subject"]["id"],
|
||||||
|
expires_at=expires.isoformat(),
|
||||||
|
)
|
||||||
|
|
@ -21,8 +21,10 @@ Every privileged action is decision-gated and writes non-secret evidence.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from secrets_engine import __version__
|
from secrets_engine import __version__
|
||||||
from secrets_engine.apply import apply_plan
|
from secrets_engine.apply import apply_plan
|
||||||
|
|
@ -83,8 +85,28 @@ def _privileged_evidence(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_lane_approval(cfg: Config, entry):
|
def _unsafe_local_demo_enabled(cfg: Config) -> bool:
|
||||||
"""Resolve and enforce the lane approval for a privileged live action."""
|
"""Return true only for an explicit, offline, loopback-only demo."""
|
||||||
|
host = (urlparse(cfg.bao_addr).hostname or "").lower()
|
||||||
|
return (
|
||||||
|
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
|
||||||
|
and not cfg.hub_url
|
||||||
|
and host in {"127.0.0.1", "localhost", "::1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_lane_approval(cfg: Config, entry, action: str = ""):
|
||||||
|
"""Resolve approval for a live action, failing production closed.
|
||||||
|
|
||||||
|
The durable State Hub action-authorization endpoint is not available yet.
|
||||||
|
Production therefore cannot rely on a coarse lane decision. The one narrow
|
||||||
|
exception is an explicit offline demo against a loopback OpenBao instance.
|
||||||
|
"""
|
||||||
|
if entry.stage == "prod" and not _unsafe_local_demo_enabled(cfg):
|
||||||
|
raise DecisionError(
|
||||||
|
f"production action '{action or 'unknown'}' requires a durable "
|
||||||
|
"State Hub action authorization; live production remains disabled"
|
||||||
|
)
|
||||||
if not entry.approval_required():
|
if not entry.approval_required():
|
||||||
return None
|
return None
|
||||||
decision = resolve_decision(
|
decision = resolve_decision(
|
||||||
|
|
@ -207,7 +229,7 @@ def cmd_apply(cfg: Config, args) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
with _privileged_evidence(cfg, entry, "apply") as evidence:
|
with _privileged_evidence(cfg, entry, "apply") as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry)
|
decision = _require_lane_approval(cfg, entry, "apply")
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
plan = build_plan(
|
plan = build_plan(
|
||||||
entry, args.stage, decision_id=decision.id if decision else ""
|
entry, args.stage, decision_id=decision.id if decision else ""
|
||||||
|
|
@ -236,7 +258,7 @@ def cmd_provision(cfg: Config, args) -> int:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry)
|
decision = _require_lane_approval(cfg, entry, "provision")
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -269,7 +291,7 @@ def cmd_verify(cfg: Config, args) -> int:
|
||||||
"negative_requested": negative,
|
"negative_requested": negative,
|
||||||
},
|
},
|
||||||
) as evidence:
|
) as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry)
|
decision = _require_lane_approval(cfg, entry, "verify")
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -345,7 +367,7 @@ def cmd_handoff(cfg: Config, args) -> int:
|
||||||
raise ProvisioningError(
|
raise ProvisioningError(
|
||||||
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
|
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry)
|
decision = _require_lane_approval(cfg, entry, "handoff")
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -397,7 +419,7 @@ def cmd_exec(cfg: Config, args) -> int:
|
||||||
},
|
},
|
||||||
) as evidence:
|
) as evidence:
|
||||||
# require approval + readiness before running.
|
# require approval + readiness before running.
|
||||||
decision = _require_lane_approval(cfg, entry)
|
decision = _require_lane_approval(cfg, entry, "exec")
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
if not args.command:
|
if not args.command:
|
||||||
from secrets_engine.errors import DeliveryError
|
from secrets_engine.errors import DeliveryError
|
||||||
|
|
@ -476,7 +498,7 @@ def cmd_revoke(cfg: Config, args) -> int:
|
||||||
with _privileged_evidence(
|
with _privileged_evidence(
|
||||||
cfg, entry, "revoke", detail={"operation": plan.operation}
|
cfg, entry, "revoke", detail={"operation": plan.operation}
|
||||||
) as evidence:
|
) as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry)
|
decision = _require_lane_approval(cfg, entry, "deactivate")
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
@ -524,7 +546,7 @@ def cmd_lifecycle(cfg: Config, args) -> int:
|
||||||
"live destroy is disabled until an exact-action destruction "
|
"live destroy is disabled until an exact-action destruction "
|
||||||
"approval contract is available; use --dry-run to inspect targets"
|
"approval contract is available; use --dry-run to inspect targets"
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry)
|
decision = _require_lane_approval(cfg, entry, args.operation)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
client = OpenBaoClient.resolve(
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ class EvidenceWriter:
|
||||||
topic_id: str = ""
|
topic_id: str = ""
|
||||||
workstream_id: str = ""
|
workstream_id: str = ""
|
||||||
author: str = "secrets-engine"
|
author: str = "secrets-engine"
|
||||||
|
repo_slug: str = "secrets-engine"
|
||||||
actor: str = field(default_factory=lambda: os.environ.get("USER", "unknown"))
|
actor: str = field(default_factory=lambda: os.environ.get("USER", "unknown"))
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
|
@ -91,8 +92,13 @@ class EvidenceWriter:
|
||||||
}
|
}
|
||||||
self._append_local(record)
|
self._append_local(record)
|
||||||
if hub_requested:
|
if hub_requested:
|
||||||
delivery_result = self._post_hub(
|
delivery = self._post_hub(
|
||||||
action, result, catalog_id, stage, decision_id
|
action,
|
||||||
|
result,
|
||||||
|
catalog_id,
|
||||||
|
stage,
|
||||||
|
decision_id,
|
||||||
|
record_id=record_id,
|
||||||
)
|
)
|
||||||
# Append-only companion evidence makes an unavailable State Hub
|
# Append-only companion evidence makes an unavailable State Hub
|
||||||
# visible without rewriting or delaying the primary local record.
|
# visible without rewriting or delaying the primary local record.
|
||||||
|
|
@ -102,23 +108,32 @@ class EvidenceWriter:
|
||||||
"related_record_id": record_id,
|
"related_record_id": record_id,
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
"action": "evidence-delivery",
|
"action": "evidence-delivery",
|
||||||
"result": delivery_result,
|
"result": delivery.status,
|
||||||
"actor": self.actor,
|
"actor": self.actor,
|
||||||
"catalog_id": catalog_id,
|
"catalog_id": catalog_id,
|
||||||
"stage": stage,
|
"stage": stage,
|
||||||
"decision_id": decision_id,
|
"decision_id": decision_id,
|
||||||
"detail": {},
|
"detail": {"outbox_id": delivery.outbox_id}
|
||||||
|
if delivery.outbox_id
|
||||||
|
else {},
|
||||||
"hub_delivery_requested": False,
|
"hub_delivery_requested": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
def _post_hub(
|
def _post_hub(
|
||||||
self, action: str, result: str, catalog_id: str, stage: str, decision_id: str
|
self,
|
||||||
) -> str:
|
action: str,
|
||||||
|
result: str,
|
||||||
|
catalog_id: str,
|
||||||
|
stage: str,
|
||||||
|
decision_id: str,
|
||||||
|
*,
|
||||||
|
record_id: str,
|
||||||
|
) -> "HubDelivery":
|
||||||
"""Best-effort progress note; return a non-secret delivery outcome."""
|
"""Best-effort progress note; return a non-secret delivery outcome."""
|
||||||
if not self.topic_id:
|
if not self.topic_id:
|
||||||
return "skipped-no-topic"
|
return HubDelivery("skipped-no-topic")
|
||||||
summary = f"secrets-engine {action}: {result}"
|
summary = f"secrets-engine {action}: {result}"
|
||||||
if catalog_id:
|
if catalog_id:
|
||||||
summary += f" [{catalog_id}{'/' + stage if stage else ''}]"
|
summary += f" [{catalog_id}{'/' + stage if stage else ''}]"
|
||||||
|
|
@ -133,17 +148,47 @@ class EvidenceWriter:
|
||||||
if decision_id:
|
if decision_id:
|
||||||
payload["detail"] = {"decision_id": decision_id, "catalog_id": catalog_id}
|
payload["detail"] = {"decision_id": decision_id, "catalog_id": catalog_id}
|
||||||
try:
|
try:
|
||||||
|
idempotency_key = f"secrets-engine:{record_id}"
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
self.hub_url.rstrip("/") + "/progress/",
|
self.hub_url.rstrip("/") + "/progress/",
|
||||||
data=json.dumps(payload).encode(),
|
data=json.dumps(payload).encode(),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": idempotency_key,
|
||||||
|
"X-StateHub-Source-Agent": self.author,
|
||||||
|
"X-StateHub-Repo-Slug": self.repo_slug,
|
||||||
|
},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
urllib.request.urlopen(req, timeout=3).read()
|
response = urllib.request.urlopen(req, timeout=3)
|
||||||
return "delivered"
|
body = response.read()
|
||||||
|
status = getattr(response, "status", 200)
|
||||||
|
if status == 202:
|
||||||
|
try:
|
||||||
|
receipt = json.loads(body or b"{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
receipt = {}
|
||||||
|
if isinstance(receipt, dict) and receipt.get("queued") is True:
|
||||||
|
outbox_id = receipt.get("outbox_id", "")
|
||||||
|
if isinstance(outbox_id, str):
|
||||||
|
try:
|
||||||
|
if str(uuid.UUID(outbox_id)) == outbox_id:
|
||||||
|
return HubDelivery("queued", outbox_id=outbox_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return HubDelivery("failed")
|
||||||
|
return HubDelivery("delivered")
|
||||||
except (urllib.error.URLError, OSError, ValueError):
|
except (urllib.error.URLError, OSError, ValueError):
|
||||||
# Hub being offline must never block secret work or leak anything.
|
# Hub being offline must never block secret work or leak anything.
|
||||||
return "failed"
|
return HubDelivery("failed")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HubDelivery:
|
||||||
|
"""Non-secret outcome returned by State Hub or its edge relay."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
outbox_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
217
src/secrets_engine/service_auth.py
Normal file
217
src/secrets_engine/service_auth.py
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
"""Explicit KeyCape service-JWT provider for future OpenBao JWT login.
|
||||||
|
|
||||||
|
This module implements the accepted KeyCape consumer contract without wiring it
|
||||||
|
into OpenBao yet. The platform owner still needs to publish the exact OpenBao
|
||||||
|
JWT auth mount and role. Keeping provider selection separate prevents an auth
|
||||||
|
failure from falling back to bootstrap, operator, or AppRole credentials.
|
||||||
|
|
||||||
|
JWT parsing here is a claim preflight, not signature verification. OpenBao must
|
||||||
|
verify the RS256 signature against the configured issuer before issuing a token.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from secrets_engine.errors import BackendError
|
||||||
|
from secrets_engine.openbao import read_strict_token_file
|
||||||
|
|
||||||
|
CLIENT_ID = "secrets-engine-openbao"
|
||||||
|
SUBJECT = "service:secrets-engine"
|
||||||
|
PRINCIPAL_TYPE = "service"
|
||||||
|
TENANT = "tenant:coulomb"
|
||||||
|
ROLE = "secrets-engine"
|
||||||
|
SCOPE = "openbao:login"
|
||||||
|
MAX_TOKEN_SECONDS = 15 * 60
|
||||||
|
RENEW_WINDOW_SECONDS = 3 * 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KeyCapeServiceAuthConfig:
|
||||||
|
token_url: str
|
||||||
|
issuer: str
|
||||||
|
client_secret_file: Path
|
||||||
|
client_id: str = CLIENT_ID
|
||||||
|
subject: str = SUBJECT
|
||||||
|
audience: str = CLIENT_ID
|
||||||
|
tenant: str = TENANT
|
||||||
|
required_role: str = ROLE
|
||||||
|
scope: str = SCOPE
|
||||||
|
timeout_seconds: float = 10.0
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.token_url.startswith("https://"):
|
||||||
|
raise BackendError("KeyCape token URL must use HTTPS")
|
||||||
|
if not self.issuer.startswith("https://"):
|
||||||
|
raise BackendError("KeyCape issuer must use HTTPS")
|
||||||
|
if self.client_id != CLIENT_ID or self.audience != CLIENT_ID:
|
||||||
|
raise BackendError("KeyCape service client/audience must match accepted contract")
|
||||||
|
if (
|
||||||
|
self.subject != SUBJECT
|
||||||
|
or self.tenant != TENANT
|
||||||
|
or self.required_role != ROLE
|
||||||
|
or self.scope != SCOPE
|
||||||
|
):
|
||||||
|
raise BackendError("KeyCape service identity claims must match accepted contract")
|
||||||
|
if self.timeout_seconds <= 0:
|
||||||
|
raise BackendError("KeyCape timeout must be positive")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ServiceJWT:
|
||||||
|
token: str = field(repr=False)
|
||||||
|
issued_at: int
|
||||||
|
expires_at: int
|
||||||
|
claims: dict[str, Any] = field(repr=False)
|
||||||
|
|
||||||
|
def needs_renewal(self, now: datetime | None = None) -> bool:
|
||||||
|
current = int((now or datetime.now(timezone.utc)).timestamp())
|
||||||
|
return self.expires_at - current <= RENEW_WINDOW_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_segment(value: str, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
padded = value + "=" * (-len(value) % 4)
|
||||||
|
decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||||
|
parsed = json.loads(decoded)
|
||||||
|
except (UnicodeEncodeError, binascii.Error, json.JSONDecodeError) as e:
|
||||||
|
raise BackendError(f"KeyCape JWT has invalid {label}") from e
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise BackendError(f"KeyCape JWT {label} must be an object")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_service_jwt(
|
||||||
|
token: str,
|
||||||
|
config: KeyCapeServiceAuthConfig,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ServiceJWT:
|
||||||
|
"""Validate non-cryptographic JWT shape/claims before OpenBao login."""
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3 or not all(parts):
|
||||||
|
raise BackendError("KeyCape access token is not a compact JWT")
|
||||||
|
header = _decode_segment(parts[0], "header")
|
||||||
|
claims = _decode_segment(parts[1], "payload")
|
||||||
|
if header.get("alg") != "RS256":
|
||||||
|
raise BackendError("KeyCape JWT algorithm is not RS256")
|
||||||
|
|
||||||
|
exact_claims = {
|
||||||
|
"iss": config.issuer,
|
||||||
|
"sub": config.subject,
|
||||||
|
"aud": config.audience,
|
||||||
|
"principal_type": PRINCIPAL_TYPE,
|
||||||
|
"tenant": config.tenant,
|
||||||
|
"groups": [],
|
||||||
|
}
|
||||||
|
for name, expected in exact_claims.items():
|
||||||
|
if claims.get(name) != expected:
|
||||||
|
raise BackendError(f"KeyCape JWT claim '{name}' does not match contract")
|
||||||
|
if claims.get("roles") != [config.required_role]:
|
||||||
|
raise BackendError("KeyCape JWT roles do not match contract")
|
||||||
|
if claims.get("scope") != config.scope:
|
||||||
|
raise BackendError("KeyCape JWT scope does not match contract")
|
||||||
|
|
||||||
|
assurance = claims.get("assurance")
|
||||||
|
if not isinstance(assurance, dict) or (
|
||||||
|
assurance.get("aal") != "AAL1"
|
||||||
|
or assurance.get("method") != "client_secret"
|
||||||
|
or assurance.get("mfa") is not False
|
||||||
|
or assurance.get("source") != "key-cape"
|
||||||
|
):
|
||||||
|
raise BackendError("KeyCape JWT assurance does not match contract")
|
||||||
|
|
||||||
|
issued_at = claims.get("iat")
|
||||||
|
expires_at = claims.get("exp")
|
||||||
|
if (
|
||||||
|
not isinstance(issued_at, int)
|
||||||
|
or isinstance(issued_at, bool)
|
||||||
|
or not isinstance(expires_at, int)
|
||||||
|
or isinstance(expires_at, bool)
|
||||||
|
):
|
||||||
|
raise BackendError("KeyCape JWT iat/exp must be integer timestamps")
|
||||||
|
current = int((now or datetime.now(timezone.utc)).timestamp())
|
||||||
|
if issued_at > current + 60:
|
||||||
|
raise BackendError("KeyCape JWT issue time is in the future")
|
||||||
|
if expires_at <= current:
|
||||||
|
raise BackendError("KeyCape JWT has expired")
|
||||||
|
if expires_at <= issued_at or expires_at - issued_at > MAX_TOKEN_SECONDS:
|
||||||
|
raise BackendError("KeyCape JWT lifetime exceeds accepted 15-minute bound")
|
||||||
|
return ServiceJWT(
|
||||||
|
token=token,
|
||||||
|
issued_at=issued_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
claims=dict(claims),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Transport = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KeyCapeServiceAuthProvider:
|
||||||
|
config: KeyCapeServiceAuthConfig
|
||||||
|
transport: Transport = field(default=urlopen, repr=False, compare=False)
|
||||||
|
|
||||||
|
def exchange(self, *, now: datetime | None = None) -> ServiceJWT:
|
||||||
|
"""Perform one client-credentials exchange; never retry or fall back."""
|
||||||
|
secret = read_strict_token_file(
|
||||||
|
self.config.client_secret_file,
|
||||||
|
purpose="KeyCape client secret",
|
||||||
|
)
|
||||||
|
basic = base64.b64encode(
|
||||||
|
f"{self.config.client_id}:{secret}".encode("utf-8")
|
||||||
|
).decode("ascii")
|
||||||
|
body = urlencode(
|
||||||
|
{"grant_type": "client_credentials", "scope": self.config.scope}
|
||||||
|
).encode("ascii")
|
||||||
|
request = Request(
|
||||||
|
self.config.token_url,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {basic}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = self.transport(request, timeout=self.config.timeout_seconds)
|
||||||
|
with response:
|
||||||
|
status = getattr(response, "status", 200)
|
||||||
|
raw = response.read()
|
||||||
|
except HTTPError as e:
|
||||||
|
raise BackendError(f"KeyCape token exchange failed with HTTP {e.code}") from e
|
||||||
|
except (URLError, TimeoutError, OSError) as e:
|
||||||
|
raise BackendError("KeyCape token exchange failed") from e
|
||||||
|
if status != 200:
|
||||||
|
raise BackendError(f"KeyCape token exchange failed with HTTP {status}")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||||
|
raise BackendError("KeyCape token response is not valid JSON") from e
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise BackendError("KeyCape token response must be an object")
|
||||||
|
if payload.get("id_token") or payload.get("refresh_token"):
|
||||||
|
raise BackendError("KeyCape service exchange returned a forbidden extra token")
|
||||||
|
token = payload.get("access_token")
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
raise BackendError("KeyCape token response has no access token")
|
||||||
|
if str(payload.get("token_type", "")).lower() != "bearer":
|
||||||
|
raise BackendError("KeyCape token response type is not Bearer")
|
||||||
|
expires_in = payload.get("expires_in")
|
||||||
|
if (
|
||||||
|
not isinstance(expires_in, int)
|
||||||
|
or isinstance(expires_in, bool)
|
||||||
|
or not 0 < expires_in <= MAX_TOKEN_SECONDS
|
||||||
|
):
|
||||||
|
raise BackendError("KeyCape token response lifetime is outside contract")
|
||||||
|
return preflight_service_jwt(token, self.config, now=now)
|
||||||
225
tests/test_action_authorization.py
Normal file
225
tests/test_action_authorization.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import copy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from secrets_engine.authorization import (
|
||||||
|
build_action_request,
|
||||||
|
request_digest,
|
||||||
|
validate_action_authorization,
|
||||||
|
)
|
||||||
|
from secrets_engine.catalog import validate_entry
|
||||||
|
from secrets_engine.errors import DecisionError
|
||||||
|
from tests.test_catalog import VALID
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 23, 10, 5, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _request():
|
||||||
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
|
return build_action_request(
|
||||||
|
entry,
|
||||||
|
"deactivate",
|
||||||
|
subject_id="user:alice",
|
||||||
|
subject_type="Human",
|
||||||
|
purpose="contract-test",
|
||||||
|
fields=["api_token"],
|
||||||
|
policy_targets=[entry.policy_name],
|
||||||
|
auth_targets=[entry.role_name],
|
||||||
|
request_id="check:test-lane-deactivate",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope():
|
||||||
|
request = _request()
|
||||||
|
return {
|
||||||
|
"schema_version": "0.1",
|
||||||
|
"id": "8bfc20be-47a4-4fb0-97a2-bf0a920afad8",
|
||||||
|
"status": "approved",
|
||||||
|
"request": request,
|
||||||
|
"validity": {
|
||||||
|
"not_before": "2026-08-23T10:00:00Z",
|
||||||
|
"expires_at": "2026-08-23T10:15:00Z",
|
||||||
|
},
|
||||||
|
"approvals": {
|
||||||
|
"required_count": 2,
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"subject_id": "user:alice",
|
||||||
|
"approved_at": "2026-08-23T10:01:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subject_id": "user:bob",
|
||||||
|
"approved_at": "2026-08-23T10:02:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"id": "decision:test-lane-deactivate",
|
||||||
|
"request_id": request["id"],
|
||||||
|
"effect": "allow",
|
||||||
|
"resource": copy.deepcopy(request["resource"]),
|
||||||
|
"subject": copy.deepcopy(request["subject"]),
|
||||||
|
"binding": {
|
||||||
|
"subject": copy.deepcopy(request["subject"]),
|
||||||
|
"action": request["action"],
|
||||||
|
"resource": copy.deepcopy(request["resource"]),
|
||||||
|
"context": copy.deepcopy(request["context"]),
|
||||||
|
"request_digest": request_digest(request),
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"evaluator": "flex-auth/local",
|
||||||
|
"mode": "standalone",
|
||||||
|
"policy_package": "secrets-engine.lifecycle",
|
||||||
|
"policy_version": "v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"provenance": {"authority": "state-hub"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(envelope, expected=None):
|
||||||
|
return validate_action_authorization(
|
||||||
|
envelope,
|
||||||
|
expected or _request(),
|
||||||
|
accepted_policy_packages={"secrets-engine.lifecycle"},
|
||||||
|
accepted_policy_versions={"v1"},
|
||||||
|
minimum_approval_count=2,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_matches_flex_auth_contract_example():
|
||||||
|
request = {
|
||||||
|
"id": "check:secrets-engine-destroy-example",
|
||||||
|
"subject": {"id": "user:alice", "type": "Human"},
|
||||||
|
"action": "destroy",
|
||||||
|
"resource": {
|
||||||
|
"id": "catalog:example-build-test-token",
|
||||||
|
"type": "secret-catalog-lane",
|
||||||
|
"system": "secrets-engine",
|
||||||
|
"attributes": {
|
||||||
|
"stage": "build",
|
||||||
|
"fields": ["token"],
|
||||||
|
"policy_targets": [],
|
||||||
|
"auth_targets": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"context": {"purpose": "contract-test"},
|
||||||
|
}
|
||||||
|
# Generated independently with flex-auth's Go api.CheckRequest and
|
||||||
|
# encoding/json. The action_authorization.json example carried a stale
|
||||||
|
# digest when this consumer contract was implemented.
|
||||||
|
assert request_digest(request) == (
|
||||||
|
"sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_exact_action_authorization_passes():
|
||||||
|
result = _validate(_envelope())
|
||||||
|
assert result.authorization_id == "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
|
||||||
|
assert result.action == "deactivate"
|
||||||
|
assert result.subject_id == "user:alice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mutation", "match"),
|
||||||
|
[
|
||||||
|
(lambda doc: doc.update(status="superseded"), "status is not approved"),
|
||||||
|
(
|
||||||
|
lambda doc: doc["request"]["resource"].update(id="catalog:wrong"),
|
||||||
|
"does not exactly match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["request"].update(action="destroy"),
|
||||||
|
"does not exactly match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["request"]["resource"]["attributes"].update(
|
||||||
|
fields=["other"]
|
||||||
|
),
|
||||||
|
"does not exactly match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["request"]["context"].update(purpose="wrong"),
|
||||||
|
"does not exactly match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["decision"].update(effect="deny"),
|
||||||
|
"effect is not allow",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["decision"]["binding"].update(
|
||||||
|
request_digest="sha256:" + "0" * 64
|
||||||
|
),
|
||||||
|
"digest does not match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["approvals"]["entries"][1].update(
|
||||||
|
subject_id="user:alice"
|
||||||
|
),
|
||||||
|
"duplicate approver",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["approvals"].update(required_count=1),
|
||||||
|
"threshold is insufficient",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["decision"].update(request_id="check:wrong"),
|
||||||
|
"request id does not match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda doc: doc["provenance"].update(authority="local-fixture"),
|
||||||
|
"authority is not State Hub",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_authorizations_fail_closed(mutation, match):
|
||||||
|
envelope = _envelope()
|
||||||
|
mutation(envelope)
|
||||||
|
with pytest.raises(DecisionError, match=match):
|
||||||
|
_validate(envelope)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_authorization_fails_closed():
|
||||||
|
with pytest.raises(DecisionError, match="expired"):
|
||||||
|
validate_action_authorization(
|
||||||
|
_envelope(),
|
||||||
|
_request(),
|
||||||
|
accepted_policy_packages={"secrets-engine.lifecycle"},
|
||||||
|
accepted_policy_versions={"v1"},
|
||||||
|
now=datetime(2026, 8, 23, 10, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_noncanonical_authorization_uuid_is_rejected():
|
||||||
|
envelope = _envelope()
|
||||||
|
envelope["id"] = envelope["id"].replace("-", "")
|
||||||
|
with pytest.raises(DecisionError, match="canonical UUID"):
|
||||||
|
_validate(envelope)
|
||||||
|
|
||||||
|
|
||||||
|
def test_approval_timestamp_must_be_inside_current_authorization_window():
|
||||||
|
envelope = _envelope()
|
||||||
|
envelope["approvals"]["entries"][1]["approved_at"] = "2026-08-23T10:06:00Z"
|
||||||
|
with pytest.raises(DecisionError, match="approval time is outside window"):
|
||||||
|
_validate(envelope)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsorted_or_duplicate_target_sets_are_rejected():
|
||||||
|
envelope = _envelope()
|
||||||
|
envelope["request"]["resource"]["attributes"]["fields"] = [
|
||||||
|
"second",
|
||||||
|
"first",
|
||||||
|
"first",
|
||||||
|
]
|
||||||
|
with pytest.raises(DecisionError, match="sorted and unique"):
|
||||||
|
_validate(envelope, expected=envelope["request"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_unaccepted_policy_revision_is_rejected():
|
||||||
|
envelope = _envelope()
|
||||||
|
envelope["decision"]["provenance"]["policy_version"] = "v2"
|
||||||
|
with pytest.raises(DecisionError, match="policy version is not accepted"):
|
||||||
|
_validate(envelope)
|
||||||
|
|
@ -96,6 +96,24 @@ def test_lane_audit_empty_directory_is_a_valid_empty_summary(tmp_path):
|
||||||
assert summary.render().startswith("Lane audit summary for 'test-lane'")
|
assert summary.render().startswith("Lane audit summary for 'test-lane'")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lane_audit_counts_edge_queued_delivery(tmp_path):
|
||||||
|
path = tmp_path / "evidence-2026-08-23.jsonl"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"catalog_id": "test-lane",
|
||||||
|
"action": "evidence-delivery",
|
||||||
|
"result": "queued",
|
||||||
|
"detail": {"outbox_id": "3f12014e-47c1-48a5-9c8f-774c1dac1853"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
summary = summarize_lane_evidence(tmp_path, "test-lane")
|
||||||
|
assert summary.hub_delivery == {"queued": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_cli_parser_exposes_audit_json_command():
|
def test_cli_parser_exposes_audit_json_command():
|
||||||
args = build_parser().parse_args(["audit", "test-lane", "--json"])
|
args = build_parser().parse_args(["audit", "test-lane", "--json"])
|
||||||
assert args.catalog_id == "test-lane"
|
assert args.catalog_id == "test-lane"
|
||||||
|
|
|
||||||
|
|
@ -72,3 +72,37 @@ def test_privileged_lane_helper_accepts_local_approval(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(cli, "repo_root", lambda: tmp_path)
|
monkeypatch.setattr(cli, "repo_root", lambda: tmp_path)
|
||||||
decision = _require_lane_approval(SimpleNamespace(hub_url=""), _approved())
|
decision = _require_lane_approval(SimpleNamespace(hub_url=""), _approved())
|
||||||
assert decision.id == "x"
|
assert decision.id == "x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_action_fails_closed_before_legacy_decision(monkeypatch):
|
||||||
|
import secrets_engine.cli as cli
|
||||||
|
|
||||||
|
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
|
||||||
|
cfg = SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200")
|
||||||
|
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
||||||
|
with pytest.raises(DecisionError, match="production action 'apply'"):
|
||||||
|
_require_lane_approval(cfg, entry, "apply")
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_demo_requires_all_three_safety_conditions(tmp_path, monkeypatch):
|
||||||
|
import secrets_engine.cli as cli
|
||||||
|
|
||||||
|
(tmp_path / ".decisions").mkdir()
|
||||||
|
(tmp_path / ".decisions" / "x.yaml").write_text(
|
||||||
|
"id: x\ntitle: approved\nstatus: resolved\nsuperseded_by: null\n"
|
||||||
|
)
|
||||||
|
entry = validate_entry(
|
||||||
|
dict(VALID, stage="prod", approval={"model": "decision", "decision_ref": "x"})
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(cli, "repo_root", lambda: tmp_path)
|
||||||
|
monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1")
|
||||||
|
|
||||||
|
allowed = SimpleNamespace(hub_url="", bao_addr="http://127.0.0.1:8200")
|
||||||
|
assert _require_lane_approval(allowed, entry, "apply").id == "x"
|
||||||
|
|
||||||
|
for cfg in (
|
||||||
|
SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200"),
|
||||||
|
SimpleNamespace(hub_url="", bao_addr="https://bao.example.test"),
|
||||||
|
):
|
||||||
|
with pytest.raises(DecisionError, match="live production remains disabled"):
|
||||||
|
_require_lane_approval(cfg, entry, "apply")
|
||||||
|
|
|
||||||
|
|
@ -96,3 +96,28 @@ def test_provision_decision_rejection_is_recorded_before_backend(
|
||||||
]
|
]
|
||||||
assert records[-1]["detail"]["approval_status"] == "rejected"
|
assert records[-1]["detail"]["approval_status"] == "rejected"
|
||||||
assert records[-1]["detail"]["decision_ref"] == "CCR-2026-0001"
|
assert records[-1]["detail"]["decision_ref"] == "CCR-2026-0001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_handler_fails_closed_before_backend(tmp_path, monkeypatch):
|
||||||
|
data = copy.deepcopy(VALID)
|
||||||
|
data.update(
|
||||||
|
stage="prod",
|
||||||
|
approval={"model": "decision", "decision_ref": "CCR-2026-0001"},
|
||||||
|
)
|
||||||
|
entry = validate_entry(data)
|
||||||
|
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
|
||||||
|
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli.OpenBaoClient,
|
||||||
|
"resolve",
|
||||||
|
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(DecisionError, match="production action 'provision'"):
|
||||||
|
cli.cmd_provision(_config(tmp_path), _provision_args(entry))
|
||||||
|
|
||||||
|
records = _records(tmp_path)
|
||||||
|
assert [record["result"] for record in records] == [
|
||||||
|
"attempt",
|
||||||
|
"failed-DecisionError",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,15 @@ def test_evidence_record_has_no_value(tmp_path):
|
||||||
|
|
||||||
|
|
||||||
def test_evidence_records_append_only_hub_delivery_success(tmp_path, monkeypatch):
|
def test_evidence_records_append_only_hub_delivery_success(tmp_path, monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def delivered(request, **_kwargs):
|
||||||
|
captured["headers"] = dict(request.header_items())
|
||||||
|
return SimpleNamespace(status=200, read=lambda: b"{}")
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"urllib.request.urlopen",
|
"urllib.request.urlopen",
|
||||||
lambda *_args, **_kwargs: SimpleNamespace(read=lambda: b"{}"),
|
delivered,
|
||||||
)
|
)
|
||||||
writer = EvidenceWriter(
|
writer = EvidenceWriter(
|
||||||
evidence_dir=tmp_path,
|
evidence_dir=tmp_path,
|
||||||
|
|
@ -68,6 +74,62 @@ def test_evidence_records_append_only_hub_delivery_success(tmp_path, monkeypatch
|
||||||
assert lines[1]["action"] == "evidence-delivery"
|
assert lines[1]["action"] == "evidence-delivery"
|
||||||
assert lines[1]["result"] == "delivered"
|
assert lines[1]["result"] == "delivered"
|
||||||
assert lines[1]["related_record_id"] == primary["record_id"]
|
assert lines[1]["related_record_id"] == primary["record_id"]
|
||||||
|
assert captured["headers"]["Idempotency-key"] == (
|
||||||
|
f"secrets-engine:{primary['record_id']}"
|
||||||
|
)
|
||||||
|
assert captured["headers"]["X-statehub-source-agent"] == "secrets-engine"
|
||||||
|
assert captured["headers"]["X-statehub-repo-slug"] == "secrets-engine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_records_edge_queued_receipt_as_queued(tmp_path, monkeypatch):
|
||||||
|
outbox_id = "3f12014e-47c1-48a5-9c8f-774c1dac1853"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
lambda *_args, **_kwargs: SimpleNamespace(
|
||||||
|
status=202,
|
||||||
|
read=lambda: json.dumps(
|
||||||
|
{"queued": True, "outbox_id": outbox_id}
|
||||||
|
).encode(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
writer = EvidenceWriter(
|
||||||
|
evidence_dir=tmp_path,
|
||||||
|
hub_url="http://edge.invalid",
|
||||||
|
topic_id="topic-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.record("verify", result="pass", catalog_id="lane")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in next(tmp_path.glob("evidence-*.jsonl")).read_text().splitlines()
|
||||||
|
]
|
||||||
|
assert lines[-1]["action"] == "evidence-delivery"
|
||||||
|
assert lines[-1]["result"] == "queued"
|
||||||
|
assert lines[-1]["detail"] == {"outbox_id": outbox_id}
|
||||||
|
assert "upstream" not in json.dumps(lines[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_rejects_malformed_queued_receipt(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
lambda *_args, **_kwargs: SimpleNamespace(
|
||||||
|
status=202,
|
||||||
|
read=lambda: b'{"queued":true,"outbox_id":"not-a-uuid"}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
writer = EvidenceWriter(
|
||||||
|
evidence_dir=tmp_path,
|
||||||
|
hub_url="http://edge.invalid",
|
||||||
|
topic_id="topic-id",
|
||||||
|
)
|
||||||
|
writer.record("verify", result="pass", catalog_id="lane")
|
||||||
|
lines = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in next(tmp_path.glob("evidence-*.jsonl")).read_text().splitlines()
|
||||||
|
]
|
||||||
|
assert lines[-1]["result"] == "failed"
|
||||||
|
assert lines[-1]["detail"] == {}
|
||||||
|
|
||||||
|
|
||||||
def test_evidence_records_hub_failure_without_raising(tmp_path, monkeypatch):
|
def test_evidence_records_hub_failure_without_raising(tmp_path, monkeypatch):
|
||||||
|
|
|
||||||
159
tests/test_service_auth.py
Normal file
159
tests/test_service_auth.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from secrets_engine.errors import BackendError
|
||||||
|
from secrets_engine.service_auth import (
|
||||||
|
KeyCapeServiceAuthConfig,
|
||||||
|
KeyCapeServiceAuthProvider,
|
||||||
|
preflight_service_jwt,
|
||||||
|
)
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 23, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _segment(value):
|
||||||
|
return base64.urlsafe_b64encode(
|
||||||
|
json.dumps(value, separators=(",", ":")).encode()
|
||||||
|
).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt(**overrides):
|
||||||
|
now = int(NOW.timestamp())
|
||||||
|
claims = {
|
||||||
|
"iss": "https://keycape.example.test",
|
||||||
|
"sub": "service:secrets-engine",
|
||||||
|
"aud": "secrets-engine-openbao",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 900,
|
||||||
|
"principal_type": "service",
|
||||||
|
"tenant": "tenant:coulomb",
|
||||||
|
"roles": ["secrets-engine"],
|
||||||
|
"groups": [],
|
||||||
|
"scope": "openbao:login",
|
||||||
|
"assurance": {
|
||||||
|
"aal": "AAL1",
|
||||||
|
"method": "client_secret",
|
||||||
|
"mfa": False,
|
||||||
|
"source": "key-cape",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
claims.update(overrides)
|
||||||
|
return f"{_segment({'alg': 'RS256', 'typ': 'JWT'})}.{_segment(claims)}.signature"
|
||||||
|
|
||||||
|
|
||||||
|
def _config(secret_file=Path("/tmp/keycape-client-secret")):
|
||||||
|
return KeyCapeServiceAuthConfig(
|
||||||
|
token_url="https://keycape.example.test/token",
|
||||||
|
issuer="https://keycape.example.test",
|
||||||
|
client_secret_file=secret_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_accepts_exact_service_contract_and_renewal_window():
|
||||||
|
token = _jwt()
|
||||||
|
result = preflight_service_jwt(token, _config(), now=NOW)
|
||||||
|
assert result.expires_at - result.issued_at == 900
|
||||||
|
assert not result.needs_renewal(NOW)
|
||||||
|
assert result.needs_renewal(datetime.fromtimestamp(result.expires_at - 180, timezone.utc))
|
||||||
|
assert token not in repr(result)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("override", "match"),
|
||||||
|
[
|
||||||
|
({"iss": "https://attacker.test"}, "'iss'"),
|
||||||
|
({"sub": "service:operator"}, "'sub'"),
|
||||||
|
({"aud": "different"}, "'aud'"),
|
||||||
|
({"tenant": "tenant:other"}, "'tenant'"),
|
||||||
|
({"roles": ["admin"]}, "roles"),
|
||||||
|
({"scope": "openbao:login admin"}, "scope"),
|
||||||
|
({"exp": int(NOW.timestamp()) + 901}, "15-minute"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_preflight_rejects_claim_or_lifetime_drift(override, match):
|
||||||
|
with pytest.raises(BackendError, match=match):
|
||||||
|
preflight_service_jwt(_jwt(**override), _config(), now=NOW)
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
def __init__(self, payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return json.dumps(self.payload).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_uses_basic_auth_and_never_places_secret_in_body(tmp_path):
|
||||||
|
secret_file = tmp_path / "client.secret"
|
||||||
|
secret_file.write_text("client-secret-value")
|
||||||
|
secret_file.chmod(0o600)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def transport(request, *, timeout):
|
||||||
|
captured["authorization"] = request.get_header("Authorization")
|
||||||
|
captured["body"] = request.data.decode()
|
||||||
|
captured["timeout"] = timeout
|
||||||
|
return _Response(
|
||||||
|
{
|
||||||
|
"access_token": _jwt(),
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 900,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = KeyCapeServiceAuthProvider(_config(secret_file), transport=transport)
|
||||||
|
result = provider.exchange(now=NOW)
|
||||||
|
assert result.expires_at == int(NOW.timestamp()) + 900
|
||||||
|
assert captured["authorization"].startswith("Basic ")
|
||||||
|
assert "client-secret-value" not in captured["body"]
|
||||||
|
assert captured["body"] == "grant_type=client_credentials&scope=openbao%3Alogin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_rejects_refresh_or_id_tokens_without_exposing_secret(tmp_path):
|
||||||
|
secret = "do-not-leak-this-client-secret"
|
||||||
|
secret_file = tmp_path / "client.secret"
|
||||||
|
secret_file.write_text(secret)
|
||||||
|
secret_file.chmod(0o600)
|
||||||
|
|
||||||
|
def transport(_request, *, timeout):
|
||||||
|
return _Response(
|
||||||
|
{
|
||||||
|
"access_token": _jwt(),
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 900,
|
||||||
|
"refresh_token": "also-secret",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = KeyCapeServiceAuthProvider(_config(secret_file), transport=transport)
|
||||||
|
with pytest.raises(BackendError, match="forbidden extra token") as error:
|
||||||
|
provider.exchange(now=NOW)
|
||||||
|
assert secret not in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_config_is_exact_and_has_no_bootstrap_fallback():
|
||||||
|
with pytest.raises(BackendError, match="HTTPS"):
|
||||||
|
KeyCapeServiceAuthConfig(
|
||||||
|
token_url="http://keycape.example.test/token",
|
||||||
|
issuer="https://keycape.example.test",
|
||||||
|
client_secret_file=Path("/tmp/unused"),
|
||||||
|
)
|
||||||
|
with pytest.raises(BackendError, match="accepted contract"):
|
||||||
|
KeyCapeServiceAuthConfig(
|
||||||
|
token_url="https://keycape.example.test/token",
|
||||||
|
issuer="https://keycape.example.test",
|
||||||
|
client_secret_file=Path("/tmp/unused"),
|
||||||
|
client_id="operator",
|
||||||
|
)
|
||||||
|
|
@ -198,15 +198,23 @@ Acceptance:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0007-T04
|
id: SECRETS-WP-0007-T04
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
Waiting 2026-08-23 on the canonical external contract rather than parsing
|
Progress 2026-08-23. flex-auth revision `c473f19` now supplies execute-time
|
||||||
decision prose or inventing authorization ownership locally. Contract requests
|
structured binding and the proposed durable `ActionAuthorization` schema. The
|
||||||
were sent to State Hub (`24663321-0263-43fe-8d48-e9c7e06d7bb9`) and flex-auth
|
engine builds and fail-closed validates the exact secrets-engine profile,
|
||||||
(`ef8ff95d-6b4e-46f8-b1a9-497d06cf7c9a`). Until resolved, local fixtures cannot
|
validity, State Hub authority, decision digest/binding, accepted policy
|
||||||
unlock the new destroy path and that live operation remains disabled.
|
revision, and a caller-selected minimum distinct-approver threshold. Every live
|
||||||
|
production handler now carries its action into the shared gate and fails before
|
||||||
|
OpenBao while State Hub lacks the durable endpoint. Legacy local decisions are
|
||||||
|
reachable for prod-labeled lanes only with an explicit unsafe-demo switch,
|
||||||
|
disabled Hub URL, and loopback OpenBao. The throwaway scripts declare that
|
||||||
|
exception. Live destroy remains disabled independently. State Hub endpoint and
|
||||||
|
authenticated approval storage are still outstanding; the flex-auth example's
|
||||||
|
committed digest was also found stale against its Go `CheckRequest` encoder and
|
||||||
|
must be corrected upstream.
|
||||||
|
|
||||||
Define and enforce the decision contract needed by production commands. A
|
Define and enforce the decision contract needed by production commands. A
|
||||||
resolved approval must bind at least:
|
resolved approval must bind at least:
|
||||||
|
|
@ -256,6 +264,13 @@ to railiance-platform (`8f910aff-3a94-43c6-8805-eb9276e46fc0`) and key-cape
|
||||||
(`0627ca55-115e-43dc-b2f6-3195be3bc90d`); steady-state engine authentication
|
(`0627ca55-115e-43dc-b2f6-3195be3bc90d`); steady-state engine authentication
|
||||||
remains external-contract work.
|
remains external-contract work.
|
||||||
|
|
||||||
|
KeyCape has accepted the `secrets-engine-openbao` service contract. An explicit
|
||||||
|
consumer scaffold now performs HTTPS client-credentials exchange, strict
|
||||||
|
out-of-repo client-secret input, exact claim/assurance/lifetime preflight,
|
||||||
|
three-minute renewal classification, and rejection of ID/refresh tokens. It has
|
||||||
|
no bootstrap/operator/AppRole fallback and is deliberately not wired into the
|
||||||
|
CLI until railiance-platform materializes the exact OpenBao JWT mount and role.
|
||||||
|
|
||||||
Refactor AppRole login into a scoped session object that retains only the
|
Refactor AppRole login into a scoped session object that retains only the
|
||||||
minimum non-secret lifecycle handle needed to revoke the issued token in a
|
minimum non-secret lifecycle handle needed to revoke the issued token in a
|
||||||
`finally` path. Apply it consistently to exec, KV verification, auth-capability
|
`finally` path. Apply it consistently to exec, KV verification, auth-capability
|
||||||
|
|
@ -304,8 +319,13 @@ rejection, backend/input exceptions, interruption, verification failure, and
|
||||||
success are recorded without exception prose. Tests prove decision and backend
|
success are recorded without exception prose. Tests prove decision and backend
|
||||||
failures stop before inappropriate backend work and exclude fake secret text.
|
failures stop before inappropriate backend work and exclude fake secret text.
|
||||||
|
|
||||||
The complete repository suite passes with 115 tests after these changes,
|
State Hub evidence posts now carry stable idempotency, source-agent, and repo
|
||||||
including throwaway OpenBao integration coverage.
|
headers. HTTP 202 edge-relay receipts are classified as `queued` and retain only
|
||||||
|
their non-secret outbox id in the companion evidence record and lane audit.
|
||||||
|
Outbox replay remains owned by State Hub/operator tooling.
|
||||||
|
|
||||||
|
The complete repository suite is re-run at each implementation slice, including
|
||||||
|
throwaway OpenBao integration coverage.
|
||||||
|
|
||||||
Make verification and routing truthful for multi-field and high-risk lanes:
|
Make verification and routing truthful for multi-field and high-risk lanes:
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue