Add named engine auth and accessor-file session revoke
Select service-jwt, bootstrap, or env exclusively: JWT login uses a JSON file, self-revokes, and never falls back to bootstrap or BAO_TOKEN. The platform JWT mount/role is still unpublished, so auto keeps named bootstrap/env providers. session revoke --accessor-file revokes an already-issued token with fingerprint-only evidence. Production remains fail-closed. Assistant: grok Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
This commit is contained in:
parent
a94003de4f
commit
3abee434df
18 changed files with 698 additions and 135 deletions
|
|
@ -64,6 +64,7 @@ SECRETS_ENGINE_HUB_URL="" bash scripts/demo-e2e.sh
|
||||||
- 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)
|
- KeyCape service-auth consumer boundary: [docs/service-auth.md](docs/service-auth.md)
|
||||||
- Approval consume-before-OpenBao (GH-DEC-2026-003): [docs/approval-consumption.md](docs/approval-consumption.md)
|
- Approval consume-before-OpenBao (GH-DEC-2026-003): [docs/approval-consumption.md](docs/approval-consumption.md)
|
||||||
|
- OpenBao JWT login contract (engine consumer): [docs/openbao-jwt-login.md](docs/openbao-jwt-login.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
|
||||||
|
|
|
||||||
18
SCOPE.md
18
SCOPE.md
|
|
@ -152,9 +152,13 @@ do not replace exact-action authorization or OpenBao audit logs.
|
||||||
boundary.
|
boundary.
|
||||||
- Never retries into or falls back to bootstrap, operator, or AppRole auth.
|
- Never retries into or falls back to bootstrap, operator, or AppRole auth.
|
||||||
|
|
||||||
This provider is deliberately not connected to OpenBao. Signature verification
|
The CLI now has a named `service-jwt` provider that logs in through that
|
||||||
and token issuance remain with the platform-owned exact-bound OpenBao JWT role,
|
scaffold when the platform JWT mount/role contract is present, then
|
||||||
whose mount/role contract is still outstanding.
|
self-revokes the issued OpenBao token. Signature verification and token
|
||||||
|
issuance remain with the platform-owned exact-bound OpenBao JWT role, whose
|
||||||
|
mount/role contract is still outstanding. Until it is published, `--auth auto`
|
||||||
|
keeps named bootstrap/env providers and never treats them as a fallback from
|
||||||
|
service-jwt failure.
|
||||||
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.
|
||||||
|
|
||||||
|
|
@ -173,9 +177,11 @@ high-risk lanes.
|
||||||
catalog-id confirmation; it will remain closed until the canonical
|
catalog-id confirmation; it will remain closed until the canonical
|
||||||
exact-action approval contract in `SECRETS-WP-0007-T04` is enforced.
|
exact-action approval contract in `SECRETS-WP-0007-T04` is enforced.
|
||||||
|
|
||||||
These operations do not manage external workload delivery. There is currently
|
These operations do not manage external workload delivery. `session revoke
|
||||||
no general lease/accessor operator command, rotation command, compromised state,
|
--accessor-file` revokes an already-issued token by a non-secret accessor the
|
||||||
or persistent/reversible lane state machine.
|
operator already holds; evidence is fingerprint-only. There is currently no
|
||||||
|
rotation command, compromised state, or persistent/reversible lane state
|
||||||
|
machine.
|
||||||
|
|
||||||
## CLI Surface
|
## CLI Surface
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,15 @@ secrets-engine --version
|
||||||
| Var | Default | Purpose |
|
| Var | Default | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `BAO_ADDR` | `http://127.0.0.1:8200` | OpenBao address |
|
| `BAO_ADDR` | `http://127.0.0.1:8200` | OpenBao address |
|
||||||
| `BAO_TOKEN` | _(unset)_ | OpenBao token (or use `--bootstrap-token-file`) |
|
| `BAO_TOKEN` | _(unset)_ | Named `env` OpenBao token; never a fallback from `service-jwt` |
|
||||||
| `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 |
|
| `SECRETS_ENGINE_UNSAFE_DEMO` | _(unset)_ | allow a prod-labeled lane only when Hub is disabled and OpenBao is loopback; throwaway demos only |
|
||||||
|
| `SECRETS_ENGINE_KEYCAPE_TOKEN_URL` | _(unset)_ | KeyCape token endpoint for `service-jwt` |
|
||||||
|
| `SECRETS_ENGINE_KEYCAPE_ISSUER` | _(unset)_ | KeyCape issuer; must match the JWT login contract |
|
||||||
|
| `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` | _(unset)_ | mode-0600 out-of-repo client secret |
|
||||||
|
| `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` | _(unset)_ | platform JWT mount/role contract YAML |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
@ -55,6 +59,7 @@ secrets-engine exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-confi
|
||||||
secrets-engine policy publication <catalog-id>
|
secrets-engine policy publication <catalog-id>
|
||||||
secrets-engine route <catalog-id> [--json]
|
secrets-engine route <catalog-id> [--json]
|
||||||
secrets-engine revoke <catalog-id> [--dry-run]
|
secrets-engine revoke <catalog-id> [--dry-run]
|
||||||
|
secrets-engine session revoke --accessor-file F [--stage stage]
|
||||||
secrets-engine lifecycle suspend <catalog-id> [--dry-run]
|
secrets-engine lifecycle suspend <catalog-id> [--dry-run]
|
||||||
secrets-engine lifecycle deactivate <catalog-id> [--dry-run]
|
secrets-engine lifecycle deactivate <catalog-id> [--dry-run]
|
||||||
secrets-engine lifecycle destroy <catalog-id> [--dry-run] [--confirm-destroy <catalog-id>]
|
secrets-engine lifecycle destroy <catalog-id> [--dry-run] [--confirm-destroy <catalog-id>]
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,15 @@ contents in this repo.
|
||||||
- Implemented consumer scaffold: exact KeyCape client-credentials exchange,
|
- Implemented consumer scaffold: exact KeyCape client-credentials exchange,
|
||||||
claim/lifetime preflight, strict client-secret file input, renewal boundary,
|
claim/lifetime preflight, strict client-secret file input, renewal boundary,
|
||||||
and no implicit fallback.
|
and no implicit fallback.
|
||||||
|
- Implemented named providers (`service-jwt`, `bootstrap`, `env`) with no
|
||||||
|
JWT→bootstrap/env fallback. JWT login uses a JSON file, never argv, and
|
||||||
|
self-revokes the issued OpenBao token.
|
||||||
- Remaining: railiance-platform stands up the exact-bound OpenBao JWT auth
|
- Remaining: railiance-platform stands up the exact-bound OpenBao JWT auth
|
||||||
mount/role and publishes its non-secret connection contract.
|
mount/role and publishes its non-secret connection contract
|
||||||
- secrets-engine logs in via that method instead of reading a token file.
|
(`SECRETS_ENGINE_OPENBAO_JWT_LOGIN`). Until then `--auth service-jwt`
|
||||||
- Remove `--bootstrap-token-file` from the steady-state path (keep only for true
|
fail-closes and `--auth auto` keeps named bootstrap/env providers.
|
||||||
break-glass, heavily audited).
|
- `--bootstrap-token-file` is a named break-glass provider with distinct
|
||||||
|
evidence, not an implicit fallback.
|
||||||
|
|
||||||
## H2 — Response-wrapped handoff
|
## H2 — Response-wrapped handoff
|
||||||
|
|
||||||
|
|
@ -67,9 +71,10 @@ contents in this repo.
|
||||||
## H4a — Known-accessor operator command
|
## H4a — Known-accessor operator command
|
||||||
|
|
||||||
- Delivery sessions already self-revoke in `finally` (`SECRETS-WP-0007-T05`).
|
- Delivery sessions already self-revoke in `finally` (`SECRETS-WP-0007-T05`).
|
||||||
- Residual from T03: no general CLI to revoke an already-issued token or
|
- Implemented: `secrets-engine session revoke --accessor-file F` reads a
|
||||||
lease by a non-secret accessor the operator already holds.
|
mode-0600 out-of-repo accessor, calls `token revoke -accessor`, and records
|
||||||
- Do not print accessors. Prefer fingerprint-only evidence.
|
only a fingerprint. Production remains fail-closed. Lease-id revoke is still
|
||||||
|
outstanding.
|
||||||
|
|
||||||
## H5 — Audit report command
|
## H5 — Audit report command
|
||||||
|
|
||||||
|
|
|
||||||
23
docs/openbao-jwt-login.md
Normal file
23
docs/openbao-jwt-login.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# OpenBao JWT login contract (engine consumer)
|
||||||
|
|
||||||
|
Steady-state engine authentication is the reviewed KeyCape
|
||||||
|
`secrets-engine-openbao` identity plus a **platform-owned** OpenBao JWT auth
|
||||||
|
mount and exact-bound role. This repository does not invent that mount.
|
||||||
|
|
||||||
|
Point `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` at a mode-normal YAML file outside
|
||||||
|
the need to hold a standing OpenBao token:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mount: jwt
|
||||||
|
role: secrets-engine
|
||||||
|
bound_issuer: https://<keycape-issuer>
|
||||||
|
```
|
||||||
|
|
||||||
|
`bound_issuer` must equal `SECRETS_ENGINE_KEYCAPE_ISSUER`. The JWT is sent
|
||||||
|
through a temporary JSON file, never argv. The resulting OpenBao token is
|
||||||
|
revoked at the end of the command (`token revoke -self`). Evidence records
|
||||||
|
the provider name and an accessor fingerprint only.
|
||||||
|
|
||||||
|
Until railiance-platform publishes that file, `--auth auto` keeps the named
|
||||||
|
bootstrap-file and `BAO_TOKEN` providers. `--auth service-jwt` fail-closes.
|
||||||
|
A service-jwt selection never reads `--bootstrap-token-file` or `BAO_TOKEN`.
|
||||||
|
|
@ -17,12 +17,17 @@ outside every Git worktree. It is sent with HTTP Basic authentication and never
|
||||||
placed in a request body, command argument, evidence record, or object
|
placed in a request body, command argument, evidence record, or object
|
||||||
representation.
|
representation.
|
||||||
|
|
||||||
This is a scaffold, not a live OpenBao authentication path. JWT payload parsing
|
JWT payload parsing does not prove a signature. OpenBao must verify RS256
|
||||||
does not prove a signature. railiance-platform still owns the exact OpenBao JWT
|
against the configured issuer before issuing a token. railiance-platform owns
|
||||||
auth mount/role, issuer keys, claim bindings, token policy, TTL/use limits, and
|
the exact OpenBao JWT auth mount/role, issuer keys, claim bindings, token
|
||||||
cryptographic verification. Until that contract is materialized, the provider
|
policy, TTL/use limits, and cryptographic verification.
|
||||||
is not selected by the CLI and bootstrap/AppRole behavior is not used as an
|
|
||||||
implicit fallback.
|
When `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` names that contract, the CLI selects
|
||||||
|
`service-jwt` and logs in for one command, then self-revokes the OpenBao
|
||||||
|
token. Failure of that path never falls back to `--bootstrap-token-file`,
|
||||||
|
`BAO_TOKEN`, or AppRole. Until the contract is published, `--auth auto` keeps
|
||||||
|
those named providers and `--auth service-jwt` fail-closes. See
|
||||||
|
[openbao-jwt-login.md](openbao-jwt-login.md).
|
||||||
|
|
||||||
Canonical provider contract:
|
Canonical provider contract:
|
||||||
`key-cape/docs/openbao-service-auth-contract.md` (reviewed 2026-08-23).
|
`key-cape/docs/openbao-service-auth-contract.md` (reviewed 2026-08-23).
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ rules:
|
||||||
|
|
||||||
- id: production-control-mutation
|
- id: production-control-mutation
|
||||||
kind: load-bearing
|
kind: load-bearing
|
||||||
actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision]
|
actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision, session-revoke]
|
||||||
stages: [prod]
|
stages: [prod]
|
||||||
emission: local-outbox
|
emission: local-outbox
|
||||||
note: >-
|
note: >-
|
||||||
|
|
|
||||||
13
layer.yaml
13
layer.yaml
|
|
@ -33,6 +33,7 @@ owned_tooling:
|
||||||
operations:
|
operations:
|
||||||
- "bao policy/auth/kv subprocess adapter"
|
- "bao policy/auth/kv subprocess adapter"
|
||||||
- "CAS-aware KV create/patch via JSON input files, never argv values"
|
- "CAS-aware KV create/patch via JSON input files, never argv values"
|
||||||
|
- "JWT login via JSON input files; issued engine tokens self-revoke"
|
||||||
note: >-
|
note: >-
|
||||||
This is the owned Lifecycle contact, not a Staff §5 shape. A new direct
|
This is the owned Lifecycle contact, not a Staff §5 shape. A new direct
|
||||||
OpenBao client outside the listed modules is a finding.
|
OpenBao client outside the listed modules is a finding.
|
||||||
|
|
@ -56,6 +57,7 @@ protected_actions:
|
||||||
- lifecycle-suspend
|
- lifecycle-suspend
|
||||||
- lifecycle-deactivate
|
- lifecycle-deactivate
|
||||||
- lifecycle-destroy
|
- lifecycle-destroy
|
||||||
|
- session-revoke
|
||||||
|
|
||||||
# §13 proposed capabilities. Owner status is proposed, not assented, until
|
# §13 proposed capabilities. Owner status is proposed, not assented, until
|
||||||
# the surface exists in this repository's own contract.
|
# the surface exists in this repository's own contract.
|
||||||
|
|
@ -143,12 +145,15 @@ non_tooling_clients:
|
||||||
layer: tooling
|
layer: tooling
|
||||||
modules:
|
modules:
|
||||||
- src/secrets_engine/service_auth.py
|
- src/secrets_engine/service_auth.py
|
||||||
operation: "HTTPS client_credentials exchange; not connected to OpenBao"
|
- src/secrets_engine/engine_auth.py
|
||||||
|
operation: "HTTPS client_credentials exchange; OpenBao JWT login when the platform contract is present"
|
||||||
write: false
|
write: false
|
||||||
note: >-
|
note: >-
|
||||||
key-cape is catalogued Tooling. This contact is an identity-token
|
key-cape is catalogued Tooling. The consumer exchanges a service JWT and
|
||||||
preflight only and is deliberately not a write into OpenBao. Wiring it
|
logs in only when SECRETS_ENGINE_OPENBAO_JWT_LOGIN names a published
|
||||||
as OpenBao JWT login remains blocked on the platform-owned role.
|
mount/role contract. Failure never falls back to bootstrap or BAO_TOKEN.
|
||||||
|
The platform-owned role remains unpublished, so auto still uses the
|
||||||
|
named bootstrap/env providers.
|
||||||
|
|
||||||
- id: audit-core-outbox-drain
|
- id: audit-core-outbox-drain
|
||||||
target: audit-core
|
target: audit-core
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ Command surface (FR7):
|
||||||
catalog show <catalog-id>
|
catalog show <catalog-id>
|
||||||
decision inspect <decision-or-ccr-id>
|
decision inspect <decision-or-ccr-id>
|
||||||
plan <decision-or-ref> --stage <stage>
|
plan <decision-or-ref> --stage <stage>
|
||||||
apply <decision-or-ref> --stage <stage> [--dry-run] [--bootstrap-token-file F]
|
apply <decision-or-ref> --stage <stage> [--dry-run] [--auth auto] [--bootstrap-token-file F]
|
||||||
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
|
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
|
||||||
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
|
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
|
||||||
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
||||||
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
||||||
route <catalog-id> [--json]
|
route <catalog-id> [--json]
|
||||||
revoke <catalog-id>
|
revoke <catalog-id>
|
||||||
|
session revoke --accessor-file F [--stage stage]
|
||||||
lifecycle suspend|deactivate|destroy <catalog-id>
|
lifecycle suspend|deactivate|destroy <catalog-id>
|
||||||
audit <catalog-id> [--json]
|
audit <catalog-id> [--json]
|
||||||
evidence heartbeat|drain|classify
|
evidence heartbeat|drain|classify
|
||||||
|
|
@ -23,7 +24,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from secrets_engine import __version__
|
from secrets_engine import __version__
|
||||||
from secrets_engine.apply import apply_plan
|
from secrets_engine.apply import apply_plan
|
||||||
|
|
@ -36,8 +39,9 @@ from secrets_engine.config import Config, repo_root
|
||||||
from secrets_engine.decisions import require_approved, resolve_decision
|
from secrets_engine.decisions import require_approved, resolve_decision
|
||||||
from secrets_engine.errors import DecisionError, SecretsEngineError
|
from secrets_engine.errors import DecisionError, SecretsEngineError
|
||||||
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
|
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
|
||||||
|
from secrets_engine.engine_auth import login_service_jwt, select_engine_auth
|
||||||
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
|
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
|
||||||
from secrets_engine.openbao import OpenBaoClient
|
from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint, read_strict_token_file
|
||||||
from secrets_engine.plan import build_plan
|
from secrets_engine.plan import build_plan
|
||||||
from secrets_engine.provision import provision_from_file, provision_generated
|
from secrets_engine.provision import provision_from_file, provision_generated
|
||||||
from secrets_engine.routing import route_lane
|
from secrets_engine.routing import route_lane
|
||||||
|
|
@ -127,6 +131,37 @@ def _require_lane_approval(
|
||||||
return decision
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _open_backend(cfg: Config, args, evidence: PrivilegedActionEvidence | None = None):
|
||||||
|
"""Yield an OpenBao client for one named provider. JWT sessions self-revoke."""
|
||||||
|
selection = select_engine_auth(cfg, args)
|
||||||
|
if evidence is not None:
|
||||||
|
evidence.detail["auth_provider"] = selection.provider
|
||||||
|
if selection.break_glass:
|
||||||
|
evidence.detail["auth_break_glass"] = True
|
||||||
|
if selection.provider == "service-jwt":
|
||||||
|
session = login_service_jwt(cfg)
|
||||||
|
try:
|
||||||
|
if evidence is not None:
|
||||||
|
evidence.detail["auth_session_handle"] = session.accessor_fingerprint
|
||||||
|
yield session.client
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
session.close()
|
||||||
|
finally:
|
||||||
|
if evidence is not None:
|
||||||
|
evidence.detail["auth_revocation_attempted"] = (
|
||||||
|
session.revocation_attempted
|
||||||
|
)
|
||||||
|
evidence.detail["auth_revocation_succeeded"] = (
|
||||||
|
session.revocation_succeeded
|
||||||
|
)
|
||||||
|
return
|
||||||
|
yield OpenBaoClient.resolve(
|
||||||
|
cfg.bao_addr, bootstrap_token_file=selection.bootstrap_token_file
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -- command handlers ------------------------------------------------------
|
# -- command handlers ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -243,10 +278,8 @@ def cmd_apply(cfg: Config, args) -> int:
|
||||||
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 ""
|
||||||
)
|
)
|
||||||
client = OpenBaoClient.resolve(
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
result = apply_plan(client, entry, plan)
|
||||||
)
|
|
||||||
result = apply_plan(client, entry, plan)
|
|
||||||
print(result.render())
|
print(result.render())
|
||||||
evidence.finish(
|
evidence.finish(
|
||||||
"applied",
|
"applied",
|
||||||
|
|
@ -269,15 +302,13 @@ def cmd_provision(cfg: Config, args) -> int:
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry, "provision", evidence)
|
decision = _require_lane_approval(cfg, entry, "provision", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
if args.generate:
|
||||||
)
|
f = provision_generated(client, entry, field)
|
||||||
if args.generate:
|
mode = "generated"
|
||||||
f = provision_generated(client, entry, field)
|
else:
|
||||||
mode = "generated"
|
f = provision_from_file(client, entry, field, Path(args.from_file))
|
||||||
else:
|
mode = "from-file"
|
||||||
f = provision_from_file(client, entry, field, Path(args.from_file))
|
|
||||||
mode = "from-file"
|
|
||||||
print(
|
print(
|
||||||
f"provisioned lane '{entry.id}' field '{f}' ({mode}) — value not displayed"
|
f"provisioned lane '{entry.id}' field '{f}' ({mode}) — value not displayed"
|
||||||
)
|
)
|
||||||
|
|
@ -302,63 +333,58 @@ def cmd_verify(cfg: Config, args) -> int:
|
||||||
) as evidence:
|
) as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry, "verify", evidence)
|
decision = _require_lane_approval(cfg, entry, "verify", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
if entry.stores_kv_value() and not fields:
|
||||||
)
|
from secrets_engine.errors import VerificationError
|
||||||
if entry.stores_kv_value() and not fields:
|
|
||||||
from secrets_engine.errors import VerificationError
|
|
||||||
|
|
||||||
raise VerificationError(f"lane '{entry.id}' has no field to verify")
|
raise VerificationError(f"lane '{entry.id}' has no field to verify")
|
||||||
unrelated_token = None
|
unrelated_token = None
|
||||||
if entry.stores_kv_value() and negative and args.negative_token_file:
|
if entry.stores_kv_value() and negative and args.negative_token_file:
|
||||||
from secrets_engine.openbao import read_strict_token_file
|
unrelated_token = read_strict_token_file(
|
||||||
|
Path(args.negative_token_file),
|
||||||
unrelated_token = read_strict_token_file(
|
purpose="negative verification token",
|
||||||
Path(args.negative_token_file),
|
)
|
||||||
purpose="negative verification token",
|
if entry.stores_kv_value():
|
||||||
)
|
results = []
|
||||||
if entry.stores_kv_value():
|
if positive:
|
||||||
results = []
|
for field in fields:
|
||||||
if positive:
|
results.extend(
|
||||||
for field in fields:
|
run_verification(
|
||||||
|
client, entry, field, positive=True, negative=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if negative:
|
||||||
results.extend(
|
results.extend(
|
||||||
run_verification(
|
run_verification(
|
||||||
client, entry, field, positive=True, negative=False
|
client,
|
||||||
|
entry,
|
||||||
|
fields[0],
|
||||||
|
positive=False,
|
||||||
|
negative=True,
|
||||||
|
unrelated_token=unrelated_token,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if negative:
|
else:
|
||||||
# Denial is path-scoped; one real unrelated probe covers the path.
|
results = run_verification(
|
||||||
results.extend(
|
client, entry, "", positive=positive, negative=negative
|
||||||
run_verification(
|
|
||||||
client,
|
|
||||||
entry,
|
|
||||||
fields[0],
|
|
||||||
positive=False,
|
|
||||||
negative=True,
|
|
||||||
unrelated_token=unrelated_token,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
rc = 0
|
||||||
results = run_verification(
|
for result in results:
|
||||||
client, entry, "", positive=positive, negative=negative
|
print(result.render())
|
||||||
|
if not result.passed:
|
||||||
|
rc = 7
|
||||||
|
evidence.writer.record(
|
||||||
|
"verify-check",
|
||||||
|
result=f"{result.check}:{'pass' if result.passed else 'fail'}",
|
||||||
|
catalog_id=entry.id,
|
||||||
|
stage=entry.stage,
|
||||||
|
decision_id=evidence.decision_id,
|
||||||
|
detail=result.detail,
|
||||||
|
)
|
||||||
|
evidence.finish(
|
||||||
|
"pass" if rc == 0 else "verification-failed",
|
||||||
|
detail={"check_count": len(results)},
|
||||||
)
|
)
|
||||||
rc = 0
|
|
||||||
for result in results:
|
|
||||||
print(result.render())
|
|
||||||
if not result.passed:
|
|
||||||
rc = 7
|
|
||||||
evidence.writer.record(
|
|
||||||
"verify-check",
|
|
||||||
result=f"{result.check}:{'pass' if result.passed else 'fail'}",
|
|
||||||
catalog_id=entry.id,
|
|
||||||
stage=entry.stage,
|
|
||||||
decision_id=evidence.decision_id,
|
|
||||||
detail=result.detail,
|
|
||||||
)
|
|
||||||
evidence.finish(
|
|
||||||
"pass" if rc == 0 else "verification-failed",
|
|
||||||
detail={"check_count": len(results)},
|
|
||||||
)
|
|
||||||
return rc
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -378,15 +404,13 @@ def cmd_handoff(cfg: Config, args) -> int:
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry, "handoff", evidence)
|
decision = _require_lane_approval(cfg, entry, "handoff", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
result = write_approle_handoff(
|
||||||
)
|
client,
|
||||||
result = write_approle_handoff(
|
entry,
|
||||||
client,
|
role_id_file=Path(args.role_id_file),
|
||||||
entry,
|
secret_id_file=Path(args.secret_id_file),
|
||||||
role_id_file=Path(args.role_id_file),
|
)
|
||||||
secret_id_file=Path(args.secret_id_file),
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
f"wrote AppRole handoff material for lane '{entry.id}' — "
|
f"wrote AppRole handoff material for lane '{entry.id}' — "
|
||||||
"secret_id not displayed"
|
"secret_id not displayed"
|
||||||
|
|
@ -434,21 +458,45 @@ def cmd_exec(cfg: Config, args) -> int:
|
||||||
from secrets_engine.errors import DeliveryError
|
from secrets_engine.errors import DeliveryError
|
||||||
|
|
||||||
raise DeliveryError("no command after '--'")
|
raise DeliveryError("no command after '--'")
|
||||||
client = OpenBaoClient.resolve(
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
rc = exec_with_secret(
|
||||||
)
|
client,
|
||||||
rc = exec_with_secret(
|
entry,
|
||||||
client,
|
field,
|
||||||
entry,
|
args.command,
|
||||||
field,
|
mode=args.mode,
|
||||||
args.command,
|
session_evidence=session_detail,
|
||||||
mode=args.mode,
|
)
|
||||||
session_evidence=session_detail,
|
|
||||||
)
|
|
||||||
evidence.finish(f"exit-{rc}")
|
evidence.finish(f"exit-{rc}")
|
||||||
return rc
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_session_revoke(cfg: Config, args) -> int:
|
||||||
|
"""Revoke an already-issued token by accessor. Never print the accessor."""
|
||||||
|
stance_entry = SimpleNamespace(stage=args.stage, approval={"model": "bootstrap-only"})
|
||||||
|
evidence = PrivilegedActionEvidence(
|
||||||
|
writer=_writer(cfg),
|
||||||
|
action="session-revoke",
|
||||||
|
catalog_id="",
|
||||||
|
stage=args.stage,
|
||||||
|
approval_required=False,
|
||||||
|
)
|
||||||
|
with evidence:
|
||||||
|
stance = apply_unreachable_engine_stance(cfg, stance_entry, "session-revoke")
|
||||||
|
evidence.mark_stance(stance)
|
||||||
|
accessor = read_strict_token_file(
|
||||||
|
Path(args.accessor_file), purpose="token accessor"
|
||||||
|
)
|
||||||
|
fingerprint = accessor_fingerprint(accessor)
|
||||||
|
evidence.detail["session_handle"] = fingerprint
|
||||||
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
|
client.revoke_accessor(accessor)
|
||||||
|
del accessor
|
||||||
|
print(f"revoked session handle {fingerprint}")
|
||||||
|
evidence.finish("revoked", detail={"session_handle": fingerprint})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_policy_publication(cfg: Config, args) -> int:
|
def cmd_policy_publication(cfg: Config, args) -> int:
|
||||||
from secrets_engine.publication_policy import PublicationPolicy, resolve
|
from secrets_engine.publication_policy import PublicationPolicy, resolve
|
||||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||||
|
|
@ -509,10 +557,8 @@ def cmd_revoke(cfg: Config, args) -> int:
|
||||||
) as evidence:
|
) as evidence:
|
||||||
decision = _require_lane_approval(cfg, entry, "deactivate", evidence)
|
decision = _require_lane_approval(cfg, entry, "deactivate", evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
result = apply_lifecycle_plan(client, plan)
|
||||||
)
|
|
||||||
result = apply_lifecycle_plan(client, plan)
|
|
||||||
print(plan.render())
|
print(plan.render())
|
||||||
print(result.render())
|
print(result.render())
|
||||||
evidence.finish(
|
evidence.finish(
|
||||||
|
|
@ -557,10 +603,8 @@ def cmd_lifecycle(cfg: Config, args) -> int:
|
||||||
)
|
)
|
||||||
decision = _require_lane_approval(cfg, entry, args.operation, evidence)
|
decision = _require_lane_approval(cfg, entry, args.operation, evidence)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
client = OpenBaoClient.resolve(
|
with _open_backend(cfg, args, evidence) as client:
|
||||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
result = apply_lifecycle_plan(client, plan)
|
||||||
)
|
|
||||||
result = apply_lifecycle_plan(client, plan)
|
|
||||||
print(plan.render())
|
print(plan.render())
|
||||||
print(result.render())
|
print(result.render())
|
||||||
evidence.finish(
|
evidence.finish(
|
||||||
|
|
@ -657,7 +701,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
|
|
||||||
def add_token_arg(sp):
|
def add_token_arg(sp):
|
||||||
sp.add_argument("--bootstrap-token-file", default=None,
|
sp.add_argument("--bootstrap-token-file", default=None,
|
||||||
help="path to a mode-0600 OpenBao token file (bootstrap only)")
|
help="named break-glass OpenBao token file (mode 0600)")
|
||||||
|
sp.add_argument(
|
||||||
|
"--auth",
|
||||||
|
default="auto",
|
||||||
|
choices=("auto", "service-jwt", "bootstrap", "env"),
|
||||||
|
help="engine OpenBao auth provider; auto never falls back from service-jwt",
|
||||||
|
)
|
||||||
|
|
||||||
cat = sub.add_parser("catalog", help="catalog operations")
|
cat = sub.add_parser("catalog", help="catalog operations")
|
||||||
catsub = cat.add_subparsers(dest="subcmd", required=True)
|
catsub = cat.add_subparsers(dest="subcmd", required=True)
|
||||||
|
|
@ -744,6 +794,21 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
add_token_arg(rv)
|
add_token_arg(rv)
|
||||||
rv.set_defaults(func=cmd_revoke)
|
rv.set_defaults(func=cmd_revoke)
|
||||||
|
|
||||||
|
sess = sub.add_parser("session", help="issued-session operator commands")
|
||||||
|
sessub = sess.add_subparsers(dest="subcmd", required=True)
|
||||||
|
srev = sessub.add_parser(
|
||||||
|
"revoke",
|
||||||
|
help="revoke a token by a non-secret accessor the operator already holds",
|
||||||
|
)
|
||||||
|
srev.add_argument(
|
||||||
|
"--accessor-file",
|
||||||
|
required=True,
|
||||||
|
help="mode-0600 out-of-repo file containing the token accessor",
|
||||||
|
)
|
||||||
|
srev.add_argument("--stage", default="prod", choices=("build", "test", "prod"))
|
||||||
|
add_token_arg(srev)
|
||||||
|
srev.set_defaults(func=cmd_session_revoke)
|
||||||
|
|
||||||
lc = sub.add_parser("lifecycle", help="explicit lane lifecycle operations")
|
lc = sub.add_parser("lifecycle", help="explicit lane lifecycle operations")
|
||||||
lcsub = lc.add_subparsers(dest="operation", required=True)
|
lcsub = lc.add_subparsers(dest="operation", required=True)
|
||||||
for operation, help_text in (
|
for operation, help_text in (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Runtime configuration resolved from environment and repo layout.
|
"""Runtime configuration resolved from environment and repo layout.
|
||||||
|
|
||||||
Nothing here is a secret. Backend auth (BAO_TOKEN / bootstrap token files) is
|
Nothing here is a secret. Backend auth (named providers in engine_auth) is
|
||||||
resolved lazily inside the backend adapter, never cached on disk by this module.
|
resolved lazily inside the backend adapter, never cached on disk by this module.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -29,11 +29,17 @@ class Config:
|
||||||
topic_id: str
|
topic_id: str
|
||||||
approval_url: str = ""
|
approval_url: str = ""
|
||||||
approval_token_file: Path | None = None
|
approval_token_file: Path | None = None
|
||||||
|
keycape_token_url: str = ""
|
||||||
|
keycape_issuer: str = ""
|
||||||
|
keycape_client_secret_file: Path | None = None
|
||||||
|
openbao_jwt_login_file: Path | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls) -> "Config":
|
def load(cls) -> "Config":
|
||||||
root = repo_root()
|
root = repo_root()
|
||||||
token_file = os.environ.get("SECRETS_ENGINE_APPROVAL_TOKEN_FILE", "")
|
token_file = os.environ.get("SECRETS_ENGINE_APPROVAL_TOKEN_FILE", "")
|
||||||
|
keycape_secret = os.environ.get("SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", "")
|
||||||
|
jwt_login = os.environ.get("SECRETS_ENGINE_OPENBAO_JWT_LOGIN", "")
|
||||||
return cls(
|
return cls(
|
||||||
catalog_dir=Path(os.environ.get("SECRETS_ENGINE_CATALOG", root / "catalog")),
|
catalog_dir=Path(os.environ.get("SECRETS_ENGINE_CATALOG", root / "catalog")),
|
||||||
policy_dir=Path(os.environ.get("SECRETS_ENGINE_POLICIES", root / "policies")),
|
policy_dir=Path(os.environ.get("SECRETS_ENGINE_POLICIES", root / "policies")),
|
||||||
|
|
@ -45,4 +51,8 @@ class Config:
|
||||||
),
|
),
|
||||||
approval_url=os.environ.get("SECRETS_ENGINE_APPROVAL_URL", ""),
|
approval_url=os.environ.get("SECRETS_ENGINE_APPROVAL_URL", ""),
|
||||||
approval_token_file=Path(token_file) if token_file else None,
|
approval_token_file=Path(token_file) if token_file else None,
|
||||||
|
keycape_token_url=os.environ.get("SECRETS_ENGINE_KEYCAPE_TOKEN_URL", ""),
|
||||||
|
keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""),
|
||||||
|
keycape_client_secret_file=Path(keycape_secret) if keycape_secret else None,
|
||||||
|
openbao_jwt_login_file=Path(jwt_login) if jwt_login else None,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
139
src/secrets_engine/engine_auth.py
Normal file
139
src/secrets_engine/engine_auth.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""Named engine OpenBao authentication. No implicit fallback.
|
||||||
|
|
||||||
|
Steady-state is the reviewed KeyCape service identity plus a platform-owned
|
||||||
|
OpenBao JWT login. Bootstrap token files and ``BAO_TOKEN`` remain explicit
|
||||||
|
providers. A service-jwt failure never reads those providers.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from secrets_engine.errors import BackendError
|
||||||
|
from secrets_engine.openbao import OpenBaoClient, ScopedTokenSession
|
||||||
|
from secrets_engine.service_auth import KeyCapeServiceAuthConfig, KeyCapeServiceAuthProvider
|
||||||
|
|
||||||
|
_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||||
|
PROVIDERS = ("auto", "service-jwt", "bootstrap", "env")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class JwtLoginContract:
|
||||||
|
"""Non-secret OpenBao JWT login coordinates published by the platform owner."""
|
||||||
|
|
||||||
|
mount: str
|
||||||
|
role: str
|
||||||
|
bound_issuer: str
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthSelection:
|
||||||
|
provider: str
|
||||||
|
bootstrap_token_file: str | Path | None = None
|
||||||
|
break_glass: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_path(value: object) -> Path | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
return Path(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_login_contract_path(cfg: Any) -> Path | None:
|
||||||
|
return _optional_path(getattr(cfg, "openbao_jwt_login_file", None))
|
||||||
|
|
||||||
|
|
||||||
|
def load_jwt_login_contract(cfg: Any) -> JwtLoginContract:
|
||||||
|
path = jwt_login_contract_path(cfg)
|
||||||
|
if path is None:
|
||||||
|
raise BackendError(
|
||||||
|
"service-jwt requires SECRETS_ENGINE_OPENBAO_JWT_LOGIN; "
|
||||||
|
"the platform JWT mount/role contract is not published"
|
||||||
|
)
|
||||||
|
if not path.is_file():
|
||||||
|
raise BackendError(
|
||||||
|
"service-jwt OpenBao JWT login contract file is missing"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
raise BackendError("unable to load OpenBao JWT login contract") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise BackendError("OpenBao JWT login contract must be a mapping")
|
||||||
|
mount = str(data.get("mount") or "")
|
||||||
|
role = str(data.get("role") or "")
|
||||||
|
issuer = str(data.get("bound_issuer") or "")
|
||||||
|
if not _NAME_RE.fullmatch(mount) or not _NAME_RE.fullmatch(role):
|
||||||
|
raise BackendError("OpenBao JWT login mount/role is invalid")
|
||||||
|
if not issuer.startswith("https://"):
|
||||||
|
raise BackendError("OpenBao JWT login bound_issuer must use HTTPS")
|
||||||
|
return JwtLoginContract(mount=mount, role=role, bound_issuer=issuer, path=path)
|
||||||
|
|
||||||
|
|
||||||
|
def keycape_config(cfg: Any) -> KeyCapeServiceAuthConfig:
|
||||||
|
token_url = str(getattr(cfg, "keycape_token_url", "") or "")
|
||||||
|
issuer = str(getattr(cfg, "keycape_issuer", "") or "")
|
||||||
|
secret = _optional_path(getattr(cfg, "keycape_client_secret_file", None))
|
||||||
|
if not token_url or not issuer or secret is None:
|
||||||
|
raise BackendError(
|
||||||
|
"service-jwt requires KeyCape token URL, issuer, and client-secret file"
|
||||||
|
)
|
||||||
|
return KeyCapeServiceAuthConfig(
|
||||||
|
token_url=token_url,
|
||||||
|
issuer=issuer,
|
||||||
|
client_secret_file=secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_contract_configured(cfg: Any) -> bool:
|
||||||
|
path = jwt_login_contract_path(cfg)
|
||||||
|
return path is not None
|
||||||
|
|
||||||
|
|
||||||
|
def select_engine_auth(cfg: Any, args: Any) -> AuthSelection:
|
||||||
|
"""Choose exactly one provider. Never chain JWT failure into bootstrap/env."""
|
||||||
|
requested = str(getattr(args, "auth", "auto") or "auto")
|
||||||
|
if requested not in PROVIDERS:
|
||||||
|
raise BackendError(f"unknown engine auth provider '{requested}'")
|
||||||
|
bootstrap = getattr(args, "bootstrap_token_file", None)
|
||||||
|
jwt_intended = jwt_contract_configured(cfg)
|
||||||
|
|
||||||
|
if requested == "service-jwt" or (requested == "auto" and jwt_intended):
|
||||||
|
if bootstrap:
|
||||||
|
raise BackendError(
|
||||||
|
"service-jwt does not accept --bootstrap-token-file; no fallback"
|
||||||
|
)
|
||||||
|
return AuthSelection(provider="service-jwt")
|
||||||
|
if requested == "bootstrap" or bootstrap:
|
||||||
|
if requested == "env":
|
||||||
|
raise BackendError("env auth does not use --bootstrap-token-file")
|
||||||
|
if not bootstrap:
|
||||||
|
raise BackendError("bootstrap auth requires --bootstrap-token-file")
|
||||||
|
return AuthSelection(
|
||||||
|
provider="bootstrap",
|
||||||
|
bootstrap_token_file=bootstrap,
|
||||||
|
break_glass=True,
|
||||||
|
)
|
||||||
|
return AuthSelection(provider="env")
|
||||||
|
|
||||||
|
|
||||||
|
def login_service_jwt(cfg: Any) -> ScopedTokenSession:
|
||||||
|
"""Mint a short-lived OpenBao token from KeyCape. No parent token, no fallback."""
|
||||||
|
contract = load_jwt_login_contract(cfg)
|
||||||
|
kcfg = keycape_config(cfg)
|
||||||
|
if contract.bound_issuer != kcfg.issuer:
|
||||||
|
raise BackendError("OpenBao JWT login issuer does not match KeyCape issuer")
|
||||||
|
service_jwt = KeyCapeServiceAuthProvider(kcfg).exchange()
|
||||||
|
bao_bin = shutil.which("bao") or shutil.which("vault") or ""
|
||||||
|
anon = OpenBaoClient(addr=cfg.bao_addr, token="", bao_bin=bao_bin)
|
||||||
|
try:
|
||||||
|
return anon.login_jwt(contract.mount, contract.role, service_jwt.token)
|
||||||
|
finally:
|
||||||
|
# Drop the KeyCape JWT from this frame; OpenBao verifies the signature.
|
||||||
|
del service_jwt
|
||||||
|
|
@ -43,6 +43,7 @@ SHIPPED_RULES = (
|
||||||
"lifecycle-suspend",
|
"lifecycle-suspend",
|
||||||
"lifecycle-deactivate",
|
"lifecycle-deactivate",
|
||||||
"provision",
|
"provision",
|
||||||
|
"session-revoke",
|
||||||
),
|
),
|
||||||
"stages": ("prod",),
|
"stages": ("prod",),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@
|
||||||
Thin wrapper over the `bao` CLI. Isolated here so the rest of the engine speaks
|
Thin wrapper over the `bao` CLI. Isolated here so the rest of the engine speaks
|
||||||
in lanes/plans, not in OpenBao endpoint quirks (FR: "isolate backend adapter").
|
in lanes/plans, not in OpenBao endpoint quirks (FR: "isolate backend adapter").
|
||||||
|
|
||||||
Auth resolution order for the token:
|
Named engine auth providers (no implicit fallback) live in ``engine_auth``.
|
||||||
1. explicit bootstrap token file (--bootstrap-token-file), mode-checked;
|
``OpenBaoClient.resolve`` only materializes an already-selected bootstrap file
|
||||||
2. BAO_TOKEN / VAULT_TOKEN environment variable;
|
or environment token.
|
||||||
3. otherwise unauthenticated (only dry-run / read-health works).
|
|
||||||
|
|
||||||
This adapter NEVER returns a secret value to its callers except through the
|
This adapter NEVER returns a secret value to its callers except through the
|
||||||
narrow `read_field_present()` (boolean) and the exec-delivery path, which writes
|
narrow `read_field_present()` (boolean) and the exec-delivery path, which writes
|
||||||
|
|
@ -14,6 +13,7 @@ straight into a child process and never logs.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -56,6 +56,11 @@ def _check_token_file(path: Path) -> str:
|
||||||
return read_strict_token_file(path, purpose="bootstrap token")
|
return read_strict_token_file(path, purpose="bootstrap token")
|
||||||
|
|
||||||
|
|
||||||
|
def accessor_fingerprint(accessor: str) -> str:
|
||||||
|
"""Non-secret handle for an accessor the caller already holds."""
|
||||||
|
return hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ScopedTokenSession:
|
class ScopedTokenSession:
|
||||||
"""One AppRole login token that revokes itself on close."""
|
"""One AppRole login token that revokes itself on close."""
|
||||||
|
|
@ -266,9 +271,7 @@ class OpenBaoClient:
|
||||||
raise BackendError("malformed AppRole login response") from e
|
raise BackendError("malformed AppRole login response") from e
|
||||||
if not token:
|
if not token:
|
||||||
raise BackendError("AppRole login returned an empty token")
|
raise BackendError("AppRole login returned an empty token")
|
||||||
import hashlib
|
fingerprint = accessor_fingerprint(accessor)
|
||||||
|
|
||||||
fingerprint = hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12]
|
|
||||||
scoped = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
|
scoped = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
|
||||||
del token
|
del token
|
||||||
return ScopedTokenSession(
|
return ScopedTokenSession(
|
||||||
|
|
@ -307,6 +310,31 @@ class OpenBaoClient:
|
||||||
def delete_approle(self, role_name: str) -> None:
|
def delete_approle(self, role_name: str) -> None:
|
||||||
self._run_ok(["delete", f"auth/approle/role/{role_name}"])
|
self._run_ok(["delete", f"auth/approle/role/{role_name}"])
|
||||||
|
|
||||||
|
def login_jwt(self, mount: str, role: str, jwt: str) -> ScopedTokenSession:
|
||||||
|
"""Login through JWT auth without putting the JWT in argv."""
|
||||||
|
output = self._run_ok_with_json_file(
|
||||||
|
["write", "-format=json", f"auth/{mount}/login"],
|
||||||
|
{"role": role, "jwt": jwt},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
auth = json.loads(output)["auth"]
|
||||||
|
token = str(auth["client_token"])
|
||||||
|
accessor = str(auth.get("accessor", ""))
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||||
|
raise BackendError("malformed JWT login response") from e
|
||||||
|
if not token:
|
||||||
|
raise BackendError("JWT login returned an empty token")
|
||||||
|
fingerprint = accessor_fingerprint(accessor)
|
||||||
|
scoped = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
|
||||||
|
del token
|
||||||
|
return ScopedTokenSession(client=scoped, accessor_fingerprint=fingerprint)
|
||||||
|
|
||||||
|
def revoke_accessor(self, accessor: str) -> None:
|
||||||
|
"""Revoke a token the operator already holds, by accessor only."""
|
||||||
|
if not accessor or any(ch.isspace() for ch in accessor):
|
||||||
|
raise BackendError("token accessor is missing or invalid")
|
||||||
|
self._run_ok(["token", "revoke", "-accessor", accessor])
|
||||||
|
|
||||||
# -- KV v2 -------------------------------------------------------------
|
# -- KV v2 -------------------------------------------------------------
|
||||||
|
|
||||||
def kv_mount_exists(self, mount: str) -> bool:
|
def kv_mount_exists(self, mount: str) -> bool:
|
||||||
|
|
|
||||||
157
tests/test_engine_auth.py
Normal file
157
tests/test_engine_auth.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import copy
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from secrets_engine.catalog import validate_entry
|
||||||
|
from secrets_engine.config import Config
|
||||||
|
from secrets_engine.engine_auth import (
|
||||||
|
login_service_jwt,
|
||||||
|
select_engine_auth,
|
||||||
|
)
|
||||||
|
from secrets_engine.errors import BackendError
|
||||||
|
from secrets_engine.openbao import OpenBaoClient
|
||||||
|
from tests.test_catalog import VALID
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(tmp_path, **overrides):
|
||||||
|
values = dict(
|
||||||
|
catalog_dir=tmp_path,
|
||||||
|
policy_dir=tmp_path,
|
||||||
|
evidence_dir=tmp_path / "evidence",
|
||||||
|
hub_url="",
|
||||||
|
bao_addr="http://127.0.0.1:8200",
|
||||||
|
topic_id="test-topic",
|
||||||
|
)
|
||||||
|
values.update(overrides)
|
||||||
|
return Config(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_contract(tmp_path, issuer="https://keycape.example.test"):
|
||||||
|
path = tmp_path / "jwt-login.yaml"
|
||||||
|
path.write_text(
|
||||||
|
yaml.safe_dump({"mount": "jwt", "role": "secrets-engine", "bound_issuer": issuer}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_without_jwt_contract_keeps_bootstrap_and_env(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
env = select_engine_auth(cfg, SimpleNamespace(auth="auto", bootstrap_token_file=None))
|
||||||
|
assert env.provider == "env"
|
||||||
|
boot = select_engine_auth(
|
||||||
|
cfg, SimpleNamespace(auth="auto", bootstrap_token_file="/tmp/bootstrap.token")
|
||||||
|
)
|
||||||
|
assert boot.provider == "bootstrap"
|
||||||
|
assert boot.break_glass is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_jwt_refuses_bootstrap_file_and_does_not_read_env(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("BAO_TOKEN", "must-not-be-used")
|
||||||
|
cfg = _cfg(tmp_path, openbao_jwt_login_file=_jwt_contract(tmp_path))
|
||||||
|
with pytest.raises(BackendError, match="no fallback"):
|
||||||
|
select_engine_auth(
|
||||||
|
cfg,
|
||||||
|
SimpleNamespace(auth="auto", bootstrap_token_file="/tmp/bootstrap.token"),
|
||||||
|
)
|
||||||
|
with pytest.raises(BackendError, match="no fallback"):
|
||||||
|
select_engine_auth(
|
||||||
|
cfg,
|
||||||
|
SimpleNamespace(auth="service-jwt", bootstrap_token_file="/tmp/bootstrap.token"),
|
||||||
|
)
|
||||||
|
selected = select_engine_auth(
|
||||||
|
cfg, SimpleNamespace(auth="auto", bootstrap_token_file=None)
|
||||||
|
)
|
||||||
|
assert selected.provider == "service-jwt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_service_jwt_fails_closed_without_contract(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("BAO_TOKEN", "must-not-be-used")
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
selected = select_engine_auth(
|
||||||
|
cfg, SimpleNamespace(auth="service-jwt", bootstrap_token_file=None)
|
||||||
|
)
|
||||||
|
assert selected.provider == "service-jwt"
|
||||||
|
with pytest.raises(BackendError, match="JWT mount/role contract is not published"):
|
||||||
|
login_service_jwt(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_login_failure_does_not_fall_back_to_env(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("BAO_TOKEN", "must-not-be-used")
|
||||||
|
secret = tmp_path / "client.secret"
|
||||||
|
secret.write_text("client-secret-value", encoding="utf-8")
|
||||||
|
secret.chmod(0o600)
|
||||||
|
cfg = _cfg(
|
||||||
|
tmp_path,
|
||||||
|
openbao_jwt_login_file=_jwt_contract(tmp_path),
|
||||||
|
keycape_token_url="https://keycape.example.test/token",
|
||||||
|
keycape_issuer="https://keycape.example.test",
|
||||||
|
keycape_client_secret_file=secret,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"secrets_engine.engine_auth.KeyCapeServiceAuthProvider.exchange",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(BackendError("exchange failed")),
|
||||||
|
)
|
||||||
|
resolved = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
OpenBaoClient,
|
||||||
|
"resolve",
|
||||||
|
lambda *_args, **_kwargs: resolved.append("used") or pytest.fail("fallback"),
|
||||||
|
)
|
||||||
|
with pytest.raises(BackendError, match="exchange failed"):
|
||||||
|
login_service_jwt(cfg)
|
||||||
|
assert resolved == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_jwt_keeps_jwt_out_of_argv_and_revokes(monkeypatch):
|
||||||
|
client = OpenBaoClient(addr="http://example.invalid", token="", bao_bin="bao")
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_json_call(args, payload):
|
||||||
|
captured["args"] = list(args)
|
||||||
|
captured["payload"] = dict(payload)
|
||||||
|
return '{"auth":{"client_token":"jwt-child-token","accessor":"jwt-accessor"}}'
|
||||||
|
|
||||||
|
monkeypatch.setattr(client, "_run_ok_with_json_file", fake_json_call)
|
||||||
|
session = client.login_jwt("jwt", "secrets-engine", "header.payload.sig")
|
||||||
|
assert "header.payload.sig" not in " ".join(captured["args"])
|
||||||
|
assert captured["payload"]["jwt"] == "header.payload.sig"
|
||||||
|
assert "jwt-accessor" not in session.accessor_fingerprint
|
||||||
|
revoke = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session.client,
|
||||||
|
"_run_ok",
|
||||||
|
lambda args, **_kwargs: revoke.append(list(args)) or "",
|
||||||
|
)
|
||||||
|
session.close()
|
||||||
|
assert revoke == [["token", "revoke", "-self"]]
|
||||||
|
assert session.client.token == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_provision_jwt_auth_never_reaches_openbao_on_missing_contract(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
from secrets_engine import cli
|
||||||
|
|
||||||
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
|
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
|
||||||
|
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli.OpenBaoClient,
|
||||||
|
"resolve",
|
||||||
|
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
|
||||||
|
)
|
||||||
|
args = SimpleNamespace(
|
||||||
|
catalog_id=entry.id,
|
||||||
|
stage=entry.stage,
|
||||||
|
field="api_token",
|
||||||
|
generate=False,
|
||||||
|
from_file="/tmp/test-value-file",
|
||||||
|
bootstrap_token_file=None,
|
||||||
|
auth="service-jwt",
|
||||||
|
)
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
with pytest.raises(BackendError, match="JWT mount/role contract is not published"):
|
||||||
|
cli.cmd_provision(cfg, args)
|
||||||
|
|
@ -171,11 +171,13 @@ def test_classify_does_not_grant_permission():
|
||||||
destroy = classify("lifecycle-destroy", "build")
|
destroy = classify("lifecycle-destroy", "build")
|
||||||
apply_prod = classify("apply", "prod")
|
apply_prod = classify("apply", "prod")
|
||||||
heartbeat = classify("evidence-heartbeat", "prod")
|
heartbeat = classify("evidence-heartbeat", "prod")
|
||||||
|
session_revoke = classify("session-revoke", "prod")
|
||||||
assert prod_provision.kind == "load-bearing"
|
assert prod_provision.kind == "load-bearing"
|
||||||
assert test_provision.kind == "attributive"
|
assert test_provision.kind == "attributive"
|
||||||
assert destroy.kind == "load-bearing"
|
assert destroy.kind == "load-bearing"
|
||||||
assert apply_prod.kind == "attributive"
|
assert apply_prod.kind == "attributive"
|
||||||
assert heartbeat.kind == "heartbeat"
|
assert heartbeat.kind == "heartbeat"
|
||||||
|
assert session_revoke.kind == "load-bearing"
|
||||||
assert prod_provision.completeness_claimed is False
|
assert prod_provision.completeness_claimed is False
|
||||||
assert CLASSIFICATION.exists()
|
assert CLASSIFICATION.exists()
|
||||||
|
|
||||||
|
|
|
||||||
104
tests/test_session_revoke.py
Normal file
104
tests/test_session_revoke.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from secrets_engine import cli
|
||||||
|
from secrets_engine.config import Config
|
||||||
|
from secrets_engine.errors import BackendError, DecisionError
|
||||||
|
from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
ACCESSOR = "test-known-accessor-value"
|
||||||
|
|
||||||
|
|
||||||
|
def _config(tmp_path):
|
||||||
|
return Config(
|
||||||
|
catalog_dir=tmp_path,
|
||||||
|
policy_dir=tmp_path,
|
||||||
|
evidence_dir=tmp_path / "evidence",
|
||||||
|
hub_url="",
|
||||||
|
bao_addr="http://127.0.0.1:8200",
|
||||||
|
topic_id="test-topic",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _accessor_file(tmp_path, value=ACCESSOR, mode=0o600):
|
||||||
|
path = tmp_path / "accessor.handle"
|
||||||
|
path.write_text(value, encoding="utf-8")
|
||||||
|
path.chmod(mode)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_revoke_uses_fingerprint_only(tmp_path, monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def revoke_accessor(self, accessor):
|
||||||
|
seen["accessor"] = accessor
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli, "_open_backend", lambda *_args, **_kwargs: _ctx(_Client())
|
||||||
|
)
|
||||||
|
args = SimpleNamespace(
|
||||||
|
accessor_file=str(_accessor_file(tmp_path)),
|
||||||
|
stage="test",
|
||||||
|
bootstrap_token_file=None,
|
||||||
|
auth="auto",
|
||||||
|
)
|
||||||
|
rc = cli.cmd_session_revoke(_config(tmp_path), args)
|
||||||
|
assert rc == 0
|
||||||
|
assert seen["accessor"] == ACCESSOR
|
||||||
|
records = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in next((tmp_path / "evidence").glob("evidence-*.jsonl")).read_text().splitlines()
|
||||||
|
]
|
||||||
|
dumped = json.dumps(records)
|
||||||
|
assert ACCESSOR not in dumped
|
||||||
|
assert records[-1]["detail"]["session_handle"] == accessor_fingerprint(ACCESSOR)
|
||||||
|
assert records[-1]["result"] == "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class _ctx:
|
||||||
|
def __init__(self, client):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_revoke_production_fails_closed_before_backend(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli.OpenBaoClient,
|
||||||
|
"resolve",
|
||||||
|
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
|
||||||
|
)
|
||||||
|
args = SimpleNamespace(
|
||||||
|
accessor_file=str(_accessor_file(tmp_path)),
|
||||||
|
stage="prod",
|
||||||
|
bootstrap_token_file=None,
|
||||||
|
auth="auto",
|
||||||
|
)
|
||||||
|
cfg = Config(
|
||||||
|
catalog_dir=tmp_path,
|
||||||
|
policy_dir=tmp_path,
|
||||||
|
evidence_dir=tmp_path / "evidence",
|
||||||
|
hub_url="http://127.0.0.1:8000",
|
||||||
|
bao_addr="http://127.0.0.1:8200",
|
||||||
|
topic_id="test-topic",
|
||||||
|
)
|
||||||
|
with pytest.raises(DecisionError, match="production action 'session-revoke'"):
|
||||||
|
cli.cmd_session_revoke(cfg, args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_accessor_rejects_blank(monkeypatch):
|
||||||
|
client = OpenBaoClient(addr="http://example.invalid", token="t", bao_bin="bao")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
client, "_run_ok", lambda *_args, **_kwargs: pytest.fail("must not call bao")
|
||||||
|
)
|
||||||
|
with pytest.raises(BackendError, match="missing or invalid"):
|
||||||
|
client.revoke_accessor(" ")
|
||||||
|
|
@ -168,8 +168,8 @@ destroy stays fail-closed until T04's exact-action contract; that is the
|
||||||
designed containment, not unfinished T03 work. Issued delivery sessions
|
designed containment, not unfinished T03 work. Issued delivery sessions
|
||||||
self-revoke in T05.
|
self-revoke in T05.
|
||||||
|
|
||||||
Residual, not this task: a general known-accessor operator command. Tracked in
|
Residual from this task, now implemented: `secrets-engine session revoke
|
||||||
`docs/hardening-backlog.md` H4a.
|
--accessor-file` (hardening H4a). Lease-id revoke remains outstanding.
|
||||||
|
|
||||||
Progress 2026-08-23. Added one lifecycle plan model shared by dry-run and live
|
Progress 2026-08-23. Added one lifecycle plan model shared by dry-run and live
|
||||||
execution. Ordinary `revoke` safely aliases native deactivation; suspend and
|
execution. Ordinary `revoke` safely aliases native deactivation; suspend and
|
||||||
|
|
@ -177,8 +177,8 @@ deactivate preserve KV custody and externally managed workload delivery.
|
||||||
Destroy has an unmistakable dry-run plan and exact-id confirmation, but its live
|
Destroy has an unmistakable dry-run plan and exact-id confirmation, but its live
|
||||||
handler is deliberately fail-closed before coarse lane approval or backend
|
handler is deliberately fail-closed before coarse lane approval or backend
|
||||||
access until T04 supplies distinct action authorization. Scoped issued-session
|
access until T04 supplies distinct action authorization. Scoped issued-session
|
||||||
self-revocation is implemented in T05; a general known-accessor operator command
|
self-revocation is implemented in T05; a general known-accessor operator
|
||||||
remains outstanding.
|
command is `session revoke --accessor-file` (H4a).
|
||||||
|
|
||||||
Replace the overloaded `revoke` behavior with explicit lifecycle operations and
|
Replace the overloaded `revoke` behavior with explicit lifecycle operations and
|
||||||
plans. Define at least:
|
plans. Define at least:
|
||||||
|
|
|
||||||
|
|
@ -228,10 +228,17 @@ priority: medium
|
||||||
state_hub_task_id: "d7bc8bdc-a0f8-5058-a640-374ef9859148"
|
state_hub_task_id: "d7bc8bdc-a0f8-5058-a640-374ef9859148"
|
||||||
```
|
```
|
||||||
|
|
||||||
Blocked on the platform-owned OpenBao JWT mount/role (railiance-platform) and
|
Progress 2026-09-02. Named engine auth is shipped: `service-jwt`, `bootstrap`,
|
||||||
on `SECRETS-WP-0007-T05` wiring. Companion §7 / statute §3.4: an agent holds
|
and `env` are exclusive providers. `--auth service-jwt` exchanges the KeyCape
|
||||||
no long-lived credential of its own. Authority is per task, time-bounded, and
|
service JWT and logs in through `OpenBaoClient.login_jwt` (JSON file, never
|
||||||
attributable to the principal it acts for.
|
argv), then self-revokes. A JWT selection refuses `--bootstrap-token-file` and
|
||||||
|
does not read `BAO_TOKEN` on failure. `--bootstrap-token-file` is a named
|
||||||
|
break-glass provider with `auth_break_glass` evidence.
|
||||||
|
|
||||||
|
The platform-owned OpenBao JWT mount/role is still unpublished, so auto keeps
|
||||||
|
bootstrap/env and this task remains `wait`. Companion §7 / statute §3.4: an
|
||||||
|
agent holds no long-lived credential of its own. Authority is per task,
|
||||||
|
time-bounded, and attributable to the principal it acts for.
|
||||||
|
|
||||||
Bootstrap token files remain break-glass, heavily evidenced, never an implicit
|
Bootstrap token files remain break-glass, heavily evidenced, never an implicit
|
||||||
fallback from service identity.
|
fallback from service identity.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue