Harden secret provisioning and lifecycle controls
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
This commit is contained in:
tegwick 2026-08-23 12:05:58 +02:00
parent 0617923ff1
commit 3a1bd4f1c8
23 changed files with 1369 additions and 162 deletions

View file

@ -12,6 +12,7 @@ Command surface (FR7):
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
route <catalog-id> [--json]
revoke <catalog-id>
lifecycle suspend|deactivate|destroy <catalog-id>
Every privileged action is decision-gated and writes non-secret evidence.
`plan` and `apply --dry-run` never mutate OpenBao.
@ -214,13 +215,32 @@ def cmd_verify(cfg: Config, args) -> int:
entry = get_entry(cfg.catalog_dir, args.catalog_id)
decision = _require_lane_approval(cfg, entry)
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
field = args.field or (entry.fields[0] if entry.fields else "")
if entry.stores_kv_value() and not field:
fields = [args.field] if args.field else list(entry.fields)
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")
positive = args.positive or not args.negative
negative = args.negative or not args.positive
results = run_verification(client, entry, field, positive=positive, negative=negative)
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:
# Denial is path-scoped, so one probe covers every field on this path.
results.extend(
run_verification(
client, entry, fields[0], positive=False, negative=True
)
)
else:
results = run_verification(
client, entry, "", positive=positive, negative=negative
)
rc = 0
for r in results:
print(r.render())
@ -287,10 +307,33 @@ def cmd_exec(cfg: Config, args) -> int:
w.record("exec", result="attempt", catalog_id=entry.id, stage=entry.stage,
decision_id=decision.id if decision else "",
detail={"command": args.command[0], "mode": args.mode})
rc = exec_with_secret(client, entry, field, args.command, mode=args.mode)
session_detail: dict[str, object] = {}
try:
rc = exec_with_secret(
client,
entry,
field,
args.command,
mode=args.mode,
session_evidence=session_detail,
)
except SecretsEngineError as e:
w.record(
"exec",
result=f"failed-{type(e).__name__}",
catalog_id=entry.id,
stage=entry.stage,
decision_id=decision.id if decision else "",
detail={
"command": args.command[0],
"mode": args.mode,
"session": session_detail,
},
)
raise
w.record("exec", result=f"exit-{rc}", catalog_id=entry.id, stage=entry.stage,
decision_id=decision.id if decision else "",
detail={"command": args.command[0]})
detail={"command": args.command[0], "session": session_detail})
return rc
@ -338,36 +381,81 @@ def cmd_route(cfg: Config, args) -> int:
def cmd_revoke(cfg: Config, args) -> int:
from secrets_engine.lifecycle import (
apply_lifecycle_plan,
build_native_deactivation_plan,
)
entry = get_entry(cfg.catalog_dir, args.catalog_id)
decision = None if args.dry_run else _require_lane_approval(cfg, entry)
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
if entry.kind == "auth-capability":
if args.dry_run:
print(
f"(dry-run) would delete approle {entry.role_name} "
f"and policy {entry.policy_name}"
)
return 0
client.delete_approle(entry.role_name)
client.delete_policy(entry.policy_name)
print(
f"revoked lane '{entry.id}': deleted approle {entry.role_name} "
f"and policy {entry.policy_name}"
)
_writer(cfg).record(
"revoke", result="auth-capability-deactivated", catalog_id=entry.id,
stage=entry.stage, decision_id=decision.id if decision else ""
)
return 0
plan = build_native_deactivation_plan(entry)
if args.dry_run:
print(f"(dry-run) would delete KV metadata {entry.mount}/{entry.path} "
f"and approle {entry.role_name}")
print(plan.render())
print("\n(dry-run: no OpenBao mutation performed)")
return 0
client.kv_delete_metadata(entry.mount, entry.path)
print(f"revoked lane '{entry.id}': KV metadata deleted at {entry.mount}/{entry.path}")
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
result = apply_lifecycle_plan(client, plan)
print(plan.render())
print(result.render())
_writer(cfg).record(
"revoke", result="deactivated", catalog_id=entry.id, stage=entry.stage,
decision_id=decision.id if decision else ""
"revoke",
result="native-access-deactivated",
catalog_id=entry.id,
stage=entry.stage,
decision_id=decision.id if decision else "",
detail={
"operation": plan.operation,
"applied": list(result.applied),
"preserved": list(result.preserved),
},
)
return 0
def cmd_lifecycle(cfg: Config, args) -> int:
from secrets_engine.lifecycle import (
apply_lifecycle_plan,
build_lifecycle_plan,
require_destroy_confirmation,
)
entry = get_entry(cfg.catalog_dir, args.catalog_id)
if args.operation == "destroy" and not args.dry_run:
require_destroy_confirmation(entry, args.confirm_destroy)
# A lane-level approval is not an authorization to erase custody. Keep
# the destructive live path closed until T04 supplies a canonical,
# exact-action, expiring and dual-control-capable decision contract.
from secrets_engine.errors import PolicyGuardError
raise PolicyGuardError(
"live destroy is disabled until an exact-action destruction "
"approval contract is available; use --dry-run to inspect targets"
)
decision = None if args.dry_run else _require_lane_approval(cfg, entry)
plan = build_lifecycle_plan(entry, args.operation)
if args.dry_run:
print(plan.render())
print("\n(dry-run: no OpenBao mutation performed)")
return 0
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
result = apply_lifecycle_plan(client, plan)
print(plan.render())
print(result.render())
_writer(cfg).record(
f"lifecycle-{args.operation}",
result="applied",
catalog_id=entry.id,
stage=entry.stage,
decision_id=decision.id if decision else "",
detail={
"operation": plan.operation,
"applied": list(result.applied),
"preserved": list(result.preserved),
},
)
return 0
@ -455,12 +543,34 @@ def build_parser() -> argparse.ArgumentParser:
ro.add_argument("--json", action="store_true")
ro.set_defaults(func=cmd_route)
rv = sub.add_parser("revoke", help="deactivate a lane (delete KV metadata)")
rv = sub.add_parser(
"revoke",
help="deactivate native policy/AppRole access (preserves KV custody)",
)
rv.add_argument("catalog_id")
rv.add_argument("--dry-run", action="store_true")
add_token_arg(rv)
rv.set_defaults(func=cmd_revoke)
lc = sub.add_parser("lifecycle", help="explicit lane lifecycle operations")
lcsub = lc.add_subparsers(dest="operation", required=True)
for operation, help_text in (
("suspend", "stop new native logins; preserve policy and KV custody"),
("deactivate", "remove native AppRole/policy; preserve KV custody"),
("destroy", "irreversibly delete KV metadata after deactivation"),
):
lp = lcsub.add_parser(operation, help=help_text)
lp.add_argument("catalog_id")
lp.add_argument("--dry-run", action="store_true")
if operation == "destroy":
lp.add_argument(
"--confirm-destroy",
default="",
help="exact catalog id required for live irreversible deletion",
)
add_token_arg(lp)
lp.set_defaults(func=cmd_lifecycle)
return p

