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

@ -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)."""