fix(auth_v2): require scim:write auth on all SCIM routes

The SCIM router mounted /scim/v2/* with no security dependency, so any caller
could create or delete users and groups unauthenticated. Guard the whole router
with Security(get_current_principal, scopes=["scim:write"]) per design 03 §11, so
provisioning callers authenticate with the same bearer token or API key as every
other route and the scope gates them: unauthenticated requests now 401, an
authenticated principal without scim:write gets 403 insufficient_scope.

Also document two deployment facts uncovered alongside this: uvicorn's
--proxy-headers rewrites request.client from X-Forwarded-For before this module's
trusted_proxy_cidrs check runs and silently bypasses it (install_auth docstring),
and the scheme_order precedence where HTTP precedes openIdConnect so a bearer JWT
is labeled bearer_jwt rather than oidc (both verify identically).
This commit is contained in:
Yassin Kortam 2026-06-10 18:03:08 -07:00
parent 03faccea17
commit d3878ee9a9
3 changed files with 20 additions and 2 deletions

View file

@ -84,6 +84,10 @@ class SamlConfig(BaseModel):
class AuthConfig(BaseModel):
# First-match-wins precedence. HTTP precedes OPENID_CONNECT, so a bearer JWT
# is claimed by HttpAuthenticator (auth_method=bearer_jwt) and OidcAuthenticator
# never runs; both share the same JwtVerifiers and verify identically, so this
# only changes the auth_method label. Reorder if openIdConnect labeling matters.
scheme_order: List[SecuritySchemeType] = Field(
default_factory=lambda: [
SecuritySchemeType.API_KEY,

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any, Dict, Optional, Type, TypeVar
from fastapi import APIRouter, Request, Response, status
from fastapi import APIRouter, Request, Response, Security, status
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from scim2_models import (
@ -23,6 +23,7 @@ from scim2_models import (
)
from .resolver import ProvisioningStore
from .security import get_current_principal
R = TypeVar("R", bound=Resource)
@ -63,7 +64,11 @@ def _dump(resource: Resource, ctx: Context) -> Dict[str, Any]:
def build_scim_router() -> APIRouter:
router = APIRouter(prefix="/scim/v2", tags=["scim"])
router = APIRouter(
prefix="/scim/v2",
tags=["scim"],
dependencies=[Security(get_current_principal, scopes=["scim:write"])],
)
@router.post("/Users", status_code=status.HTTP_201_CREATED)
async def create_user(request: Request) -> Response:

View file

@ -31,6 +31,15 @@ def install_auth(
mount_oidc: bool = True,
mount_saml: bool = True,
) -> AuthContext:
"""Wire the authenticators, resolver and optional routers onto the app.
Deployment requirement for trusted-proxy IP resolution: uvicorn's
``--proxy-headers`` (enabled by default) overwrites ``request.client`` from
``X-Forwarded-For`` before this module's ``trusted_proxy_cidrs`` check runs,
which silently bypasses it. Run uvicorn with ``--no-proxy-headers`` and let
this module resolve the client IP, or leave ``trusted_proxy_cidrs`` empty and
rely on uvicorn's own ``--forwarded-allow-ips``. Do not enable both.
"""
ctx = AuthContext(config, build_authenticators(config), resolver)
app.state.auth_v2 = ctx
if mount_scim: