Harden secret provisioning and lifecycle controls
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
This commit is contained in:
parent
0617923ff1
commit
3a1bd4f1c8
23 changed files with 1369 additions and 162 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
196
src/secrets_engine/lifecycle.py
Normal file
196
src/secrets_engine/lifecycle.py
Normal 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))
|
||||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue