Files
ServiceMap-Public/backend/app/auth.py
T

179 lines
6.5 KiB
Python

"""Optional, fail-closed OIDC bearer authorization for ServiceMap APIs."""
from __future__ import annotations
import os
from dataclasses import dataclass
from functools import lru_cache
from urllib.parse import urlparse
import httpx
import jwt
from fastapi import HTTPException, Request, status
class AuthorizationError(RuntimeError):
pass
_ROLE_ORDER = {"viewer": 1, "operator": 2, "admin": 3}
def _csv(name: str) -> set[str]:
return {item.strip().casefold() for item in os.getenv(name, "").split(",") if item.strip()}
@dataclass(frozen=True)
class AuthConfig:
mode: str
issuer: str | None = None
audience: str | None = None
role_claims: tuple[str, ...] = ("groups", "roles")
viewer_groups: frozenset[str] = frozenset()
operator_groups: frozenset[str] = frozenset()
admin_groups: frozenset[str] = frozenset()
@classmethod
def from_environment(cls) -> AuthConfig:
mode = os.getenv("SERVICEMAP_AUTH_MODE", "disabled").strip().casefold() or "disabled"
if mode == "disabled":
return cls(mode="disabled")
if mode != "oidc":
raise AuthorizationError("SERVICEMAP_AUTH_MODE must be 'disabled' or 'oidc'")
issuer = os.getenv("SERVICEMAP_OIDC_ISSUER", "").strip().rstrip("/")
audience = os.getenv("SERVICEMAP_OIDC_AUDIENCE", "").strip()
parsed = urlparse(issuer)
local_http = os.getenv("SERVICEMAP_AUTH_ALLOW_HTTP_FOR_LOCAL", "false").casefold() == "true"
if (
not issuer
or not audience
or not parsed.netloc
or (parsed.scheme != "https" and not local_http)
):
raise AuthorizationError("OIDC mode requires a valid HTTPS issuer and audience")
claims = tuple(
item.strip()
for item in os.getenv("SERVICEMAP_OIDC_ROLE_CLAIMS", "groups,roles").split(",")
if item.strip()
)
return cls(
mode=mode,
issuer=issuer,
audience=audience,
role_claims=claims or ("groups", "roles"),
viewer_groups=frozenset(_csv("SERVICEMAP_OIDC_VIEWER_GROUPS")),
operator_groups=frozenset(_csv("SERVICEMAP_OIDC_OPERATOR_GROUPS")),
admin_groups=frozenset(_csv("SERVICEMAP_OIDC_ADMIN_GROUPS")),
)
@lru_cache(maxsize=8)
def _jwks_client(issuer: str) -> jwt.PyJWKClient:
try:
document = httpx.get(f"{issuer}/.well-known/openid-configuration", timeout=5.0).json()
jwks_uri = str(document.get("jwks_uri", "")) if isinstance(document, dict) else ""
except (httpx.HTTPError, ValueError) as exc:
raise AuthorizationError("OIDC discovery is unavailable") from exc
parsed = urlparse(jwks_uri)
issuer_host = urlparse(issuer).hostname
if not parsed.netloc or parsed.scheme != "https" or parsed.hostname != issuer_host:
raise AuthorizationError("OIDC discovery returned an invalid JWKS URI")
return jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=300)
def _claim_values(claims: dict[str, object], names: tuple[str, ...]) -> set[str]:
values: set[str] = set()
for name in names:
raw = claims.get(name)
if isinstance(raw, str):
values.add(raw.casefold())
elif isinstance(raw, list):
values.update(str(item).casefold() for item in raw if isinstance(item, (str, int)))
return values
def _roles_for(claims: dict[str, object], config: AuthConfig) -> set[str]:
values = _claim_values(claims, config.role_claims)
roles: set[str] = set()
# Explicit role claims remain useful with a constrained issuer; group
# mapping gives operators a stable least-privilege model in Authentik.
roles.update(value for value in values if value in _ROLE_ORDER)
if values & config.viewer_groups:
roles.add("viewer")
if values & config.operator_groups:
roles.add("operator")
if values & config.admin_groups:
roles.add("admin")
if "admin" in roles:
roles.update({"operator", "viewer"})
elif "operator" in roles:
roles.add("viewer")
return roles
def verify_bearer(token: str, config: AuthConfig) -> set[str]:
if not config.issuer or not config.audience:
raise AuthorizationError("OIDC configuration is incomplete")
try:
signing_key = _jwks_client(config.issuer).get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=[signing_key.algorithm_name],
audience=config.audience,
issuer=config.issuer,
options={"require": ["exp", "sub", "iss", "aud"]},
)
except jwt.PyJWTError as exc:
raise AuthorizationError("Bearer token verification failed") from exc
if not isinstance(claims, dict):
raise AuthorizationError("Bearer token claims are invalid")
return _roles_for(claims, config)
def required_role(request: Request) -> str:
if request.method in {"GET", "HEAD", "OPTIONS"}:
return "viewer"
path = request.url.path
if path.startswith(
("/api/v1/config", "/api/v1/discovery", "/api/v1/docker", "/api/v1/maintenance")
):
return "admin"
return "operator"
def authorize_request(request: Request) -> None:
"""Authorize API requests; health and static content remain reachable.
Disabled mode preserves v1.5 behavior intentionally. Enabling OIDC with
incomplete configuration fails closed on the API while `/health` remains
available for recovery and deployment diagnostics.
"""
if request.url.path == "/health" or not request.url.path.startswith("/api/"):
return
try:
config = AuthConfig.from_environment()
except AuthorizationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
if config.mode == "disabled":
return
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bearer authentication is required"
)
try:
roles = verify_bearer(header.removeprefix("Bearer ").strip(), config)
except AuthorizationError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
if (
_ROLE_ORDER.get(max(roles, key=lambda item: _ROLE_ORDER[item]) if roles else "", 0)
< _ROLE_ORDER[required_role(request)]
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient ServiceMap role"
)