View file

@ -50,27 +50,45 @@ def resolve_npm_token_env(entry: CatalogEntry, *, policy_dir=None) -> str:
return res.token_env
def _fetch_value(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str:
def _fetch_value(
client: OpenBaoClient,
entry: CatalogEntry,
field: str,
*,
session_evidence: dict[str, object] | None = None,
) -> str:
"""Read the field value via an approle-scoped token. Held in memory only."""
if entry.delivery_auth_method != "approle" or not entry.has_delivery_auth:
raise DeliveryError(
f"lane '{entry.id}' has no AppRole delivery auth for native exec"
)
session = None
try:
token = client.approle_login_token(entry.role_name)
with client.approle_session(entry.role_name) as session:
proc = session.client._run(
["kv", "get", "-format=json", f"{entry.mount}/{entry.path}"]
)
if proc.returncode != 0:
raise DeliveryError(
f"scoped read failed for lane '{entry.id}' (denied or absent)"
)
try:
data = json.loads(proc.stdout)["data"]["data"]
except (json.JSONDecodeError, KeyError) as e:
raise DeliveryError(f"malformed KV response for lane '{entry.id}'") from e
if field not in data:
raise DeliveryError(f"field '{field}' absent in lane '{entry.id}'")
return data[field]
except DeliveryError:
raise
except Exception as e:
raise DeliveryError(f"could not obtain scoped token for delivery: {e}") from e
scoped = OpenBaoClient(addr=client.addr, token=token, bao_bin=client.bao_bin)
proc = scoped._run(["kv", "get", "-format=json", f"{entry.mount}/{entry.path}"])
if proc.returncode != 0:
raise DeliveryError(f"scoped read failed for lane '{entry.id}' (denied or absent)")
try:
data = json.loads(proc.stdout)["data"]["data"]
except (json.JSONDecodeError, KeyError) as e:
raise DeliveryError(f"malformed KV response for lane '{entry.id}'") from e
if field not in data:
raise DeliveryError(f"field '{field}' absent in lane '{entry.id}'")
return data[field]
raise DeliveryError(f"scoped delivery session failed: {e}") from e
finally:
if session_evidence is not None:
if session is not None and hasattr(session, "evidence"):
session_evidence.update(session.evidence())
else:
session_evidence.setdefault("established", session is not None)
def _registry_authkey(registry: str) -> str:
@ -123,6 +141,7 @@ def exec_with_secret(
*,
mode: str = "auto",
policy_dir=None,
session_evidence: dict[str, object] | None = None,
) -> int:
"""Run `command` with the lane's secret injected for the child only.
@ -151,7 +170,12 @@ def exec_with_secret(
f"(allowed {sorted(declared)})"
)
value = _fetch_value(client, entry, field)
if session_evidence is None:
value = _fetch_value(client, entry, field)
else:
value = _fetch_value(
client, entry, field, session_evidence=session_evidence
)
child_env = dict(os.environ)
if mode == "npm-config":

View file

@ -0,0 +1,196 @@
"""Non-destructive lifecycle plans for native secrets-engine access.
The legacy KV revoke path deleted all KV metadata while leaving delivery auth
behind. That is not revocation and is not reversible. Ordinary revoke now
means *deactivate native access*: remove only policy/AppRole objects managed by
secrets-engine and preserve OpenBao custody plus externally managed workload
delivery.
"""
from __future__ import annotations
from dataclasses import dataclass
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import PolicyGuardError
from secrets_engine.openbao import OpenBaoClient
LIFECYCLE_OPERATIONS = ("suspend", "deactivate", "destroy")
@dataclass(frozen=True)
class LifecycleAction:
kind: str
target: str
mutation: bool
reason: str
mount: str = ""
path: str = ""
def render(self) -> str:
mode = "mutation" if self.mutation else "no mutation"
return f" [{self.kind}] {self.target} ({mode}; {self.reason})"
@dataclass(frozen=True)
class LifecyclePlan:
catalog_id: str
operation: str
actions: tuple[LifecycleAction, ...]
def render(self) -> str:
lines = [
f"Lifecycle plan for lane '{self.catalog_id}'",
f" operation: {self.operation}",
" actions:",
]
lines.extend(action.render() for action in self.actions)
return "\n".join(lines)
@dataclass(frozen=True)
class LifecycleResult:
applied: tuple[str, ...]
preserved: tuple[str, ...]
def render(self) -> str:
lines = [f" applied: {target}" for target in self.applied]
lines.extend(f" preserved: {target}" for target in self.preserved)
return "\n".join(lines) or " (nothing to do)"
def build_lifecycle_plan(entry: CatalogEntry, operation: str) -> LifecyclePlan:
"""Plan an explicit lifecycle operation with no hidden mutations."""
if operation not in LIFECYCLE_OPERATIONS:
raise PolicyGuardError(
f"unknown lifecycle operation '{operation}'; allowed {LIFECYCLE_OPERATIONS}"
)
if operation == "destroy" and not entry.stores_kv_value():
raise PolicyGuardError(
f"lane '{entry.id}' is {entry.kind}; it has no KV custody to destroy"
)
actions: list[LifecycleAction] = []
if entry.manages_delivery_auth:
actions.append(
LifecycleAction(
"delete-approle",
entry.role_name,
True,
"stop new native delivery logins",
)
)
if operation in {"deactivate", "destroy"}:
actions.append(
LifecycleAction(
"delete-policy",
entry.policy_name,
True,
"remove native consumer policy",
)
)
else:
actions.append(
LifecycleAction(
"preserve-policy",
entry.policy_name,
False,
"suspension keeps policy for reviewed rollback",
)
)
elif entry.has_delivery_auth:
actions.extend(
(
LifecycleAction(
"preserve-external-approle",
entry.role_name,
False,
"delivery auth is externally managed",
),
LifecycleAction(
"preserve-external-policy",
entry.policy_name,
False,
"delivery auth is externally managed",
),
)
)
else:
actions.append(
LifecycleAction(
"no-native-delivery-auth",
entry.id,
False,
"lane declares no native delivery auth",
)
)
if entry.stores_kv_value():
if operation == "destroy":
actions.append(
LifecycleAction(
"delete-kv-metadata",
f"{entry.mount}/{entry.path}",
True,
"irreversibly delete all KV versions and metadata",
mount=entry.mount,
path=entry.path,
)
)
else:
actions.append(
LifecycleAction(
"preserve-kv-custody",
f"{entry.mount}/{entry.path}",
False,
"suspend/deactivate never delete KV metadata or values",
)
)
if entry.workload_delivery:
actions.append(
LifecycleAction(
"preserve-workload-delivery",
entry.id,
False,
"workload delivery is externally managed",
)
)
return LifecyclePlan(
catalog_id=entry.id,
operation=operation,
actions=tuple(actions),
)
def build_native_deactivation_plan(entry: CatalogEntry) -> LifecyclePlan:
"""Compatibility plan for ordinary revoke: safe native deactivation."""
return build_lifecycle_plan(entry, "deactivate")
def require_destroy_confirmation(entry: CatalogEntry, confirmation: str) -> None:
"""Require an exact lane id before an irreversible custody operation."""
if confirmation != entry.id:
raise PolicyGuardError(
"destroy requires --confirm-destroy with the exact catalog id; "
"no custody mutation performed"
)
def apply_lifecycle_plan(
client: OpenBaoClient, plan: LifecyclePlan
) -> LifecycleResult:
"""Apply exactly the mutations described by a lifecycle plan."""
applied: list[str] = []
preserved: list[str] = []
for action in plan.actions:
if not action.mutation:
preserved.append(action.target)
continue
if action.kind == "delete-approle":
client.delete_approle(action.target)
elif action.kind == "delete-policy":
client.delete_policy(action.target)
elif action.kind == "delete-kv-metadata":
client.kv_delete_metadata(action.mount, action.path)
else: # defensive: a rendered plan must not grow hidden mutations
raise ValueError(f"unsupported lifecycle mutation '{action.kind}'")
applied.append(action.target)
return LifecycleResult(applied=tuple(applied), preserved=tuple(preserved))

View file

@ -19,6 +19,8 @@ import os
import shutil
import stat
import subprocess
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
@ -49,6 +51,39 @@ def _check_token_file(path: Path) -> str:
return token
@dataclass
class ScopedTokenSession:
"""One AppRole login token that revokes itself on close."""
client: "OpenBaoClient"
accessor_fingerprint: str
closed: bool = False
revocation_attempted: bool = False
revocation_succeeded: bool = False
def evidence(self) -> dict[str, object]:
"""Return non-secret lifecycle evidence for this issued session."""
return {
"session_handle": self.accessor_fingerprint,
"established": True,
"revocation_attempted": self.revocation_attempted,
"revocation_succeeded": self.revocation_succeeded,
}
def close(self) -> None:
if self.closed:
return
self.revocation_attempted = True
try:
self.client._run_ok(["token", "revoke", "-self"])
self.revocation_succeeded = True
finally:
# Drop the credential from the reusable client object even when
# backend cleanup fails; TTL/use limits remain the final backstop.
self.client.token = ""
self.closed = True
@dataclass
class OpenBaoClient:
addr: str
@ -104,6 +139,25 @@ class OpenBaoClient:
)
return proc.stdout
def _run_ok_with_json_file(self, args: list[str], payload: dict) -> str:
"""Run a CLI operation with JSON from a strict temporary file reference."""
fd, temp_name = tempfile.mkstemp(prefix="se-bao-input-", suffix=".json")
temp_path = Path(temp_name)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fd = -1
json.dump(payload, fh)
fh.write("\n")
return self._run_ok([*args, f"@{temp_path}"])
finally:
if fd >= 0:
os.close(fd)
try:
temp_path.unlink()
except FileNotFoundError:
pass
# -- health / capabilities --------------------------------------------
def is_reachable(self) -> bool:
@ -188,21 +242,43 @@ class OpenBaoClient:
["write", "-field=secret_id", "-f", f"auth/approle/role/{role_name}/secret-id"]
).strip()
def approle_login_token(self, role_name: str) -> str:
"""Login as the approle and return a scoped child token. Used only for
verification / exec delivery; never logged."""
def create_approle_session(self, role_name: str) -> ScopedTokenSession:
"""Login through AppRole without putting role/secret ids in argv."""
role_id = self.read_approle_role_id(role_name)
secret_id = self.create_approle_secret_id(role_name)
token = self._run_ok(
[
"write",
"-field=token",
"auth/approle/login",
f"role_id={role_id}",
f"secret_id={secret_id}",
]
).strip()
return token
try:
output = self._run_ok_with_json_file(
["write", "-format=json", "auth/approle/login"],
{"role_id": role_id, "secret_id": secret_id},
)
finally:
del secret_id
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 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]
scoped = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
del token
return ScopedTokenSession(
client=scoped,
accessor_fingerprint=fingerprint,
)
@contextmanager
def approle_session(self, role_name: str):
"""Yield a scoped client and revoke its token on every exit path."""
session = self.create_approle_session(role_name)
try:
yield session
finally:
session.close()
def token_capabilities(self, path: str, *, token: str) -> list[str]:
"""Return token capabilities for a path without returning any secret value."""
@ -253,33 +329,117 @@ class OpenBaoClient:
return
raise BackendError(f"could not enable kv at {mount}: {stderr.strip()}")
def kv_put(self, mount: str, path: str, field: str, value: str) -> None:
"""Write a single field. `value` is a secret and is passed via stdin-free
argv only as a key=value to the local CLI; it is never logged or returned."""
self._run_ok(["kv", "put", f"{mount}/{path}", f"{field}={value}"])
def kv_current_version(self, mount: str, path: str) -> int:
"""Return current KV v2 version from metadata, or 0 when absent.
Metadata contains no secret values. Permission/backend errors fail closed
rather than being misread as a new path.
"""
proc = self._run(
["kv", "metadata", "get", "-format=json", f"-mount={mount}", path]
)
if proc.returncode != 0:
message = f"{proc.stderr}\n{proc.stdout}".lower()
if "no value found" in message or "not found" in message or "404" in message:
return 0
raise BackendError(
f"bao kv metadata get failed (exit {proc.returncode}): "
f"{proc.stderr.strip() or proc.stdout.strip()}"
)
try:
version = json.loads(proc.stdout).get("data", {}).get("current_version", 0)
return int(version)
except (json.JSONDecodeError, TypeError, ValueError) as e:
raise BackendError("malformed KV metadata response") from e
def kv_patch_fields(
self,
mount: str,
path: str,
values: dict[str, str],
*,
expected_version: int | None = None,
) -> int:
"""Atomically update declared fields without exposing values in argv.
Existing paths use server-side HTTP PATCH with CAS, preserving every
unmentioned sibling. New paths use CAS=0 put. Values are written to a
mode-0600 temporary JSON input file referenced by path and removed in a
finally block. The returned integer is the version used as the CAS base.
"""
if not values:
raise BackendError("refusing empty KV field update")
if any(not isinstance(k, str) or not k for k in values):
raise BackendError("KV field update contains an invalid field name")
version = (
self.kv_current_version(mount, path)
if expected_version is None
else expected_version
)
if version < 0:
raise BackendError("expected KV version must be non-negative")
if version == 0:
args = [
"kv",
"put",
f"-mount={mount}",
"-cas=0",
path,
]
else:
args = [
"kv",
"patch",
f"-mount={mount}",
"-method=patch",
f"-cas={version}",
path,
]
self._run_ok_with_json_file(args, values)
return version
def kv_metadata_exists(self, mount: str, path: str) -> bool:
proc = self._run(["kv", "metadata", "get", "-format=json", f"{mount}/{path}"])
return proc.returncode == 0
def kv_field_present(self, mount: str, path: str, field: str, *, token: str | None = None) -> bool:
"""Return whether `field` exists at the path — WITHOUT returning its value.
def kv_fields_present(
self,
mount: str,
path: str,
fields: list[str] | tuple[str, ...],
*,
token: str | None = None,
) -> dict[str, bool]:
"""Return declared-field presence after one read, never field values.
If `token` is given, the read is attempted as that (scoped) token, so a
True/False result doubles as a positive/negative access check.
If ``token`` is given, the read is attempted as that scoped token. A
denied or malformed read reports every requested field as absent; no
response data is returned to the caller.
"""
requested = tuple(dict.fromkeys(fields))
if not requested:
return {}
client = self
if token is not None:
client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
proc = client._run(["kv", "get", "-format=json", f"{mount}/{path}"])
if proc.returncode != 0:
return False
return {field: False for field in requested}
try:
doc = json.loads(proc.stdout)
except json.JSONDecodeError:
return False
return {field: False for field in requested}
data = doc.get("data", {}).get("data", {})
return field in data and bool(data[field])
if not isinstance(data, dict):
return {field: False for field in requested}
return {field: field in data and bool(data[field]) for field in requested}
def kv_field_present(
self, mount: str, path: str, field: str, *, token: str | None = None
) -> bool:
"""Compatibility wrapper for a single non-secret presence result."""
return self.kv_fields_present(mount, path, [field], token=token)[field]
def kv_can_read(self, mount: str, path: str, *, token: str) -> bool:
"""True iff `token` is permitted to read the path at all (no value used)."""

View file

@ -57,7 +57,7 @@ def provision_from_file(
value = _read_value_file(Path(file_path))
if entry.manages_mount:
client.ensure_kv_mount(entry.mount)
client.kv_put(entry.mount, entry.path, field, value)
client.kv_patch_fields(entry.mount, entry.path, {field: value})
del value
return field
@ -81,6 +81,6 @@ def provision_generated(client: OpenBaoClient, entry: CatalogEntry, field: str)
value = "test-" + "".join(_secrets.choice(alphabet) for _ in range(32))
if entry.manages_mount:
client.ensure_kv_mount(entry.mount)
client.kv_put(entry.mount, entry.path, field, value)
client.kv_patch_fields(entry.mount, entry.path, {field: value})
del value
return field

View file

@ -24,7 +24,7 @@ STAGE_PREFIX = {
}
# Capabilities a stage role's *own* policy may carry. Anything else is broad.
ALLOWED_CAPABILITIES = {"create", "read", "update", "delete", "list"}
ALLOWED_CAPABILITIES = {"create", "read", "update", "patch", "delete", "list"}
# Auth-capability lanes grant an operational action, not KV value access. Keep
# this intentionally smaller than the stage-role capability set.

View file

@ -27,6 +27,7 @@ class RouteResult:
review_url: str
metadata_applied: bool
value_present: bool
missing_fields: list[str]
ready: bool
next_command: str
missing: str
@ -59,16 +60,17 @@ def route_lane(
metadata_applied = False
value_present = False
missing_fields: list[str] = list(entry.fields) if entry.stores_kv_value() else []
if client is not None and client.is_reachable():
if entry.has_delivery_auth:
policy_applied = client.read_policy(entry.policy_name) is not None
role_applied = client.approle_exists(entry.role_name)
metadata_applied = policy_applied and role_applied
if entry.stores_kv_value():
# Presence check uses the engine's own token; reports boolean only.
field = entry.fields[0] if entry.fields else ""
if field:
value_present = client.kv_field_present(entry.mount, entry.path, field)
# One read produces booleans only; readiness requires every field.
presence = client.kv_fields_present(entry.mount, entry.path, entry.fields)
missing_fields = [field for field in entry.fields if not presence.get(field)]
value_present = bool(entry.fields) and not missing_fields
else:
# Auth-capability lanes have no stored value; a fresh secret_id is minted
# on demand through the handoff command once metadata exists.
@ -99,10 +101,12 @@ def route_lane(
"--role-id-file <path> --secret-id-file <path>"
)
elif not value_present:
missing = "provisioned secret value"
names = ", ".join(missing_fields)
missing = f"provisioned secret fields: {names}"
next_field = missing_fields[0] if missing_fields else entry.fields[0]
next_command = (
f"secrets-engine provision {entry.id} --stage {entry.stage} "
f"--field {entry.fields[0]} --from-file <path>"
f"--field {next_field} --from-file <path>"
)
else:
missing = ""
@ -123,6 +127,7 @@ def route_lane(
review_url=review_url,
metadata_applied=metadata_applied,
value_present=value_present,
missing_fields=missing_fields,
ready=ready,
next_command=next_command,
missing=missing,

View file

@ -27,6 +27,16 @@ class VerifyResult:
return f" {self.check} check: {status} ({self.detail.get('reason', '')})"
def _session_evidence(session: object | None) -> dict[str, Any]:
"""Extract only the session's explicit non-secret lifecycle record."""
if session is None:
return {"established": False}
evidence = getattr(session, "evidence", None)
if callable(evidence):
return evidence()
return {"established": True}
def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> VerifyResult:
"""Approved consumer token must be able to read the field."""
if entry.delivery_auth_method != "approle" or not entry.has_delivery_auth:
@ -35,15 +45,20 @@ def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> V
False,
{"reason": "lane has no AppRole delivery auth", "path": entry.path},
)
session = None
try:
token = client.approle_login_token(entry.role_name)
with client.approle_session(entry.role_name) as session:
present = session.client.kv_field_present(entry.mount, entry.path, field)
except Exception as e: # backend errors -> failed verification, not a value leak
return VerifyResult(
"positive",
False,
{"reason": f"could not obtain approle token: {e}", "path": entry.path},
{
"reason": f"scoped verification session failed: {e}",
"path": entry.path,
"session": _session_evidence(session),
},
)
present = client.kv_field_present(entry.mount, entry.path, field, token=token)
return VerifyResult(
"positive",
present,
@ -54,6 +69,7 @@ def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> V
"path": entry.path,
"field": field,
"role": entry.role_name,
"session": _session_evidence(session),
},
)
@ -76,26 +92,26 @@ def verify_negative(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
def verify_auth_capability_positive(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
"""Approved AppRole token must carry update on every exact allowed path."""
missing: list[str] = []
session = None
try:
token = client.approle_login_token(entry.role_name)
with client.approle_session(entry.role_name) as session:
for path in entry.auth_allowed_paths:
caps = session.client.token_capabilities(
path, token=session.client.token
)
if "update" not in caps:
missing.append(path)
except Exception as e:
return VerifyResult(
"positive",
False,
{"reason": f"could not obtain approle token: {e}", "role": entry.role_name},
{
"reason": f"scoped verification session failed: {e}",
"role": entry.role_name,
"session": _session_evidence(session),
},
)
missing: list[str] = []
for path in entry.auth_allowed_paths:
try:
caps = client.token_capabilities(path, token=token)
except Exception as e:
return VerifyResult(
"positive",
False,
{"reason": f"could not inspect capabilities: {e}", "path": path},
)
if "update" not in caps:
missing.append(path)
passed = not missing
return VerifyResult(
"positive",
@ -107,32 +123,33 @@ def verify_auth_capability_positive(client: OpenBaoClient, entry: CatalogEntry)
"role": entry.role_name,
"allowed_paths": sorted(entry.auth_allowed_paths),
"missing_update": missing,
"session": _session_evidence(session),
},
)
def verify_auth_capability_negative(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
"""Approved AppRole token must not gain update outside denial probes."""
leaks: list[str] = []
session = None
try:
token = client.approle_login_token(entry.role_name)
with client.approle_session(entry.role_name) as session:
for path in entry.auth_denied_probe_paths:
caps = session.client.token_capabilities(
path, token=session.client.token
)
if "update" in caps or "sudo" in caps or "root" in caps:
leaks.append(path)
except Exception as e:
return VerifyResult(
"negative",
False,
{"reason": f"could not obtain approle token: {e}", "role": entry.role_name},
{
"reason": f"scoped verification session failed: {e}",
"role": entry.role_name,
"session": _session_evidence(session),
},
)
leaks: list[str] = []
for path in entry.auth_denied_probe_paths:
try:
caps = client.token_capabilities(path, token=token)
except Exception as e:
return VerifyResult(
"negative",
False,
{"reason": f"could not inspect capabilities: {e}", "path": path},
)
if "update" in caps or "sudo" in caps or "root" in caps:
leaks.append(path)
passed = not leaks
return VerifyResult(
"negative",
@ -144,6 +161,7 @@ def verify_auth_capability_negative(client: OpenBaoClient, entry: CatalogEntry)
"role": entry.role_name,
"denied_probe_paths": entry.auth_denied_probe_paths,
"leaks": leaks,
"session": _session_evidence(session),
},
)