"""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