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
103 lines
4 KiB
Python
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
|