179 lines
6.5 KiB
Python
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"
|
|
)
|