557 lines
18 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import os
from typing import Any
import httpx
from ..settings import settings
from ..utils.logging import get_logger
logger = get_logger(__name__)
@dataclass(frozen=True)
class VaultResult:
status: str
detail: str = ""
def _split_csv(value: str) -> list[str]:
return [item.strip() for item in (value or "").split(",") if item.strip()]
def _read_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as handle:
return handle.read().strip()
except FileNotFoundError:
return ""
def _build_policy(read_paths: str, write_paths: str) -> str:
policy_parts: list[str] = []
for path in (read_paths or "").split():
policy_parts.append(
f'path "kv/data/atlas/{path}" {{\n capabilities = ["read"]\n}}\n'
f'path "kv/metadata/atlas/{path}" {{\n capabilities = ["list"]\n}}\n'
)
for path in (write_paths or "").split():
policy_parts.append(
f'path "kv/data/atlas/{path}" {{\n capabilities = ["create", "update", "read"]\n}}\n'
f'path "kv/metadata/atlas/{path}" {{\n capabilities = ["list"]\n}}\n'
)
return "\n".join(policy_parts).strip() + "\n"
_K8S_ROLES: list[dict[str, str]] = [
{
"role": "outline",
"namespace": "outline",
"service_accounts": "outline-vault",
"read_paths": "outline/* shared/postmark-relay",
"write_paths": "",
},
{
"role": "planka",
"namespace": "planka",
"service_accounts": "planka-vault",
"read_paths": "planka/* shared/postmark-relay",
"write_paths": "",
},
{
"role": "bstein-dev-home",
"namespace": "bstein-dev-home",
"service_accounts": "bstein-dev-home,bstein-dev-home-vault-sync",
"read_paths": "portal/* shared/chat-ai-keys-runtime shared/portal-e2e-client shared/postmark-relay "
"mailu/mailu-initial-account-secret shared/harbor-pull",
"write_paths": "",
},
{
"role": "gitea",
"namespace": "gitea",
"service_accounts": "gitea-vault",
"read_paths": "gitea/*",
"write_paths": "",
},
{
"role": "vaultwarden",
"namespace": "vaultwarden",
"service_accounts": "vaultwarden-vault",
"read_paths": "vaultwarden/* mailu/mailu-initial-account-secret",
"write_paths": "",
},
{
"role": "sso",
"namespace": "sso",
"service_accounts": "sso-vault,sso-vault-sync,mas-secrets-ensure",
"read_paths": "sso/* portal/bstein-dev-home-keycloak-admin shared/keycloak-admin "
"shared/portal-e2e-client shared/postmark-relay shared/harbor-pull",
"write_paths": "",
},
{
"role": "mailu-mailserver",
"namespace": "mailu-mailserver",
"service_accounts": "mailu-vault-sync",
"read_paths": "mailu/* shared/postmark-relay shared/harbor-pull",
"write_paths": "",
},
{
"role": "harbor",
"namespace": "harbor",
"service_accounts": "harbor-vault-sync",
"read_paths": "harbor/* shared/harbor-pull",
"write_paths": "",
},
{
"role": "nextcloud",
"namespace": "nextcloud",
"service_accounts": "nextcloud-vault",
"read_paths": "nextcloud/* shared/keycloak-admin shared/postmark-relay",
"write_paths": "",
},
{
"role": "comms",
"namespace": "comms",
"service_accounts": "comms-vault,atlasbot",
"read_paths": "comms/* shared/chat-ai-keys-runtime shared/harbor-pull",
"write_paths": "",
},
{
"role": "jenkins",
"namespace": "jenkins",
"service_accounts": "jenkins",
"read_paths": "jenkins/*",
"write_paths": "",
},
{
"role": "monitoring",
"namespace": "monitoring",
"service_accounts": "monitoring-vault-sync",
"read_paths": "monitoring/* shared/postmark-relay shared/harbor-pull",
"write_paths": "",
},
{
"role": "logging",
"namespace": "logging",
"service_accounts": "logging-vault-sync",
"read_paths": "logging/* shared/harbor-pull",
"write_paths": "",
},
{
"role": "pegasus",
"namespace": "jellyfin",
"service_accounts": "pegasus-vault-sync",
"read_paths": "pegasus/* shared/harbor-pull",
"write_paths": "",
},
{
"role": "crypto",
"namespace": "crypto",
"service_accounts": "crypto-vault-sync",
"read_paths": "crypto/* shared/harbor-pull",
"write_paths": "",
},
{
"role": "health",
"namespace": "health",
"service_accounts": "health-vault-sync",
"read_paths": "health/*",
"write_paths": "",
},
{
"role": "maintenance",
"namespace": "maintenance",
"service_accounts": "ariadne,maintenance-vault-sync",
"read_paths": "maintenance/ariadne-db portal/bstein-dev-home-keycloak-admin mailu/mailu-db-secret "
"mailu/mailu-initial-account-secret shared/harbor-pull",
"write_paths": "",
},
{
"role": "finance",
"namespace": "finance",
"service_accounts": "finance-vault",
"read_paths": "finance/* shared/postmark-relay",
"write_paths": "",
},
{
"role": "finance-secrets",
"namespace": "finance",
"service_accounts": "finance-secrets-ensure",
"read_paths": "",
"write_paths": "finance/*",
},
{
"role": "longhorn",
"namespace": "longhorn-system",
"service_accounts": "longhorn-vault,longhorn-vault-sync",
"read_paths": "longhorn/* shared/harbor-pull",
"write_paths": "",
},
{
"role": "postgres",
"namespace": "postgres",
"service_accounts": "postgres-vault",
"read_paths": "postgres/postgres-db",
"write_paths": "",
},
{
"role": "vault",
"namespace": "vault",
"service_accounts": "vault",
"read_paths": "vault/*",
"write_paths": "",
},
{
"role": "sso-secrets",
"namespace": "sso",
"service_accounts": "mas-secrets-ensure",
"read_paths": "shared/keycloak-admin",
"write_paths": "harbor/harbor-oidc vault/vault-oidc-config comms/synapse-oidc "
"logging/oauth2-proxy-logs-oidc finance/actual-oidc",
},
{
"role": "crypto-secrets",
"namespace": "crypto",
"service_accounts": "crypto-secrets-ensure",
"read_paths": "",
"write_paths": "crypto/wallet-monero-temp-rpc-auth",
},
{
"role": "comms-secrets",
"namespace": "comms",
"service_accounts": "comms-secrets-ensure,mas-db-ensure,mas-admin-client-secret-writer,othrys-synapse-signingkey-job",
"read_paths": "",
"write_paths": "comms/turn-shared-secret comms/livekit-api comms/synapse-redis comms/synapse-macaroon "
"comms/atlasbot-credentials-runtime comms/synapse-db comms/mas-db comms/mas-admin-client-runtime "
"comms/mas-secrets-runtime comms/othrys-synapse-signingkey",
},
]
_VAULT_ADMIN_POLICY = """
path "sys/auth" {
capabilities = ["read"]
}
path "sys/auth/*" {
capabilities = ["create", "update", "delete", "sudo", "read"]
}
path "auth/kubernetes/*" {
capabilities = ["create", "update", "read"]
}
path "auth/oidc/*" {
capabilities = ["create", "update", "read"]
}
path "sys/policies/acl" {
capabilities = ["list"]
}
path "sys/policies/acl/*" {
capabilities = ["create", "update", "read"]
}
path "sys/internal/ui/mounts" {
capabilities = ["read"]
}
path "sys/mounts" {
capabilities = ["read"]
}
path "sys/mounts/auth/*" {
capabilities = ["read", "update", "sudo"]
}
path "kv/data/atlas/vault/*" {
capabilities = ["read"]
}
path "kv/metadata/atlas/vault/*" {
capabilities = ["list"]
}
path "kv/data/*" {
capabilities = ["create", "update", "read", "delete", "patch"]
}
path "kv/metadata" {
capabilities = ["list"]
}
path "kv/metadata/*" {
capabilities = ["read", "list", "delete"]
}
path "kv/data/atlas/shared/*" {
capabilities = ["create", "update", "read", "patch"]
}
path "kv/metadata/atlas/shared/*" {
capabilities = ["list"]
}
""".strip()
_DEV_KV_POLICY = """
path "kv/metadata" {
capabilities = ["list"]
}
path "kv/metadata/atlas" {
capabilities = ["list"]
}
path "kv/metadata/atlas/shared" {
capabilities = ["list"]
}
path "kv/metadata/atlas/shared/*" {
capabilities = ["list"]
}
path "kv/data/atlas/shared/*" {
capabilities = ["read"]
}
""".strip()
class VaultClient:
def __init__(self, base_url: str, token: str | None = None) -> None:
self._base_url = base_url.rstrip("/")
self._token = token
def request(self, method: str, path: str, json: dict[str, Any] | None = None) -> httpx.Response:
headers = {}
if self._token:
headers["X-Vault-Token"] = self._token
return httpx.request(
method,
f"{self._base_url}{path}",
headers=headers,
json=json,
timeout=settings.k8s_api_timeout_sec,
)
class VaultService:
def __init__(self) -> None:
self._token: str | None = None
def _health(self, client: VaultClient) -> dict[str, Any]:
resp = client.request("GET", "/v1/sys/health")
resp.raise_for_status()
return resp.json()
def _ensure_token(self) -> str:
if self._token:
return self._token
if settings.vault_token:
self._token = settings.vault_token
return self._token
jwt = settings.vault_k8s_token_reviewer_jwt
if not jwt and settings.vault_k8s_token_reviewer_jwt_file:
jwt = _read_file(settings.vault_k8s_token_reviewer_jwt_file)
if not jwt:
jwt = _read_file("/var/run/secrets/kubernetes.io/serviceaccount/token")
if not jwt:
raise RuntimeError("vault auth jwt missing")
resp = httpx.post(
f"{settings.vault_addr.rstrip('/')}/v1/auth/kubernetes/login",
json={"role": settings.vault_k8s_role, "jwt": jwt},
timeout=settings.k8s_api_timeout_sec,
)
resp.raise_for_status()
token = resp.json().get("auth", {}).get("client_token")
if not isinstance(token, str) or not token:
raise RuntimeError("vault login token missing")
self._token = token
return token
def _client(self) -> VaultClient:
token = self._ensure_token()
return VaultClient(settings.vault_addr, token)
def _ensure_auth_enabled(self, client: VaultClient, auth_name: str, auth_type: str) -> None:
resp = client.request("GET", "/v1/sys/auth")
resp.raise_for_status()
mounts = resp.json()
if f"{auth_name}/" not in mounts:
resp = client.request("POST", f"/v1/sys/auth/{auth_name}", json={"type": auth_type})
resp.raise_for_status()
def _write_policy(self, client: VaultClient, name: str, policy: str) -> None:
resp = client.request("PUT", f"/v1/sys/policies/acl/{name}", json={"policy": policy})
resp.raise_for_status()
def _write_k8s_role(self, client: VaultClient, role: dict[str, str]) -> None:
payload = {
"bound_service_account_names": role["service_accounts"],
"bound_service_account_namespaces": role["namespace"],
"policies": role["role"],
"ttl": settings.vault_k8s_role_ttl,
}
resp = client.request("POST", f"/v1/auth/kubernetes/role/{role['role']}", json=payload)
resp.raise_for_status()
def _vault_ready(self) -> VaultResult | None:
try:
status = self._health(VaultClient(settings.vault_addr))
except Exception as exc: # noqa: BLE001
return VaultResult("error", str(exc))
if not status.get("initialized"):
return VaultResult("skip", "vault not initialized")
if status.get("sealed"):
return VaultResult("skip", "vault sealed")
return None
def _validate_oidc_settings(self) -> str | None:
if not settings.vault_oidc_discovery_url:
return "oidc discovery url missing"
if not settings.vault_oidc_client_id or not settings.vault_oidc_client_secret:
return "oidc client credentials missing"
return None
def _configure_oidc(self, client: VaultClient) -> None:
resp = client.request(
"POST",
"/v1/auth/oidc/config",
json={
"oidc_discovery_url": settings.vault_oidc_discovery_url,
"oidc_client_id": settings.vault_oidc_client_id,
"oidc_client_secret": settings.vault_oidc_client_secret,
"default_role": settings.vault_oidc_default_role or "admin",
},
)
resp.raise_for_status()
def _tune_oidc_listing(self, client: VaultClient) -> None:
try:
client.request(
"POST",
"/v1/sys/auth/oidc/tune",
json={"listing_visibility": "unauth"},
)
except Exception:
pass
def _oidc_context(self) -> dict[str, Any]:
scopes = settings.vault_oidc_scopes or "openid profile email groups"
scope_parts = [part for part in scopes.replace(" ", ",").split(",") if part]
scopes_csv = ",".join(dict.fromkeys(scope_parts))
return {
"scopes_csv": scopes_csv,
"redirect_uris": _split_csv(settings.vault_oidc_redirect_uris),
"bound_audiences": settings.vault_oidc_bound_audiences or settings.vault_oidc_client_id,
"bound_claims_type": settings.vault_oidc_bound_claims_type or "string",
"user_claim": settings.vault_oidc_user_claim or "preferred_username",
"groups_claim": settings.vault_oidc_groups_claim or "groups",
}
def _oidc_roles(self) -> list[tuple[str, str, str]]:
admin_group = settings.vault_oidc_admin_group or "admin"
admin_policies = settings.vault_oidc_admin_policies or "default,vault-admin"
dev_group = settings.vault_oidc_dev_group or "dev"
dev_policies = settings.vault_oidc_dev_policies or "default,dev-kv"
user_group = settings.vault_oidc_user_group or dev_group
user_policies = (
settings.vault_oidc_user_policies
or settings.vault_oidc_token_policies
or dev_policies
)
return [
("admin", admin_group, admin_policies),
("dev", dev_group, dev_policies),
("user", user_group, user_policies),
]
def _oidc_role_payload(
self,
context: dict[str, Any],
groups: str,
policies: str,
) -> dict[str, Any] | None:
group_list = _split_csv(groups)
if not group_list or not policies:
return None
return {
"user_claim": context["user_claim"],
"oidc_scopes": context["scopes_csv"],
"token_policies": policies,
"bound_audiences": context["bound_audiences"],
"bound_claims": {context["groups_claim"]: group_list},
"bound_claims_type": context["bound_claims_type"],
"groups_claim": context["groups_claim"],
"allowed_redirect_uris": context["redirect_uris"],
}
def sync_k8s_auth(self, wait: bool = True) -> dict[str, Any]:
try:
status = self._health(VaultClient(settings.vault_addr))
except Exception as exc: # noqa: BLE001
return VaultResult("error", str(exc)).__dict__
if not status.get("initialized"):
return VaultResult("skip", "vault not initialized").__dict__
if status.get("sealed"):
return VaultResult("skip", "vault sealed").__dict__
client = self._client()
self._ensure_auth_enabled(client, "kubernetes", "kubernetes")
token_reviewer_jwt = settings.vault_k8s_token_reviewer_jwt
if not token_reviewer_jwt and settings.vault_k8s_token_reviewer_jwt_file:
token_reviewer_jwt = _read_file(settings.vault_k8s_token_reviewer_jwt_file)
if not token_reviewer_jwt:
token_reviewer_jwt = _read_file("/var/run/secrets/kubernetes.io/serviceaccount/token")
k8s_host = f"https://{os.environ.get('KUBERNETES_SERVICE_HOST', 'kubernetes.default.svc')}:443"
k8s_ca = _read_file("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
resp = client.request(
"POST",
"/v1/auth/kubernetes/config",
json={
"token_reviewer_jwt": token_reviewer_jwt,
"kubernetes_host": k8s_host,
"kubernetes_ca_cert": k8s_ca,
},
)
resp.raise_for_status()
self._write_policy(client, "vault-admin", _VAULT_ADMIN_POLICY)
self._write_policy(client, "dev-kv", _DEV_KV_POLICY)
self._write_k8s_role(
client,
{
"role": "vault-admin",
"namespace": "vault",
"service_accounts": "vault-admin",
},
)
for role in _K8S_ROLES:
policy = _build_policy(role.get("read_paths", ""), role.get("write_paths", ""))
self._write_policy(client, role["role"], policy)
self._write_k8s_role(client, role)
return VaultResult("ok", "k8s auth configured").__dict__
def sync_oidc(self, wait: bool = True) -> dict[str, Any]:
status = self._vault_ready()
if status:
return status.__dict__
settings_error = self._validate_oidc_settings()
if settings_error:
return VaultResult("error", settings_error).__dict__
client = self._client()
self._ensure_auth_enabled(client, "oidc", "oidc")
self._configure_oidc(client)
self._tune_oidc_listing(client)
context = self._oidc_context()
for role_name, groups, policies in self._oidc_roles():
payload = self._oidc_role_payload(context, groups, policies)
if not payload:
continue
resp = client.request(
"POST",
f"/v1/auth/oidc/role/{role_name}",
json=payload,
)
resp.raise_for_status()
return VaultResult("ok", "oidc configured").__dict__
vault = VaultService()