secrets-engine/src/secrets_engine/catalog.py

202 lines
7.1 KiB
Python
Raw Normal View History

"""Catalog: the non-secret registry of secret lanes and grants.
A catalog entry describes *where* a secret lives in OpenBao, *who* may consume
it, *how* it is delivered, and *what* approval/verification/rotation it requires.
It never contains a secret value. Loading and validation are strict: a malformed
or under-specified lane is rejected rather than silently defaulted.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from secrets_engine.errors import CatalogError
from secrets_engine.redact import looks_secret
VALID_STAGES = ("build", "test", "prod")
VALID_DELIVERY_MODES = ("exec-env", "exec-file", "npm-config", "wrapped", "read-check")
VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only")
REQUIRED_FIELDS = (
"id",
"org",
"repo",
"stage",
"mount",
"path",
"fields",
"consumers",
"delivery_modes",
"approval",
"verification",
"rotation",
"deactivation",
"audit",
)
@dataclass(frozen=True)
class CatalogEntry:
id: 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")
org: str
repo: str
stage: str
mount: str
path: str
fields: list[str]
consumers: list[dict[str, Any]]
delivery_modes: list[str]
approval: dict[str, Any]
verification: dict[str, Any]
rotation: dict[str, Any]
deactivation: dict[str, Any]
audit: dict[str, Any]
delivery_config: dict[str, Any] = field(default_factory=dict)
description: str = ""
raw: dict[str, Any] = field(default_factory=dict)
@property
def owner(self) -> str:
"""The lane's owning repo as the explicit ``org/repo`` slug."""
return f"{self.org}/{self.repo}"
@property
def npm(self) -> dict[str, Any]:
"""npm delivery config (registry, scope, package) when present."""
return self.delivery_config.get("npm", {})
@property
def kv_data_path(self) -> str:
"""Full KV v2 *data* path used for read/write of the value."""
return f"{self.mount}/data/{self.path}"
@property
def kv_logical_path(self) -> str:
"""KV v2 logical path (used inside ACL policy capabilities)."""
return f"{self.mount}/data/{self.path}"
@property
def policy_name(self) -> str:
return f"se-{self.stage}-{self.id}"
@property
def role_name(self) -> str:
return f"se-{self.stage}-{self.id}"
def approval_required(self) -> bool:
return self.approval.get("model") != "bootstrap-only"
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")
missing = [k for k in REQUIRED_FIELDS if k not in data or data[k] in (None, "", [], {})]
if missing:
raise CatalogError(f"{source}: missing required fields: {', '.join(missing)}")
stage = data["stage"]
if stage not in VALID_STAGES:
raise CatalogError(
f"{source}: stage '{stage}' invalid; must be one of {VALID_STAGES}"
)
modes = data["delivery_modes"]
if not isinstance(modes, list) or not modes:
raise CatalogError(f"{source}: delivery_modes must be a non-empty list")
bad_modes = [m for m in modes if m not in VALID_DELIVERY_MODES]
if bad_modes:
raise CatalogError(
f"{source}: unknown delivery_modes {bad_modes}; allowed {VALID_DELIVERY_MODES}"
)
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")
consumers = data["consumers"]
if not isinstance(consumers, list) or not consumers:
raise CatalogError(f"{source}: consumers must be a non-empty list")
for c in consumers:
if not isinstance(c, dict) or "name" not in c or "auth" not in c:
raise CatalogError(
f"{source}: each consumer needs at least 'name' and 'auth'"
)
approval = data["approval"]
if not isinstance(approval, dict) or "model" not in approval:
raise CatalogError(f"{source}: approval must include a 'model'")
if approval["model"] not in VALID_APPROVAL_MODELS:
raise CatalogError(
f"{source}: approval.model '{approval['model']}' invalid; "
f"allowed {VALID_APPROVAL_MODELS}"
)
# npm-config delivery must declare WHERE it publishes (registry + scope), so
# the registry is catalog data, never hardcoded in the engine.
if "npm-config" in modes:
npm = (data.get("delivery_config") or {}).get("npm")
if not isinstance(npm, dict) or not npm.get("registry") or not npm.get("scope"):
raise CatalogError(
f"{source}: npm-config delivery requires "
"delivery_config.npm.registry and .scope"
)
if not str(npm["registry"]).startswith(("http://", "https://")):
raise CatalogError(
f"{source}: delivery_config.npm.registry must be an http(s) URL"
)
# 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")
# Guard against accidentally broad mount/path.
path = data["path"]
if "*" in path or "*" in data["mount"]:
raise CatalogError(f"{source}: wildcard mount/path not allowed in catalog")
known = {f.name for f in CatalogEntry.__dataclass_fields__.values()} - {"raw"}
kwargs = {k: v for k, v in data.items() if k in known}
return CatalogEntry(raw=data, **kwargs)
def load_entry(path: Path) -> CatalogEntry:
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except FileNotFoundError as e:
raise CatalogError(f"catalog file not found: {path}") from e
except yaml.YAMLError as e:
raise CatalogError(f"{path}: YAML parse error: {e}") from e
return validate_entry(data, source=str(path))
def load_catalog(catalog_dir: Path) -> dict[str, CatalogEntry]:
"""Load and validate every *.yaml in the catalog directory."""
catalog_dir = Path(catalog_dir)
if not catalog_dir.exists():
return {}
entries: dict[str, CatalogEntry] = {}
for path in sorted(catalog_dir.glob("*.yaml")):
entry = load_entry(path)
if entry.id in entries:
raise CatalogError(f"duplicate catalog id '{entry.id}' in {path}")
entries[entry.id] = entry
return entries
def get_entry(catalog_dir: Path, catalog_id: str) -> CatalogEntry:
entries = load_catalog(catalog_dir)
if catalog_id not in entries:
raise CatalogError(
f"catalog id '{catalog_id}' not found in {catalog_dir} "
f"(known: {', '.join(sorted(entries)) or 'none'})"
)
return entries[catalog_id]