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