From f1c8a72f260ada88d16f19d11f417d9e7ae3b7ac Mon Sep 17 00:00:00 2001 From: BeSovereign Date: Wed, 19 Aug 2026 17:27:56 +0200 Subject: [PATCH] Enhancement: sync OIDC groups to superuser and staff roles (#13060) Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> Co-authored-by: SoleroTG Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com> --- docs/configuration.md | 18 ++ paperless.conf.example | 2 + src/paperless/settings/__init__.py | 6 + src/paperless/signals.py | 40 +++ src/paperless/tests/test_signals.py | 379 ++++++++++++++++++++++++++++ 5 files changed, 445 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 884be821c..28f3c2b8c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -776,6 +776,24 @@ system. See the corresponding Defaults to "groups" +#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP} + +: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user superuser (admin) and staff status in Paperless-ngx. If the group is not present in the claim, superuser status will be revoked upon next login. + + !!! warning + This is a direct reflection of the claim on every login, including the connecting user, with no exemption for the last remaining admin. If the group is missing or misconfigured on the identity provider side, the logged-in user will immediately lose their own superuser access. Fix the group membership or claim mapping on the identity provider to restore it. If the identity provider itself is unreachable or misconfigured and you are locked out, you can recover admin access locally with `manage.py createsuperuser`. + + Defaults to None + +#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP} + +: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user staff status in Paperless-ngx. If the group is not present in the claim and the user is not a superuser, staff status will be revoked upon next login. + + !!! warning + As with [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP), this is applied on every login unconditionally, including for the connecting user themselves. + + Defaults to None + #### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS} : A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist. diff --git a/paperless.conf.example b/paperless.conf.example index a0c406f82..e2cedf63c 100644 --- a/paperless.conf.example +++ b/paperless.conf.example @@ -34,6 +34,8 @@ PAPERLESS_SECRET_KEY=change-me #PAPERLESS_AUTO_LOGIN_USERNAME= #PAPERLESS_COOKIE_PREFIX= #PAPERLESS_ENABLE_HTTP_REMOTE_USER=false +#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP= +#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP= # OCR settings diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 55aac5a91..f833ed7f5 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -344,6 +344,12 @@ SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM: Final[str] = os.getenv( "PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM", "groups", ) +SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP: Final[str | None] = os.getenv( + "PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP", +) +SOCIAL_ACCOUNT_SYNC_STAFF_GROUP: Final[str | None] = os.getenv( + "PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP", +) HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy" diff --git a/src/paperless/signals.py b/src/paperless/signals.py index 1ed88c051..e5bd54640 100644 --- a/src/paperless/signals.py +++ b/src/paperless/signals.py @@ -38,6 +38,18 @@ def handle_social_account_updated(sender, request, sociallogin, **kwargs): """ from django.contrib.auth.models import Group + if not sociallogin.user.is_active: + # allauth looks up and updates the social account, firing this + # signal, before checking if the user is allowed to actually log + # in. Syncing groups/roles here would arm a deactivated account + # with permissions it never exercised, which would silently take + # effect if the account is later reactivated for an unrelated + # reason. + logger.debug( + f"Skipping social account sync for inactive user `{sociallogin.user}`", + ) + return + extra_data = sociallogin.account.extra_data or {} social_account_groups = extra_data.get( settings.SOCIAL_ACCOUNT_SYNC_GROUPS_CLAIM, @@ -61,3 +73,31 @@ def handle_social_account_updated(sender, request, sociallogin, **kwargs): f"Syncing groups for user `{sociallogin.user}`: {social_account_groups}", ) sociallogin.user.groups.set(groups, clear=True) + + modified_fields = [] + if settings.SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP: + is_superuser = ( + settings.SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP in social_account_groups + ) + if sociallogin.user.is_superuser != is_superuser: + sociallogin.user.is_superuser = is_superuser + modified_fields.append("is_superuser") + + if settings.SOCIAL_ACCOUNT_SYNC_STAFF_GROUP: + is_staff = ( + settings.SOCIAL_ACCOUNT_SYNC_STAFF_GROUP in social_account_groups + ) or sociallogin.user.is_superuser + if sociallogin.user.is_staff != is_staff: + sociallogin.user.is_staff = is_staff + modified_fields.append("is_staff") + elif settings.SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP: + is_staff = sociallogin.user.is_superuser or sociallogin.user.is_staff + if sociallogin.user.is_staff != is_staff: + sociallogin.user.is_staff = is_staff + modified_fields.append("is_staff") + + if modified_fields: + logger.debug( + f"Syncing roles for user `{sociallogin.user}`: superuser={sociallogin.user.is_superuser}, staff={sociallogin.user.is_staff}", + ) + sociallogin.user.save(update_fields=modified_fields) diff --git a/src/paperless/tests/test_signals.py b/src/paperless/tests/test_signals.py index 0cf087b1e..cf43f7fc2 100644 --- a/src/paperless/tests/test_signals.py +++ b/src/paperless/tests/test_signals.py @@ -163,6 +163,47 @@ class TestSyncSocialLoginGroups(TestCase): ) self.assertEqual(list(user.groups.all()), []) + @override_settings( + SOCIAL_ACCOUNT_SYNC_GROUPS=True, + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin-group", + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP="staff-group", + ) + def test_no_sync_for_inactive_user(self) -> None: + """ + GIVEN: + - Enabled group, superuser, and staff syncing + - A deactivated user with a matching social login + WHEN: + - The social login is updated via signal + THEN: + - Groups and roles are left untouched, since the login itself + would be rejected for a deactivated user anyway + """ + Group.objects.create(name="admin-group") + user = User.objects.create_user( + username="inactive_user", + is_active=False, + is_superuser=False, + is_staff=False, + ) + sociallogin = Mock( + user=user, + account=Mock( + extra_data={ + "groups": ["admin-group", "staff-group"], + }, + ), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertEqual(list(user.groups.all()), []) + self.assertFalse(user.is_superuser) + self.assertFalse(user.is_staff) + @override_settings(SOCIAL_ACCOUNT_SYNC_GROUPS=True) def test_no_groups(self) -> None: """ @@ -254,6 +295,344 @@ class TestSyncSocialLoginGroups(TestCase): self.assertEqual(list(user.groups.all()), [group]) + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin-group", + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=None, + ) + def test_sync_superuser_enabled(self) -> None: + """ + GIVEN: + - Configured superuser group sync, and user with that group + WHEN: + - Social login updated via signal + THEN: + - User becomes superuser and staff + """ + user = User.objects.create_user( + username="testuser_s_e", + is_superuser=False, + is_staff=False, + ) + sociallogin = Mock( + user=user, + account=Mock( + extra_data={ + "groups": ["admin-group"], + }, + ), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertTrue(user.is_superuser) + self.assertTrue(user.is_staff) + + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin-group", + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=None, + ) + def test_sync_superuser_disabled(self) -> None: + """ + GIVEN: + - Configured superuser group sync, and user without that group + WHEN: + - Social login updated via signal + THEN: + - User loses superuser status but preserves staff status if they had it + """ + user = User.objects.create_user( + username="testuser_s_d", + is_superuser=True, + is_staff=True, + ) + sociallogin = Mock( + user=user, + account=Mock( + extra_data={ + "groups": ["other-group"], + }, + ), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertFalse(user.is_superuser) + self.assertTrue(user.is_staff) + + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=None, + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP="staff-group", + ) + def test_sync_staff_enabled(self) -> None: + """ + GIVEN: + - Configured staff group sync, and user with that group + WHEN: + - Social login updated via signal + THEN: + - User becomes staff + """ + user = User.objects.create_user( + username="testuser_st_e", + is_superuser=False, + is_staff=False, + ) + sociallogin = Mock( + user=user, + account=Mock( + extra_data={ + "groups": ["staff-group"], + }, + ), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertTrue(user.is_staff) + self.assertFalse(user.is_superuser) + + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=None, + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP="staff-group", + ) + def test_sync_staff_disabled(self) -> None: + """ + GIVEN: + - Configured staff group sync, and user without that group + WHEN: + - Social login updated via signal + THEN: + - User loses staff status + """ + user = User.objects.create_user( + username="testuser_st_d", + is_superuser=False, + is_staff=True, + ) + sociallogin = Mock( + user=user, + account=Mock( + extra_data={ + "groups": ["other-group"], + }, + ), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertFalse(user.is_staff) + + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin-group", + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP="staff-group", + ) + def test_sync_both_groups(self) -> None: + """ + GIVEN: + - Configured both superuser and staff group sync + WHEN: + - Social login updated via signal + THEN: + - Roles are correctly assigned/revoked according to groups + """ + # Case 1: has both + user = User.objects.create_user( + username="testuser_b_1", + is_superuser=False, + is_staff=False, + ) + sociallogin = Mock( + user=user, + account=Mock(extra_data={"groups": ["admin-group", "staff-group"]}), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertTrue(user.is_superuser) + self.assertTrue(user.is_staff) + + # Case 2: has only staff + user2 = User.objects.create_user( + username="testuser_b_2", + is_superuser=True, + is_staff=True, + ) + sociallogin2 = Mock( + user=user2, + account=Mock(extra_data={"groups": ["staff-group"]}), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin2, + ) + user2.refresh_from_db() + self.assertFalse(user2.is_superuser) + self.assertTrue(user2.is_staff) + + # Case 3: has neither + user3 = User.objects.create_user( + username="testuser_b_3", + is_superuser=True, + is_staff=True, + ) + sociallogin3 = Mock( + user=user3, + account=Mock(extra_data={"groups": ["other-group"]}), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin3, + ) + user3.refresh_from_db() + self.assertFalse(user3.is_superuser) + self.assertFalse(user3.is_staff) + + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=None, + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=None, + ) + def test_no_sync_when_not_configured(self) -> None: + """ + GIVEN: + - No sync settings configured + WHEN: + - Social login updated via signal + THEN: + - Existing roles are not modified + """ + user = User.objects.create_user( + username="testuser_n_s", + is_superuser=True, + is_staff=True, + ) + sociallogin = Mock( + user=user, + account=Mock(extra_data={"groups": ["admin-group", "staff-group"]}), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertTrue(user.is_superuser) + self.assertTrue(user.is_staff) + + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin-group", + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=None, + ) + def test_sync_superuser_demotes_local_user_without_group(self) -> None: + """ + GIVEN: + - Configured superuser group sync + - User with a usable (local) password, but without the group + WHEN: + - Social login updated via signal + THEN: + - User's superuser status is demoted, matching the group claim exactly + """ + user = User.objects.create_user( + username="local_admin", + password="password123", + is_superuser=True, + is_staff=True, + ) + sociallogin = Mock( + user=user, + account=Mock(extra_data={"groups": ["other-group"]}), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertFalse(user.is_superuser) + + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP="admin-group", + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=None, + ) + def test_sync_superuser_demotes_last_admin(self) -> None: + """ + GIVEN: + - Configured superuser group sync + - User without the group, and no other active superuser exists + WHEN: + - Social login updated via signal + THEN: + - User's superuser status is demoted, even though they are the last admin + """ + user = User.objects.create_user( + username="last_admin", + is_superuser=True, + is_staff=True, + ) + user.set_unusable_password() + user.save() + + sociallogin = Mock( + user=user, + account=Mock(extra_data={"groups": ["other-group"]}), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertFalse(user.is_superuser) + + @override_settings( + SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=None, + SOCIAL_ACCOUNT_SYNC_STAFF_GROUP="staff-group", + ) + def test_sync_staff_demotes_local_user_without_group(self) -> None: + """ + GIVEN: + - Configured staff group sync + - User with a usable (local) password, but without the group + WHEN: + - Social login updated via signal + THEN: + - User's staff status is demoted, matching the group claim exactly + """ + user = User.objects.create_user( + username="local_staff", + password="password123", + is_superuser=False, + is_staff=True, + ) + sociallogin = Mock( + user=user, + account=Mock(extra_data={"groups": ["other-group"]}), + ) + handle_social_account_updated( + sender=None, + request=HttpRequest(), + sociallogin=sociallogin, + ) + user.refresh_from_db() + self.assertFalse(user.is_staff) + class TestUserGroupDeletionCleanup(TestCase): """