From 9971bb100758dcc615550c966415e85de8091311 Mon Sep 17 00:00:00 2001 From: Arthur Dodin Date: Wed, 25 Mar 2026 10:15:22 +0100 Subject: [PATCH] feat: OIDC groups mapping --- .env.example | 5 ++- auth/auth_backends.py | 84 +++++++++++++++++++++++++++++++++++++++++++ bde/env.py | 12 +++++++ bde/settings.py | 3 ++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 4b6958a..c5d7430 100644 --- a/.env.example +++ b/.env.example @@ -21,4 +21,7 @@ OIDC_OP_JWKS_ENDPOINT= OIDC_RP_CLIENT_ID= OIDC_RP_CLIENT_SECRET= OIDC_RP_SIGN_ALGO= # example: RS256 -OIDC_RP_SCOPES= # example: openid email profile \ No newline at end of file +OIDC_RP_SCOPES= # example: openid email profile +OIDC_SUPERUSER_GROUP= # OIDC group giving Django staff+superuser +OIDC_EDITOR_GROUP= # OIDC group granting staff + editor Django group +OIDC_EDITOR_DJANGO_GROUP= # Django group name (example: Editeur BDE) \ No newline at end of file diff --git a/auth/auth_backends.py b/auth/auth_backends.py index 9e9ef82..bd2b221 100644 --- a/auth/auth_backends.py +++ b/auth/auth_backends.py @@ -1,29 +1,113 @@ +# You're lucky, I added some comments to this file to explain the custom logic. +# Maybe because it's not trivial (that's the least we can say...) +# So, have a good read ! +# Arthur + +# Some imports (trivial for now) +import logging + +from django.conf import settings +from django.contrib.auth.models import Group from mozilla_django_oidc.auth import ( # type: ignore[import-untyped] OIDCAuthenticationBackend, ) +logger = logging.getLogger(__name__) + + class CustomOIDCBackend(OIDCAuthenticationBackend): + + # Normalize the groups claim so downstream checks always work on a list[str]. + @staticmethod + def _extract_groups(claims): + groups = claims.get("groups", []) + if isinstance(groups, str): + return [groups] + if isinstance(groups, list): + return [str(group) for group in groups] + logger.warning("OIDC claims 'groups' has unsupported type: %s", type(groups)) + return [] + + # Synchronize Django access flags and group membership from OIDC claims. + # Rules: + # - superuser comes only from OIDC_SUPERUSER_GROUP + # - staff comes from superuser OR editor OIDC group + # - editor Django group membership mirrors editor OIDC group presence + def _sync_oidc_access(self, user, claims): + oidc_groups = self._extract_groups(claims) + + # Names are configured through environment-backed Django settings. + superuser_group = getattr(settings, "OIDC_SUPERUSER_GROUP", "") + editor_group = getattr(settings, "OIDC_EDITOR_GROUP", "") + editor_django_group_name = getattr(settings, "OIDC_EDITOR_DJANGO_GROUP", "") + + is_superuser_from_oidc = ( + bool(superuser_group) and superuser_group in oidc_groups + ) + has_editor_from_oidc = bool(editor_group) and editor_group in oidc_groups + + if not superuser_group: + logger.warning( + "OIDC_SUPERUSER_GROUP is not configured; superuser flag will be removed" + ) + + # Keep Django privilege flags aligned with OIDC source of truth. + user.is_superuser = is_superuser_from_oidc + user.is_staff = is_superuser_from_oidc or has_editor_from_oidc + + if not editor_django_group_name: + logger.warning( + "OIDC_EDITOR_DJANGO_GROUP is not configured; cannot grant editor group" + ) + return + + try: + django_editor_group = Group.objects.get(name=editor_django_group_name) + except Group.DoesNotExist: + logger.warning( + "Configured Django editor group '%s' does not exist", + editor_django_group_name, + ) + return + + if not editor_group: + logger.warning( + "OIDC_EDITOR_GROUP is not configured; removing editor Django group" + ) + + # Add or remove the editor Django group on every login to avoid drift. + if has_editor_from_oidc: + user.groups.add(django_editor_group) + else: + user.groups.remove(django_editor_group) + + # First login path: create local user, then apply profile and access sync. def create_user(self, claims): user = super().create_user(claims) name = claims.get("name", user.username) + # Sync identity fields from OIDC claims. user.username = claims.get("preferred_username", user.username) user.first_name = name.split(" ")[0] if " " in name else name user.last_name = " ".join(name.split(" ")[1:]) if " " in name else "" user.email = claims.get("email", "") + self._sync_oidc_access(user, claims) user.save() return user + # Subsequent logins: refresh profile and access rules from OIDC. def update_user(self, user, claims): name = claims.get("name", user.username) + # Sync identity fields from OIDC claims. user.username = claims.get("preferred_username", user.username) user.first_name = name.split(" ")[0] if " " in name else name user.last_name = " ".join(name.split(" ")[1:]) if " " in name else "" user.email = claims.get("email", "") + self._sync_oidc_access(user, claims) user.save() return user diff --git a/bde/env.py b/bde/env.py index 8c5f911..35c7ece 100644 --- a/bde/env.py +++ b/bde/env.py @@ -115,3 +115,15 @@ def OIDC_RP_SIGN_ALGO(self) -> str: @property def OIDC_RP_SCOPES(self) -> str: return self.get("OIDC_RP_SCOPES", "") + + @property + def OIDC_SUPERUSER_GROUP(self) -> str: + return self.get("OIDC_SUPERUSER_GROUP", "") + + @property + def OIDC_EDITOR_GROUP(self) -> str: + return self.get("OIDC_EDITOR_GROUP", "") + + @property + def OIDC_EDITOR_DJANGO_GROUP(self) -> str: + return self.get("OIDC_EDITOR_DJANGO_GROUP", "") diff --git a/bde/settings.py b/bde/settings.py index cecc4c5..b0fe7f7 100644 --- a/bde/settings.py +++ b/bde/settings.py @@ -59,6 +59,9 @@ OIDC_RP_CLIENT_SECRET = env.OIDC_RP_CLIENT_SECRET OIDC_RP_SIGN_ALGO = env.OIDC_RP_SIGN_ALGO OIDC_RP_SCOPES = env.OIDC_RP_SCOPES +OIDC_SUPERUSER_GROUP = env.OIDC_SUPERUSER_GROUP +OIDC_EDITOR_GROUP = env.OIDC_EDITOR_GROUP +OIDC_EDITOR_DJANGO_GROUP = env.OIDC_EDITOR_DJANGO_GROUP ROOT_URLCONF = "bde.urls"