309 lines
11 KiB
Python
309 lines
11 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
|
|
from .vault_policies import DEV_KV_POLICY as _DEV_KV_POLICY
|
|
from .vault_policies import K8S_ROLES as _K8S_ROLES
|
|
from .vault_policies import VAULT_ADMIN_POLICY as _VAULT_ADMIN_POLICY
|
|
|
|
|
|
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"
|
|
|
|
class VaultClient:
|
|
"""Minimal HTTP client for Vault API requests."""
|
|
|
|
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:
|
|
"""Ensure Vault is initialized, unsealed, and configured for Atlas access."""
|
|
|
|
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__
|
|
|
|
self._token = None
|
|
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,maintenance",
|
|
"service_accounts": "vault-admin,ariadne",
|
|
},
|
|
)
|
|
|
|
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__
|
|
|
|
self._token = None
|
|
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()
|