secrets-engine/src/secrets_engine/handoff.py
tegwick 784be978bf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
feat: admit existing OpenBao catalog lanes
2026-08-21 08:20:33 +02:00

97 lines
3 KiB
Python

"""Out-of-band AppRole handoff helpers for auth-capability lanes.
The secret_id is secret material. It is minted only after output paths have been
validated, written to a mode-0600 file outside any Git worktree, and never
printed or recorded in evidence.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import ProvisioningError
from secrets_engine.openbao import OpenBaoClient
from secrets_engine.safe_paths import containing_git_worktree
@dataclass(frozen=True)
class HandoffResult:
role_name: str
role_id_file: str
secret_id_file: str
token_ttl: str
secret_id_ttl: str
secret_id_num_uses: int
def _assert_outside_git_worktree(path: Path) -> Path:
resolved = path.expanduser().resolve()
worktree = containing_git_worktree(resolved)
if worktree is not None:
raise ProvisioningError(
f"handoff file {resolved} is inside a Git worktree ({worktree}); "
"keep AppRole material outside repos"
)
return resolved
def _validate_output_path(path: Path) -> Path:
resolved = _assert_outside_git_worktree(path)
if resolved.exists() and resolved.stat().st_mode & 0o077:
raise ProvisioningError(
f"handoff file {resolved} is group/other-accessible "
f"(mode {oct(resolved.stat().st_mode & 0o777)}); must be 0600"
)
return resolved
def _write_mode_0600(path: Path, value: str) -> None:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
fd: int | None = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fd = None
fh.write(value)
fh.write("\n")
finally:
if fd is not None:
os.close(fd)
def write_approle_handoff(
client: OpenBaoClient,
entry: CatalogEntry,
*,
role_id_file: Path,
secret_id_file: Path,
) -> HandoffResult:
"""Mint and write AppRole handoff material without printing the secret_id."""
if entry.kind != "auth-capability":
raise ProvisioningError(
f"lane '{entry.id}' is {entry.kind}; handoff is only for auth-capability lanes"
)
role_path = _validate_output_path(role_id_file)
secret_path = _validate_output_path(secret_id_file)
if role_path == secret_path:
raise ProvisioningError("role_id_file and secret_id_file must be different")
role_id = client.read_approle_role_id(entry.role_name)
secret_id = client.create_approle_secret_id(entry.role_name)
try:
_write_mode_0600(role_path, role_id)
_write_mode_0600(secret_path, secret_id)
finally:
del secret_id
return HandoffResult(
role_name=entry.role_name,
role_id_file=str(role_path),
secret_id_file=str(secret_path),
token_ttl=entry.token_ttl,
secret_id_ttl=entry.secret_id_ttl,
secret_id_num_uses=entry.secret_id_num_uses,
)