diff --git a/.env.example b/.env.example index 4420566..6ea73c4 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ OIDC_RP_CLIENT_ID= OIDC_RP_CLIENT_SECRET= OIDC_RP_SIGN_ALGO= # example: RS256 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) # Logging / debugging DJANGO_LOG_LEVEL= # example: DEBUG, INFO, WARNING, ERROR @@ -29,4 +32,4 @@ OIDC_LOG_LEVEL= # example: DEBUG, INFO GUNICORN_LOG_LEVEL= # example: debug, info, warning, error GUNICORN_WORKERS= # example: 2 GUNICORN_THREADS= # example: 2 -GUNICORN_TIMEOUT= # example: 120 \ No newline at end of file +GUNICORN_TIMEOUT= # example: 120 diff --git a/auth/auth_backends.py b/auth/auth_backends.py index 8bb5d29..499c353 100644 --- a/auth/auth_backends.py +++ b/auth/auth_backends.py @@ -1,5 +1,13 @@ +# 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, ) @@ -10,15 +18,71 @@ class CustomOIDCBackend(OIDCAuthenticationBackend): + # Normalize the groups claim so downstream checks always work on a list[str]. @staticmethod - def _claims_context(claims): - return { - "sub": claims.get("sub"), - "preferred_username": claims.get("preferred_username"), - "email": claims.get("email"), - "name": claims.get("name"), - } + 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): logger.debug("OIDC create_user start: %s", self._claims_context(claims)) try: @@ -26,11 +90,13 @@ def create_user(self, claims): name = claims.get("name", user.username) - 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", "") - user.save() + # 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() logger.info("OIDC create_user success for username=%s", user.username) return user @@ -40,6 +106,7 @@ def create_user(self, claims): ) raise + # Subsequent logins: refresh profile and access rules from OIDC. def update_user(self, user, claims): logger.debug( "OIDC update_user start for username=%s claims=%s", @@ -49,11 +116,13 @@ def update_user(self, user, claims): try: name = claims.get("name", user.username) - 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", "") - user.save() + # 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() logger.info("OIDC update_user success for username=%s", user.username) return user diff --git a/bde/env.py b/bde/env.py index 96c7eb6..1aac952 100644 --- a/bde/env.py +++ b/bde/env.py @@ -116,6 +116,18 @@ def OIDC_RP_SIGN_ALGO(self) -> str: 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", "") + @property def DJANGO_LOG_LEVEL(self) -> str: return self.get("DJANGO_LOG_LEVEL", "INFO") diff --git a/bde/settings.py b/bde/settings.py index 2718a5a..8ed54a4 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"