From 3abee434df1a5e4edc9427726d9aeb48b5d5a620 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 2 Sep 2026 01:24:08 +0200 Subject: [PATCH] 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 --- README.md | 1 + SCOPE.md | 18 +- docs/cli.md | 7 +- docs/hardening-backlog.md | 19 +- docs/openbao-jwt-login.md | 23 ++ docs/service-auth.md | 17 +- evidence-classification.yaml | 2 +- layer.yaml | 13 +- src/secrets_engine/cli.py | 253 +++++++++++------- src/secrets_engine/config.py | 12 +- src/secrets_engine/engine_auth.py | 139 ++++++++++ src/secrets_engine/evidence_class.py | 1 + src/secrets_engine/openbao.py | 42 ++- tests/test_engine_auth.py | 157 +++++++++++ tests/test_layer_conformance.py | 2 + tests/test_session_revoke.py | 104 +++++++ ...-WP-0007-production-lifecycle-hardening.md | 8 +- ...-0008-layer-model-lifecycle-conformance.md | 15 +- 18 files changed, 698 insertions(+), 135 deletions(-) create mode 100644 docs/openbao-jwt-login.md create mode 100644 src/secrets_engine/engine_auth.py create mode 100644 tests/test_engine_auth.py create mode 100644 tests/test_session_revoke.py diff --git a/README.md b/README.md index 7135dda..ee158ac 100644 --- a/README.md +++ b/README.md @@ -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) - 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) +- 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 reached only through the `bao` CLI adapter (`openbao.py`); the rest of the code diff --git a/SCOPE.md b/SCOPE.md index fd4df4b..2cadd77 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -152,9 +152,13 @@ do not replace exact-action authorization or OpenBao audit logs. 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. +The CLI now has a named `service-jwt` provider that logs in through that +scaffold when the platform JWT mount/role contract is present, then +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 high-risk lanes. @@ -173,9 +177,11 @@ high-risk lanes. catalog-id confirmation; it will remain closed until the canonical exact-action approval contract in `SECRETS-WP-0007-T04` is enforced. -These operations do not manage external workload delivery. There is currently -no general lease/accessor operator command, rotation command, compromised state, -or persistent/reversible lane state machine. +These operations do not manage external workload delivery. `session revoke +--accessor-file` revokes an already-issued token by a non-secret accessor the +operator already holds; evidence is fingerprint-only. There is currently no +rotation command, compromised state, or persistent/reversible lane state +machine. ## CLI Surface diff --git a/docs/cli.md b/docs/cli.md index 138755c..e626981 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -34,11 +34,15 @@ secrets-engine --version | Var | Default | Purpose | | --- | --- | --- | | `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_CATALOG` | `./catalog` | catalog directory | | `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_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 @@ -55,6 +59,7 @@ secrets-engine exec --catalog [--field NAME] [--mode auto|npm-confi secrets-engine policy publication secrets-engine route [--json] secrets-engine revoke [--dry-run] +secrets-engine session revoke --accessor-file F [--stage stage] secrets-engine lifecycle suspend [--dry-run] secrets-engine lifecycle deactivate [--dry-run] secrets-engine lifecycle destroy [--dry-run] [--confirm-destroy ] diff --git a/docs/hardening-backlog.md b/docs/hardening-backlog.md index c7c9690..5553042 100644 --- a/docs/hardening-backlog.md +++ b/docs/hardening-backlog.md @@ -34,11 +34,15 @@ contents in this repo. - Implemented consumer scaffold: exact KeyCape client-credentials exchange, claim/lifetime preflight, strict client-secret file input, renewal boundary, 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 - mount/role and publishes its non-secret connection contract. -- 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 - break-glass, heavily audited). + mount/role and publishes its non-secret connection contract + (`SECRETS_ENGINE_OPENBAO_JWT_LOGIN`). Until then `--auth service-jwt` + fail-closes and `--auth auto` keeps named bootstrap/env providers. +- `--bootstrap-token-file` is a named break-glass provider with distinct + evidence, not an implicit fallback. ## H2 — Response-wrapped handoff @@ -67,9 +71,10 @@ contents in this repo. ## H4a — Known-accessor operator command - Delivery sessions already self-revoke in `finally` (`SECRETS-WP-0007-T05`). -- Residual from T03: no general CLI to revoke an already-issued token or - lease by a non-secret accessor the operator already holds. -- Do not print accessors. Prefer fingerprint-only evidence. +- Implemented: `secrets-engine session revoke --accessor-file F` reads a + mode-0600 out-of-repo accessor, calls `token revoke -accessor`, and records + only a fingerprint. Production remains fail-closed. Lease-id revoke is still + outstanding. ## H5 — Audit report command diff --git a/docs/openbao-jwt-login.md b/docs/openbao-jwt-login.md new file mode 100644 index 0000000..7b841d4 --- /dev/null +++ b/docs/openbao-jwt-login.md @@ -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:// +``` + +`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`. diff --git a/docs/service-auth.md b/docs/service-auth.md index 1ca6a39..06208c1 100644 --- a/docs/service-auth.md +++ b/docs/service-auth.md @@ -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 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. +JWT payload parsing does not prove a signature. OpenBao must verify RS256 +against the configured issuer before issuing a token. railiance-platform owns +the exact OpenBao JWT auth mount/role, issuer keys, claim bindings, token +policy, TTL/use limits, and cryptographic verification. + +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: `key-cape/docs/openbao-service-auth-contract.md` (reviewed 2026-08-23). diff --git a/evidence-classification.yaml b/evidence-classification.yaml index 22ed468..6935517 100644 --- a/evidence-classification.yaml +++ b/evidence-classification.yaml @@ -45,7 +45,7 @@ rules: - id: production-control-mutation kind: load-bearing - actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision] + actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision, session-revoke] stages: [prod] emission: local-outbox note: >- diff --git a/layer.yaml b/layer.yaml index 9b39e48..e42d8f3 100644 --- a/layer.yaml +++ b/layer.yaml @@ -33,6 +33,7 @@ owned_tooling: operations: - "bao policy/auth/kv subprocess adapter" - "CAS-aware KV create/patch via JSON input files, never argv values" + - "JWT login via JSON input files; issued engine tokens self-revoke" note: >- This is the owned Lifecycle contact, not a Staff §5 shape. A new direct OpenBao client outside the listed modules is a finding. @@ -56,6 +57,7 @@ protected_actions: - lifecycle-suspend - lifecycle-deactivate - lifecycle-destroy + - session-revoke # §13 proposed capabilities. Owner status is proposed, not assented, until # the surface exists in this repository's own contract. @@ -143,12 +145,15 @@ non_tooling_clients: layer: tooling modules: - 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 note: >- - key-cape is catalogued Tooling. This contact is an identity-token - preflight only and is deliberately not a write into OpenBao. Wiring it - as OpenBao JWT login remains blocked on the platform-owned role. + key-cape is catalogued Tooling. The consumer exchanges a service JWT and + logs in only when SECRETS_ENGINE_OPENBAO_JWT_LOGIN names a published + 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 target: audit-core diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index bdc796a..0aaedc7 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -5,13 +5,14 @@ Command surface (FR7): catalog show decision inspect plan --stage - apply --stage [--dry-run] [--bootstrap-token-file F] + apply --stage [--dry-run] [--auth auto] [--bootstrap-token-file F] provision --stage (--from-file F | --generate) --field NAME verify [--positive] [--negative] [--field NAME] [--negative-token-file F] handoff --stage --role-id-file F --secret-id-file F exec --catalog [--field NAME] [--mode auto|npm-config|exec-env] -- CMD... route [--json] revoke + session revoke --accessor-file F [--stage stage] lifecycle suspend|deactivate|destroy audit [--json] evidence heartbeat|drain|classify @@ -23,7 +24,9 @@ from __future__ import annotations import argparse import sys +from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace from secrets_engine import __version__ 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.errors import DecisionError, SecretsEngineError 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.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.provision import provision_from_file, provision_generated from secrets_engine.routing import route_lane @@ -127,6 +131,37 @@ def _require_lane_approval( 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 ------------------------------------------------------ @@ -243,10 +278,8 @@ def cmd_apply(cfg: Config, args) -> int: plan = build_plan( entry, args.stage, decision_id=decision.id if decision else "" ) - client = OpenBaoClient.resolve( - cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file - ) - result = apply_plan(client, entry, plan) + with _open_backend(cfg, args, evidence) as client: + result = apply_plan(client, entry, plan) print(result.render()) evidence.finish( "applied", @@ -269,15 +302,13 @@ def cmd_provision(cfg: Config, args) -> int: ) decision = _require_lane_approval(cfg, entry, "provision", evidence) evidence.mark_approved(decision) - client = OpenBaoClient.resolve( - cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file - ) - if args.generate: - f = provision_generated(client, entry, field) - mode = "generated" - else: - f = provision_from_file(client, entry, field, Path(args.from_file)) - mode = "from-file" + with _open_backend(cfg, args, evidence) as client: + if args.generate: + f = provision_generated(client, entry, field) + mode = "generated" + else: + f = provision_from_file(client, entry, field, Path(args.from_file)) + mode = "from-file" print( f"provisioned lane '{entry.id}' field '{f}' ({mode}) — value not displayed" ) @@ -302,63 +333,58 @@ def cmd_verify(cfg: Config, args) -> int: ) as evidence: decision = _require_lane_approval(cfg, entry, "verify", evidence) evidence.mark_approved(decision) - client = OpenBaoClient.resolve( - cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file - ) - if entry.stores_kv_value() and not fields: - from secrets_engine.errors import VerificationError + with _open_backend(cfg, args, evidence) as client: + 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") - unrelated_token = None - 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), - purpose="negative verification token", - ) - if entry.stores_kv_value(): - results = [] - if positive: - for field in fields: + raise VerificationError(f"lane '{entry.id}' has no field to verify") + unrelated_token = None + if entry.stores_kv_value() and negative and args.negative_token_file: + unrelated_token = read_strict_token_file( + Path(args.negative_token_file), + purpose="negative verification token", + ) + if entry.stores_kv_value(): + results = [] + if positive: + for field in fields: + results.extend( + run_verification( + client, entry, field, positive=True, negative=False + ) + ) + if negative: results.extend( run_verification( - client, entry, field, positive=True, negative=False + client, + entry, + fields[0], + positive=False, + negative=True, + unrelated_token=unrelated_token, ) ) - if negative: - # Denial is path-scoped; one real unrelated probe covers the path. - results.extend( - run_verification( - client, - entry, - fields[0], - positive=False, - negative=True, - unrelated_token=unrelated_token, - ) + else: + results = run_verification( + client, entry, "", positive=positive, negative=negative ) - else: - results = run_verification( - client, entry, "", positive=positive, negative=negative + 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)}, ) - 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 @@ -378,15 +404,13 @@ def cmd_handoff(cfg: Config, args) -> int: ) decision = _require_lane_approval(cfg, entry, "handoff", evidence) evidence.mark_approved(decision) - client = OpenBaoClient.resolve( - cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file - ) - result = write_approle_handoff( - client, - entry, - role_id_file=Path(args.role_id_file), - secret_id_file=Path(args.secret_id_file), - ) + with _open_backend(cfg, args, evidence) as client: + result = write_approle_handoff( + client, + entry, + role_id_file=Path(args.role_id_file), + secret_id_file=Path(args.secret_id_file), + ) print( f"wrote AppRole handoff material for lane '{entry.id}' — " "secret_id not displayed" @@ -434,21 +458,45 @@ def cmd_exec(cfg: Config, args) -> int: from secrets_engine.errors import DeliveryError raise DeliveryError("no command after '--'") - client = OpenBaoClient.resolve( - cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file - ) - rc = exec_with_secret( - client, - entry, - field, - args.command, - mode=args.mode, - session_evidence=session_detail, - ) + with _open_backend(cfg, args, evidence) as client: + rc = exec_with_secret( + client, + entry, + field, + args.command, + mode=args.mode, + session_evidence=session_detail, + ) evidence.finish(f"exit-{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: from secrets_engine.publication_policy import PublicationPolicy, resolve entry = get_entry(cfg.catalog_dir, args.catalog_id) @@ -509,10 +557,8 @@ def cmd_revoke(cfg: Config, args) -> int: ) as evidence: decision = _require_lane_approval(cfg, entry, "deactivate", evidence) evidence.mark_approved(decision) - client = OpenBaoClient.resolve( - cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file - ) - result = apply_lifecycle_plan(client, plan) + with _open_backend(cfg, args, evidence) as client: + result = apply_lifecycle_plan(client, plan) print(plan.render()) print(result.render()) evidence.finish( @@ -557,10 +603,8 @@ def cmd_lifecycle(cfg: Config, args) -> int: ) decision = _require_lane_approval(cfg, entry, args.operation, evidence) evidence.mark_approved(decision) - client = OpenBaoClient.resolve( - cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file - ) - result = apply_lifecycle_plan(client, plan) + with _open_backend(cfg, args, evidence) as client: + result = apply_lifecycle_plan(client, plan) print(plan.render()) print(result.render()) evidence.finish( @@ -657,7 +701,13 @@ def build_parser() -> argparse.ArgumentParser: def add_token_arg(sp): 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") catsub = cat.add_subparsers(dest="subcmd", required=True) @@ -744,6 +794,21 @@ def build_parser() -> argparse.ArgumentParser: add_token_arg(rv) 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") lcsub = lc.add_subparsers(dest="operation", required=True) for operation, help_text in ( diff --git a/src/secrets_engine/config.py b/src/secrets_engine/config.py index ababa5a..f73b611 100644 --- a/src/secrets_engine/config.py +++ b/src/secrets_engine/config.py @@ -1,6 +1,6 @@ """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. """ from __future__ import annotations @@ -29,11 +29,17 @@ class Config: topic_id: str approval_url: str = "" 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 def load(cls) -> "Config": root = repo_root() 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( catalog_dir=Path(os.environ.get("SECRETS_ENGINE_CATALOG", root / "catalog")), 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_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, ) diff --git a/src/secrets_engine/engine_auth.py b/src/secrets_engine/engine_auth.py new file mode 100644 index 0000000..77cc034 --- /dev/null +++ b/src/secrets_engine/engine_auth.py @@ -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 diff --git a/src/secrets_engine/evidence_class.py b/src/secrets_engine/evidence_class.py index 45aafee..50c17b9 100644 --- a/src/secrets_engine/evidence_class.py +++ b/src/secrets_engine/evidence_class.py @@ -43,6 +43,7 @@ SHIPPED_RULES = ( "lifecycle-suspend", "lifecycle-deactivate", "provision", + "session-revoke", ), "stages": ("prod",), }, diff --git a/src/secrets_engine/openbao.py b/src/secrets_engine/openbao.py index 0c43414..a883168 100644 --- a/src/secrets_engine/openbao.py +++ b/src/secrets_engine/openbao.py @@ -3,10 +3,9 @@ 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"). -Auth resolution order for the token: - 1. explicit bootstrap token file (--bootstrap-token-file), mode-checked; - 2. BAO_TOKEN / VAULT_TOKEN environment variable; - 3. otherwise unauthenticated (only dry-run / read-health works). +Named engine auth providers (no implicit fallback) live in ``engine_auth``. +``OpenBaoClient.resolve`` only materializes an already-selected bootstrap file +or environment token. 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 @@ -14,6 +13,7 @@ straight into a child process and never logs. """ from __future__ import annotations +import hashlib import json import os import shutil @@ -56,6 +56,11 @@ def _check_token_file(path: Path) -> str: 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 class ScopedTokenSession: """One AppRole login token that revokes itself on close.""" @@ -266,9 +271,7 @@ class OpenBaoClient: raise BackendError("malformed AppRole login response") from e if not token: raise BackendError("AppRole login returned an empty token") - import hashlib - - fingerprint = hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12] + fingerprint = accessor_fingerprint(accessor) scoped = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin) del token return ScopedTokenSession( @@ -307,6 +310,31 @@ class OpenBaoClient: def delete_approle(self, role_name: str) -> None: 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 ------------------------------------------------------------- def kv_mount_exists(self, mount: str) -> bool: diff --git a/tests/test_engine_auth.py b/tests/test_engine_auth.py new file mode 100644 index 0000000..a59977d --- /dev/null +++ b/tests/test_engine_auth.py @@ -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) diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py index 7930de9..ba56a62 100644 --- a/tests/test_layer_conformance.py +++ b/tests/test_layer_conformance.py @@ -171,11 +171,13 @@ def test_classify_does_not_grant_permission(): destroy = classify("lifecycle-destroy", "build") apply_prod = classify("apply", "prod") heartbeat = classify("evidence-heartbeat", "prod") + session_revoke = classify("session-revoke", "prod") assert prod_provision.kind == "load-bearing" assert test_provision.kind == "attributive" assert destroy.kind == "load-bearing" assert apply_prod.kind == "attributive" assert heartbeat.kind == "heartbeat" + assert session_revoke.kind == "load-bearing" assert prod_provision.completeness_claimed is False assert CLASSIFICATION.exists() diff --git a/tests/test_session_revoke.py b/tests/test_session_revoke.py new file mode 100644 index 0000000..3915424 --- /dev/null +++ b/tests/test_session_revoke.py @@ -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(" ") diff --git a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md index cc16ecd..3ead921 100644 --- a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md +++ b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md @@ -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 self-revoke in T05. -Residual, not this task: a general known-accessor operator command. Tracked in -`docs/hardening-backlog.md` H4a. +Residual from this task, now implemented: `secrets-engine session revoke +--accessor-file` (hardening H4a). Lease-id revoke remains outstanding. Progress 2026-08-23. Added one lifecycle plan model shared by dry-run and live 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 handler is deliberately fail-closed before coarse lane approval or backend access until T04 supplies distinct action authorization. Scoped issued-session -self-revocation is implemented in T05; a general known-accessor operator command -remains outstanding. +self-revocation is implemented in T05; a general known-accessor operator +command is `session revoke --accessor-file` (H4a). Replace the overloaded `revoke` behavior with explicit lifecycle operations and plans. Define at least: diff --git a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md index 0fcc64d..a48c26a 100644 --- a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md +++ b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md @@ -228,10 +228,17 @@ priority: medium state_hub_task_id: "d7bc8bdc-a0f8-5058-a640-374ef9859148" ``` -Blocked on the platform-owned OpenBao JWT mount/role (railiance-platform) and -on `SECRETS-WP-0007-T05` wiring. 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. +Progress 2026-09-02. Named engine auth is shipped: `service-jwt`, `bootstrap`, +and `env` are exclusive providers. `--auth service-jwt` exchanges the KeyCape +service JWT and logs in through `OpenBaoClient.login_jwt` (JSON file, never +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 fallback from service identity.