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
96 lines
4 KiB
Python
96 lines
4 KiB
Python
"""What the identity provider's groups make somebody here.
|
|
|
|
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
|
|
"""
|
|
import os
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
|
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from app.database import Base
|
|
from app.models.user import User
|
|
from app.services import sso_roles
|
|
|
|
|
|
def config(claim="groups", admin="", moderator=""):
|
|
return SimpleNamespace(OIDC_ROLE_CLAIM=claim, OIDC_ADMIN_GROUPS=admin,
|
|
OIDC_MODERATOR_GROUPS=moderator)
|
|
|
|
|
|
class RoleMappingTests(unittest.TestCase):
|
|
def test_off_unless_configured(self):
|
|
# The old behaviour, exactly: no claim named, no opinion about anybody.
|
|
self.assertIsNone(sso_roles.role_for({"groups": ["peds-admins"]}, config()))
|
|
self.assertIsNone(sso_roles.role_for({"groups": ["peds-admins"]},
|
|
config(claim="", admin="peds-admins")))
|
|
|
|
def test_reads_the_three_shapes_a_provider_sends(self):
|
|
cfg = config(admin="Peds-Admins")
|
|
for claim in (["peds-admins"], "peds-admins", "peds-admins other",
|
|
"peds-admins,other", ("PEDS-ADMINS",)):
|
|
self.assertEqual(sso_roles.role_for({"groups": claim}, cfg), "admin", claim)
|
|
|
|
def test_admin_beats_moderator(self):
|
|
cfg = config(admin="bosses", moderator="educators")
|
|
self.assertEqual(
|
|
sso_roles.role_for({"groups": ["educators", "bosses"]}, cfg), "admin")
|
|
|
|
def test_belonging_to_neither_is_a_learner(self):
|
|
cfg = config(admin="bosses", moderator="educators")
|
|
self.assertEqual(sso_roles.role_for({"groups": ["everyone"]}, cfg), "user")
|
|
self.assertEqual(sso_roles.role_for({}, cfg), "user")
|
|
|
|
|
|
class RoleSyncTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool)
|
|
Base.metadata.create_all(self.engine)
|
|
self.db = Session(self.engine)
|
|
|
|
def tearDown(self):
|
|
self.db.close()
|
|
self.engine.dispose()
|
|
|
|
def add(self, email, role):
|
|
user = User(name=email, email=email, hashed_password="unused", role=role)
|
|
self.db.add(user)
|
|
self.db.commit()
|
|
return user
|
|
|
|
def test_the_directory_promotes_and_demotes(self):
|
|
# Demotion is the point. A group somebody can be added to and never
|
|
# removed from is not a list anybody can rely on.
|
|
cfg = config(admin="bosses", moderator="educators")
|
|
learner = self.add("new@example.test", "user")
|
|
self.assertEqual(sso_roles.apply(self.db, learner, {"groups": ["educators"]}, cfg),
|
|
"moderator")
|
|
self.assertEqual(learner.role, "moderator")
|
|
self.assertEqual(sso_roles.apply(self.db, learner, {"groups": []}, cfg), "user")
|
|
self.assertEqual(learner.role, "user")
|
|
|
|
def test_it_will_not_demote_the_last_admin(self):
|
|
# A mistyped group name should not lock everybody out of the settings
|
|
# page, including the person who could fix the group name.
|
|
cfg = config(admin="bosses")
|
|
boss = self.add("boss@example.test", "admin")
|
|
self.assertIsNone(sso_roles.apply(self.db, boss, {"groups": ["typo"]}, cfg))
|
|
self.assertEqual(boss.role, "admin")
|
|
# With a second admin standing, the demotion goes through.
|
|
self.add("other@example.test", "admin")
|
|
self.assertEqual(sso_roles.apply(self.db, boss, {"groups": ["typo"]}, cfg), "user")
|
|
self.assertEqual(boss.role, "user")
|
|
|
|
def test_an_unconfigured_site_never_touches_a_role(self):
|
|
boss = self.add("boss@example.test", "admin")
|
|
self.assertIsNone(sso_roles.apply(self.db, boss, {"groups": []}, config()))
|
|
self.assertEqual(boss.role, "admin")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|