Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8
This commit is contained in:
commit
105251b9c8
96 files changed
+23587
No files matched your search
@@ -0,0 +1,178 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in new issue
Block a user