"""OpenBao over the mesh: jwt-tenancy login, token upkeep, KV-v2 reads of the env's own secrets. The agent never sees a secret from tenancy: it logs in with the deploy grant (`POST /v1/auth//login {"role": "see-env", "jwt": }`) and reads `/data//see/` itself. The policy `see-env` is templated on the token's entity alias (`user_claim=env_id`), so a manifest path for another env is refused here BEFORE it could even be tried (`ManifestPathError`).""" from __future__ import annotations import logging import urllib.parse from dataclasses import dataclass from monky_deployd.redact import REDACTOR from monky_deployd.transport import HttpClient, HttpResponse, TransportError log = logging.getLogger("monky-deployd.bao") class BaoError(Exception): def __init__(self, status: int, errors: list[str] | str, where: str = ""): errs = errors if isinstance(errors, list) else [str(errors)] super().__init__(f"{where or 'openbao'}: {status} {'; '.join(errs)[:300]}") self.status = status self.errors = errs class ManifestPathError(Exception): pass @dataclass class BaoToken: client_token: str accessor: str | None ttl_s: int renewable: bool meta: dict policies: list[str] @property def grant_jti(self) -> str | None: return (self.meta or {}).get("grant_jti") def kv_data_path(kv_mount: str, manifest_path: str, env_id: str) -> str: """`monky//see/` or `monky/data//see/` -> `/v1/monky/data//see/`. The env segment MUST equal this agent's env_id: the agent reads its own subtree and nothing else (ADR-0028 ยง4 "what the agent may read").""" parts = [p for p in manifest_path.strip("/").split("/") if p] if len(parts) < 3 or parts[0] != kv_mount: raise ManifestPathError(f"manifest path {manifest_path!r} is not under KV mount {kv_mount!r}") rest = parts[1:] if rest[0] == "data": rest = rest[1:] if len(rest) < 3 or rest[0] != env_id or rest[1] != "see": raise ManifestPathError(f"manifest path {manifest_path!r} is outside {kv_mount}/data/{env_id}/see/") if any(p in (".", "..") or not p for p in rest): raise ManifestPathError(f"manifest path {manifest_path!r} is malformed") return "/v1/" + "/".join([kv_mount, "data", *rest]) def _errors(resp: HttpResponse) -> list[str]: try: js = resp.json() if isinstance(js, dict) and isinstance(js.get("errors"), list): return [str(e) for e in js["errors"]] or [f"http {resp.status}"] except TransportError: pass return [resp.text()[:200] or f"http {resp.status}"] class BaoClient: def __init__(self, http: HttpClient, *, auth_mount: str, role: str, kv_mount: str): self.http = http self.auth_mount = auth_mount self.role = role self.kv_mount = kv_mount def _call(self, method: str, path: str, token: str | None = None, json_body=None) -> HttpResponse: headers = {"X-Vault-Request": "true"} if token: headers["X-Vault-Token"] = token resp = self.http.request(method, path, json_body=json_body, headers=headers) if resp.status >= 400: raise BaoError(resp.status, _errors(resp), f"{method} {path.split('?')[0]}") return resp # -- auth -------------------------------------------------------------------------------------- def login(self, grant_jwt: str, *, mount: str | None = None, role: str | None = None) -> BaoToken: mount = mount or self.auth_mount role = role or self.role REDACTOR.add(grant_jwt) js = self._call("POST", f"/v1/auth/{mount}/login", json_body={"role": role, "jwt": grant_jwt}).json() auth = (js or {}).get("auth") or {} tok = auth.get("client_token") if not tok: raise BaoError(200, "login returned no client_token", f"auth/{mount}/login") REDACTOR.add(tok) return BaoToken( client_token=tok, accessor=auth.get("accessor"), ttl_s=int(auth.get("lease_duration") or 0), renewable=bool(auth.get("renewable", False)), meta=dict(auth.get("metadata") or {}), policies=list(auth.get("token_policies") or auth.get("policies") or []), ) def lookup_self(self, token: str) -> dict: js = self._call("GET", "/v1/auth/token/lookup-self", token).json() return dict((js or {}).get("data") or {}) def renew_self(self, token: str, increment_s: int | None = None) -> int: body = {"increment": f"{increment_s}s"} if increment_s else {} js = self._call("POST", "/v1/auth/token/renew-self", token, json_body=body).json() return int(((js or {}).get("auth") or {}).get("lease_duration") or 0) def revoke_self(self, token: str) -> None: try: self._call("POST", "/v1/auth/token/revoke-self", token, json_body={}) except BaoError as exc: log.debug("revoke-self ignored: %s", exc) # -- KV ---------------------------------------------------------------------------------------- def kv_read(self, token: str, manifest_path: str, env_id: str, version: int | None) -> str: path = kv_data_path(self.kv_mount, manifest_path, env_id) if version: path += "?" + urllib.parse.urlencode({"version": int(version)}) js = self._call("GET", path, token).json() or {} data = (js.get("data") or {}).get("data") if not isinstance(data, dict) or not data: raise BaoError(200, "empty secret", path.split("?")[0]) if "value" in data: value = data["value"] elif len(data) == 1: value = next(iter(data.values())) else: raise BaoError(200, "secret has no 'value' key and is ambiguous", path.split("?")[0]) if not isinstance(value, str): value = str(value) REDACTOR.add(value) got = ((js.get("data") or {}).get("metadata") or {}).get("version") if version and got is not None and int(got) != int(version): raise BaoError(200, f"version mismatch: wanted {version}, got {got}", path.split("?")[0]) return value