pdf-quiz-generator/backend/app/services/sso_roles.py
Daniel 7f36e07af0
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 33s
Tests / e2e (push) Failing after 34s
feat: SSO reads roles from the provider's groups, and needs a verified address
Everybody arriving through SSO landed as a learner and was promoted by
hand. That is fine for three people and wrong for three hundred: the
list of who may edit the bank then lives in two places and only one of
them is maintained. OIDC_ROLE_CLAIM names the claim that carries somebody's
groups ("groups" for most providers, "roles" for Entra app roles), and
OIDC_ADMIN_GROUPS / OIDC_MODERATOR_GROUPS say which values mean what.
All three shapes a provider might send are read — a list, a
space-separated string, a comma-separated one — and matched case-blind.

Applied on every sign-in, not only at creation, because a group somebody
can be added to and never removed from is not a list anybody can rely
on. The one demotion it refuses is the last administrator: a mistyped
group name should not lock everybody out of the settings page,
including the person who could fix the group name. It is logged when it
happens.

Blank OIDC_ROLE_CLAIM is the old behaviour exactly: role "user", set
once, never touched.

And an address the provider will not vouch for is not an identity. This
matches on email, so an explicit email_verified:false would hand an
existing account to whoever typed its address at a careless provider.
Refused now — only on an explicit false, since a provider that omits the
claim is not asserting anything either way.

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

84 lines
3.3 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 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