557 lines
18 KiB
Python
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()
|