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
|
|
@ -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)."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue