"""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 = "") -> 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]