pdf-quiz-generator/backend/app/services/sso_roles.py
Daniel 12453ba473
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 28s
Tests / e2e (push) Failing after 34s
fix: a role is not set here while the provider holds it
Two endpoints still set a role locally — PUT /access/{id}/role and PUT
/admin/users/{id}/role. With OIDC_ROLE_CLAIM configured, my sync applies
the person's groups on every sign-in, so a role set in the app lasts
until they next sign in and is then overwritten. That is the worst kind
of setting: it appears to work, holds for an afternoon, and quietly
undoes itself.

Both now answer 409 naming where the switch actually is. With no mapping
configured they behave exactly as before, which is what a site with no
provider needs.

The two existing tests now patch is_configured explicitly rather than
inheriting whatever the container's .env happens to say — a test that
reads differently on two machines is not a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:04:16 +02:00

103 lines
4 KiB
Python

"""What a person's groups at the identity provider make them here.
Without this, everybody who arrives through SSO is a learner and somebody
promotes them by hand — which is fine for three people and wrong for three
hundred, because the list of who may edit the bank then lives in two places
and only one of them is maintained.
With it, the provider's directory is the list. A person removed from the
educators group at the provider stops being an educator here at their next
sign-in, without anybody remembering to do it.
Nothing happens unless it is configured. A blank OIDC_ROLE_CLAIM means the old
behaviour exactly: role "user", set once, never touched again.
"""
import logging
logger = logging.getLogger(__name__)
ROLES = ("admin", "moderator", "user")
def _values(raw) -> set[str]:
return {part.strip().lower() for part in str(raw or "").split(",") if part.strip()}
def _claimed(userinfo: dict, claim: str) -> set[str]:
"""The groups the provider says this person is in.
Providers disagree about the shape: a list of strings is the common one,
Entra sends app roles the same way, and some send a single space- or
comma-separated string. All three are read.
"""
raw = (userinfo or {}).get(claim)
if raw is None:
return set()
if isinstance(raw, str):
return {part.strip().lower() for part in raw.replace(",", " ").split() if part.strip()}
if isinstance(raw, (list, tuple, set)):
return {str(item).strip().lower() for item in raw if str(item).strip()}
return set()
def is_configured(settings) -> bool:
return bool(str(getattr(settings, "OIDC_ROLE_CLAIM", "") or "").strip()
and (_values(getattr(settings, "OIDC_ADMIN_GROUPS", ""))
or _values(getattr(settings, "OIDC_MODERATOR_GROUPS", ""))))
def refuse_local_role_change() -> None:
"""Stop a role being set here when the provider is the one that decides.
With group mapping configured, a role set in this app lasts until the
person next signs in and is then overwritten from their groups — a change
that appears to work, holds for an afternoon and quietly undoes itself.
Better to say where the switch actually is.
"""
from fastapi import HTTPException
from app.config import settings
if is_configured(settings):
raise HTTPException(
409,
"Roles come from your identity provider's groups on this site. "
"Change the person's group there and it applies at their next sign-in.")
def role_for(userinfo: dict, settings) -> str | None:
"""The role this person's groups earn, or None when mapping is off.
Admin beats moderator: somebody in both groups is the more privileged of
the two, never the lesser.
"""
if not is_configured(settings):
return None
groups = _claimed(userinfo, str(settings.OIDC_ROLE_CLAIM).strip())
if groups & _values(settings.OIDC_ADMIN_GROUPS):
return "admin"
if groups & _values(settings.OIDC_MODERATOR_GROUPS):
return "moderator"
return "user"
def apply(db, user, userinfo: dict, settings) -> str | None:
"""Bring a user's role into line with the provider. Returns the new role, or None.
Demotion is the point — a directory that can only add is not a directory —
but not of the last administrator. Losing every admin to a mistyped group
name locks everybody out of the settings page, including the person who
could fix the group name, so that one demotion is refused and logged.
"""
wanted = role_for(userinfo, settings)
if wanted is None or wanted == user.role:
return None
if user.role == "admin" and wanted != "admin":
from app.models.user import User
others = db.query(User.id).filter(User.role == "admin", User.id != user.id).count()
if not others:
logger.warning("SSO would demote the last admin (%s); keeping the role", user.email)
return None
logger.info("SSO role for %s: %s -> %s", user.email, user.role, wanted)
user.role = wanted
return wanted