From 10ad4c81479e968392630cf9b5e5558a76e26075 Mon Sep 17 00:00:00 2001 From: Maarten Draijer Date: Tue, 7 Jul 2026 13:07:08 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20populate=20User.teams=20fr?= =?UTF-8?q?om=20a=20configurable=20OIDC=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User.teams shipped as a hardcoded empty list, so deployers had to patch the model to wire team-based access to their identity provider's groups. Add an OIDC_TEAMS_CLAIM setting: when set, that claim is stored on the user (alongside OIDC_STORE_CLAIMS) and read by User.teams. Defaults to None, preserving the previous behaviour. Companion to the existing OIDC_STORE_CLAIMS mechanism. --- CHANGELOG.md | 1 + docs/env.md | 1 + src/backend/core/authentication/backends.py | 8 +++++-- src/backend/core/models.py | 11 ++++++++-- .../tests/authentication/test_backends.py | 19 ++++++++++++++++ src/backend/core/tests/test_models_users.py | 22 +++++++++++++++++++ src/backend/drive/settings.py | 8 +++++++ 7 files changed, 66 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf354c789..84f50093e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(backend) populate `User.teams` from a configurable OIDC claim (`OIDC_TEAMS_CLAIM`) - ✨(backend) allow converting a file while it is being analyzed - ✨(frontend) add file type, contact and modification date topbar filters - ✨(frontend) add location, file type, contact and date search filters diff --git a/docs/env.md b/docs/env.md index 586170f38..6cb175540 100644 --- a/docs/env.md +++ b/docs/env.md @@ -91,6 +91,7 @@ This document lists all configurable environment variables for the Drive applica | `OIDC_STORE_ID_TOKEN` | Store OIDC ID token | `True` | | `OIDC_STORE_REFRESH_TOKEN` | Store OIDC refresh token | `False` | | `OIDC_STORE_REFRESH_TOKEN_KEY` | Key for storing OIDC refresh token | `None` | +| `OIDC_TEAMS_CLAIM` | Name of the OIDC claim holding the user's teams/groups, stored on the user and exposed through `User.teams` for team-based access. Empty keeps `User.teams` empty. | `None` | | `OIDC_USE_NONCE` | Use nonce for OIDC requests | `True` | | `OIDC_USE_PKCE` | Use PKCE when interacting with OIDC server | `False` | | `OIDC_USER_INFO` | List of OIDC user info claims | `[]` | diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 153f9a645..39a86d313 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -35,8 +35,12 @@ def get_extra_claims(self, user_info): """ # We need to add the claims that we want to store so that they are - # available in the post_get_or_create_user method. - claims_to_store = {claim: user_info.get(claim) for claim in settings.OIDC_STORE_CLAIMS} + # available in the post_get_or_create_user method. The team claim (if + # configured) is always stored so that ``User.teams`` can read it. + store_claims = list(settings.OIDC_STORE_CLAIMS) + if settings.OIDC_TEAMS_CLAIM and settings.OIDC_TEAMS_CLAIM not in store_claims: + store_claims.append(settings.OIDC_TEAMS_CLAIM) + claims_to_store = {claim: user_info.get(claim) for claim in store_claims} return { "full_name": self.compute_full_name(user_info), "short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD), diff --git a/src/backend/core/models.py b/src/backend/core/models.py index ad065b2c9..88a8c22b7 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -365,9 +365,16 @@ def send_email(self, subject, context=None, language=None): def teams(self): """ Get list of teams in which the user is, as a list of strings. - Must be cached if retrieved remotely. + + Teams are read from the OIDC claim named by ``settings.OIDC_TEAMS_CLAIM``, + stored on ``self.claims`` at login time. When the setting is unset the + user belongs to no team (the previous default). Cached per instance; + must be cached if ever retrieved remotely. """ - return [] + claim = settings.OIDC_TEAMS_CLAIM + if not claim: + return [] + return (self.claims or {}).get(claim) or [] class UserReconciliation(BaseModel): diff --git a/src/backend/core/tests/authentication/test_backends.py b/src/backend/core/tests/authentication/test_backends.py index f4c1a8a41..42056edfc 100644 --- a/src/backend/core/tests/authentication/test_backends.py +++ b/src/backend/core/tests/authentication/test_backends.py @@ -631,3 +631,22 @@ def get_userinfo_mocked(*args): # Verify the entitlement backend was called mock_get_entitlements_backend.assert_called_once() mock_entitlement_backend.can_access.assert_called_once() + + +@override_settings(OIDC_TEAMS_CLAIM="groups") +def test_authentication_stores_teams_claim(monkeypatch): + """ + When OIDC_TEAMS_CLAIM is set, the claim is stored on the user (even if absent + from OIDC_STORE_CLAIMS) and surfaced through ``User.teams``. + """ + klass = OIDCAuthenticationBackend() + + def get_userinfo_mocked(*args): + return {"sub": "123", "email": "drive@example.com", "groups": ["team-a", "team-b"]} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + user = klass.get_or_create_user(access_token="test-token", id_token=None, payload=None) + + assert user.claims.get("groups") == ["team-a", "team-b"] + assert user.teams == ["team-a", "team-b"] diff --git a/src/backend/core/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py index 1e6d4a779..83209b2a5 100644 --- a/src/backend/core/tests/test_models_users.py +++ b/src/backend/core/tests/test_models_users.py @@ -6,6 +6,7 @@ from django.core import mail from django.core.exceptions import ValidationError +from django.test.utils import override_settings import pytest @@ -20,6 +21,27 @@ def test_models_users_str(): assert str(user) == user.email +def test_models_users_teams_default_empty(): + """Without OIDC_TEAMS_CLAIM configured, a user belongs to no team.""" + user = factories.UserFactory(claims={"groups": ["team-a", "team-b"]}) + assert user.teams == [] + + +@override_settings(OIDC_TEAMS_CLAIM="groups") +def test_models_users_teams_from_claim(): + """User.teams should read the list from the configured OIDC claim.""" + user = factories.UserFactory(claims={"groups": ["team-a", "team-b"]}) + assert user.teams == ["team-a", "team-b"] + + +@override_settings(OIDC_TEAMS_CLAIM="groups") +def test_models_users_teams_claim_absent_or_null(): + """A configured-but-missing (or null) claim should yield an empty list.""" + assert factories.UserFactory(claims={}).teams == [] + assert factories.UserFactory(claims={"groups": None}).teams == [] + assert factories.UserFactory(claims=None).teams == [] + + def test_models_users_id_unique(): """The "id" field should be unique.""" user = factories.UserFactory() diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index 124cfb5f6..e01789986 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -1165,6 +1165,14 @@ class Base(Configuration): environ_prefix=None, ) + # Name of the OIDC claim holding the user's team/group memberships. When set, + # that claim is stored (see get_extra_claims) and surfaced through + # ``User.teams`` for team-based access control. Empty (default) keeps + # ``User.teams`` empty, preserving the previous behaviour. + OIDC_TEAMS_CLAIM = values.Value( + None, environ_name="OIDC_TEAMS_CLAIM", environ_prefix=None + ) + # WARNING: Enabling this setting allows multiple user accounts to share the same email # address. This may cause security issues and is not recommended for production use when # email is activated as fallback for identification (see previous setting).