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
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
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-deactivate",
|
||||
"provision",
|
||||
"session-revoke",
|
||||
),
|
||||
"stages": ("prod",),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue