Add named engine auth and accessor-file session revoke
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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:
tegwick 2026-09-02 01:24:08 +02:00
parent a94003de4f
commit 3abee434df
18 changed files with 698 additions and 135 deletions

View file

@ -5,13 +5,14 @@ Command surface (FR7):
catalog show <catalog-id>
decision inspect <decision-or-ccr-id>
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
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-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...
route <catalog-id> [--json]
revoke <catalog-id>
session revoke --accessor-file F [--stage stage]
lifecycle suspend|deactivate|destroy <catalog-id>
audit <catalog-id> [--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 (