feat: add auth-capability lanes and pilot closeout
Add the warden-sign auth-capability lane, AppRole handoff, verification guards, docs, and tests. Point the whynot-design pilot at the canonical decision and add the real publish closeout preflight/runbook.
This commit is contained in:
parent
a621fbaffd
commit
6382139890
27 changed files with 1455 additions and 107 deletions
|
|
@ -16,18 +16,26 @@ import yaml
|
|||
from secrets_engine.errors import CatalogError
|
||||
from secrets_engine.redact import looks_secret
|
||||
|
||||
VALID_KINDS = ("kv", "auth-capability")
|
||||
VALID_STAGES = ("build", "test", "prod")
|
||||
VALID_DELIVERY_MODES = ("exec-env", "exec-file", "npm-config", "wrapped", "read-check")
|
||||
VALID_DELIVERY_MODES = (
|
||||
"exec-env",
|
||||
"exec-file",
|
||||
"npm-config",
|
||||
"wrapped",
|
||||
"read-check",
|
||||
"approle-login",
|
||||
)
|
||||
VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only")
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"id",
|
||||
"kind",
|
||||
"org",
|
||||
"repo",
|
||||
"stage",
|
||||
"mount",
|
||||
"path",
|
||||
"fields",
|
||||
"consumers",
|
||||
"delivery_modes",
|
||||
"approval",
|
||||
|
|
@ -41,6 +49,7 @@ REQUIRED_FIELDS = (
|
|||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
id: str
|
||||
kind: str
|
||||
# Gitea coordinates, kept explicit to avoid the overloaded word "project".
|
||||
# org = the Gitea organisation (e.g. "coulomb")
|
||||
# repo = the Gitea repository / product (e.g. "whynot-design")
|
||||
|
|
@ -58,6 +67,7 @@ class CatalogEntry:
|
|||
deactivation: dict[str, Any]
|
||||
audit: dict[str, Any]
|
||||
delivery_config: dict[str, Any] = field(default_factory=dict)
|
||||
auth_capability: dict[str, Any] = field(default_factory=dict)
|
||||
description: str = ""
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
|
@ -83,22 +93,75 @@ class CatalogEntry:
|
|||
|
||||
@property
|
||||
def policy_name(self) -> str:
|
||||
if self.kind == "auth-capability":
|
||||
return self.auth_capability.get("policy_name") or self.id
|
||||
return f"se-{self.stage}-{self.id}"
|
||||
|
||||
@property
|
||||
def role_name(self) -> str:
|
||||
if self.kind == "auth-capability":
|
||||
return self.auth_capability.get("role_name") or self.id
|
||||
return f"se-{self.stage}-{self.id}"
|
||||
|
||||
@property
|
||||
def auth_allowed_paths(self) -> dict[str, list[str]]:
|
||||
"""Allowed OpenBao paths/capabilities for an auth-capability lane."""
|
||||
paths: dict[str, list[str]] = {}
|
||||
for item in self.auth_capability.get("allowed_paths", []):
|
||||
paths[item["path"]] = list(item["capabilities"])
|
||||
return paths
|
||||
|
||||
@property
|
||||
def auth_denied_probe_paths(self) -> list[str]:
|
||||
"""Non-secret denial probes proving the lane did not get broader."""
|
||||
return list(self.auth_capability.get("denied_probe_paths", []))
|
||||
|
||||
@property
|
||||
def token_ttl(self) -> str:
|
||||
return str(self.auth_capability.get("token_ttl", self.rotation.get("ttl", "30m")))
|
||||
|
||||
@property
|
||||
def token_max_ttl(self) -> str:
|
||||
return str(self.auth_capability.get("token_max_ttl", self.token_ttl))
|
||||
|
||||
@property
|
||||
def secret_id_ttl(self) -> str:
|
||||
return str(self.auth_capability.get("secret_id_ttl", self.token_ttl))
|
||||
|
||||
@property
|
||||
def secret_id_num_uses(self) -> int:
|
||||
return int(self.auth_capability.get("secret_id_num_uses", 0))
|
||||
|
||||
@property
|
||||
def token_num_uses(self) -> int:
|
||||
return int(self.auth_capability.get("token_num_uses", 0))
|
||||
|
||||
def approval_required(self) -> bool:
|
||||
return self.approval.get("model") != "bootstrap-only"
|
||||
|
||||
def stores_kv_value(self) -> bool:
|
||||
return self.kind == "kv"
|
||||
|
||||
|
||||
def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> CatalogEntry:
|
||||
"""Validate a raw mapping and return a CatalogEntry, or raise CatalogError."""
|
||||
if not isinstance(data, dict):
|
||||
raise CatalogError(f"{source}: catalog entry must be a mapping")
|
||||
|
||||
data = dict(data)
|
||||
data.setdefault("kind", "kv")
|
||||
if data["kind"] not in VALID_KINDS:
|
||||
raise CatalogError(
|
||||
f"{source}: kind '{data['kind']}' invalid; must be one of {VALID_KINDS}"
|
||||
)
|
||||
if data["kind"] == "auth-capability":
|
||||
data.setdefault("fields", [])
|
||||
else:
|
||||
data.setdefault("auth_capability", {})
|
||||
|
||||
missing = [k for k in REQUIRED_FIELDS if k not in data or data[k] in (None, "", [], {})]
|
||||
if data["kind"] == "kv" and not data.get("fields"):
|
||||
missing.append("fields")
|
||||
if missing:
|
||||
raise CatalogError(f"{source}: missing required fields: {', '.join(missing)}")
|
||||
|
||||
|
|
@ -120,6 +183,8 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
fields = data["fields"]
|
||||
if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields):
|
||||
raise CatalogError(f"{source}: fields must be a list of strings")
|
||||
if data["kind"] == "auth-capability" and fields:
|
||||
raise CatalogError(f"{source}: auth-capability lanes must not declare KV fields")
|
||||
|
||||
consumers = data["consumers"]
|
||||
if not isinstance(consumers, list) or not consumers:
|
||||
|
|
@ -153,6 +218,35 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
f"{source}: delivery_config.npm.registry must be an http(s) URL"
|
||||
)
|
||||
|
||||
if data["kind"] == "auth-capability":
|
||||
cfg = data.get("auth_capability")
|
||||
if not isinstance(cfg, dict) or not cfg.get("allowed_paths"):
|
||||
raise CatalogError(
|
||||
f"{source}: auth-capability lanes require auth_capability.allowed_paths"
|
||||
)
|
||||
if "approle-login" not in modes:
|
||||
raise CatalogError(
|
||||
f"{source}: auth-capability lanes require approle-login delivery mode"
|
||||
)
|
||||
allowed = cfg["allowed_paths"]
|
||||
if not isinstance(allowed, list) or not allowed:
|
||||
raise CatalogError(f"{source}: auth_capability.allowed_paths must be a list")
|
||||
for item in allowed:
|
||||
if not isinstance(item, dict) or "path" not in item or "capabilities" not in item:
|
||||
raise CatalogError(
|
||||
f"{source}: each allowed path needs path and capabilities"
|
||||
)
|
||||
caps = item["capabilities"]
|
||||
if not isinstance(caps, list) or not all(isinstance(c, str) for c in caps):
|
||||
raise CatalogError(
|
||||
f"{source}: auth_capability.allowed_paths capabilities must be strings"
|
||||
)
|
||||
denied = cfg.get("denied_probe_paths", [])
|
||||
if not isinstance(denied, list) or not all(isinstance(p, str) for p in denied):
|
||||
raise CatalogError(
|
||||
f"{source}: auth_capability.denied_probe_paths must be a list of strings"
|
||||
)
|
||||
|
||||
# A path must never leak a value through a field name suggesting inline secrets.
|
||||
if any(looks_secret(k) and data.get(k) for k in ("value", "secret", "token", "password")):
|
||||
raise CatalogError(f"{source}: catalog entries must not contain secret values")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue