diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 276ad95a5..6e8c4bf2b 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -133,10 +133,25 @@ def retry_send_messages_action(__, request, queryset): ) +class PasswordlessUserForm(forms.Form): + """Minimal form to create a passwordless (sub-less) User from the admin.""" + + email = forms.EmailField(label="Email", required=True) + + def clean_email(self): + """Reject emails that are already in use.""" + email = self.cleaned_data["email"] + if models.User.objects.filter(email=email).exists(): + raise forms.ValidationError("A user with this email already exists.") + return email + + @admin.register(models.User) class UserAdmin(auth_admin.UserAdmin): """Admin class for the User model""" + change_list_template = "admin/core/user/change_list.html" + fieldsets = ( ( None, @@ -213,6 +228,56 @@ class UserAdmin(auth_admin.UserAdmin): ) search_fields = ("id", "sub", "admin_email", "email", "full_name") + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "add-passwordless/", + self.admin_site.admin_view(self.add_passwordless_view), + name="core_user_add_passwordless", + ), + ] + return custom_urls + urls + + def add_passwordless_view(self, request): + """Create a passwordless (sub-less) user from a single email field. + + These users cannot authenticate locally (unusable password, no + ``admin_email``) and will be claimed on first OIDC login by + ``UserManager.get_user_by_sub_or_email``. + """ + if request.method == "POST": + form = PasswordlessUserForm(request.POST) + if form.is_valid(): + email = form.cleaned_data["email"] + user = models.User.objects.filter(email=email).first() + if user is None: + user = models.User(email=email) + user.set_unusable_password() + user.save() + messages.success( + request, + f"Passwordless user created: {user.email}", + ) + else: + messages.info( + request, + f"User already exists: {user.email}", + ) + return redirect("admin:core_user_changelist") + else: + form = PasswordlessUserForm() + + context = { + **self.admin_site.each_context(request), + "title": "Add passwordless user", + "form": form, + "opts": self.model._meta, # noqa: SLF001 + } + return TemplateResponse( + request, "admin/core/user/add_passwordless.html", context + ) + class MailDomainAccessInline(admin.TabularInline): """Inline class for the MailDomainAccess model""" diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 1e7b3f27a..028cbcaab 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1085,23 +1085,23 @@ class Meta: read_only_fields = fields # All fields are effectively read-only from this serializer's perspective -class UserField(serializers.PrimaryKeyRelatedField): - """Custom field that accepts either UUID or email address for user lookup.""" +class UserAccessWriteField(serializers.PrimaryKeyRelatedField): + """Custom field that accepts either UUID or email address for user lookup. + + When an email is provided and no matching user exists, a passwordless + "stub" user is created. The stub has no OIDC ``sub`` and will be claimed + on first OIDC login by ``UserManager.get_user_by_sub_or_email``. + """ def to_internal_value(self, data): """Convert UUID string or email to User instance.""" - if isinstance(data, str): - if "@" in data: - # It's an email address, look up the user - try: - return models.User.objects.get(email=data) - except models.User.DoesNotExist as e: - raise serializers.ValidationError( - f"No user found with email: {data}" - ) from e - else: - # It's a UUID, use the parent method - return super().to_internal_value(data) + if isinstance(data, str) and "@" in data: + user = models.User.objects.filter(email=data).first() + if user is None: + user = models.User(email=data) + user.set_unusable_password() + user.save() + return user return super().to_internal_value(data) @@ -1111,7 +1111,7 @@ class MailboxAccessWriteSerializer(serializers.ModelSerializer): """ role = IntegerChoicesField(choices_class=models.MailboxRoleChoices) - user = UserField( + user = UserAccessWriteField( queryset=models.User.objects.all(), help_text="User ID (UUID) or email address" ) @@ -1201,7 +1201,7 @@ class MaildomainAccessWriteSerializer(serializers.ModelSerializer): """ role = IntegerChoicesField(choices_class=models.MailDomainAccessRoleChoices) - user = UserField( + user = UserAccessWriteField( queryset=models.User.objects.all(), help_text="User ID (UUID) or email address" ) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 50d2c93d6..1fcaea650 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -117,6 +117,14 @@ def get_user_by_sub_or_email(self, sub, email): if not email: return None + # Always claim sub-less "stub" users (created via invite/admin) by email, + # regardless of OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION: a stub has no + # other way to ever be linked to its OIDC identity. + try: + return self.get(email=email, sub__isnull=True) + except self.model.DoesNotExist: + pass + if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION: try: return self.get(email=email) diff --git a/src/backend/core/templates/admin/core/user/add_passwordless.html b/src/backend/core/templates/admin/core/user/add_passwordless.html new file mode 100644 index 000000000..ba37f97bb --- /dev/null +++ b/src/backend/core/templates/admin/core/user/add_passwordless.html @@ -0,0 +1,35 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+

+ {% translate "Creates a user with no password and no OIDC sub. The account will be claimed automatically on first OIDC login that matches this email." %} +

+ {% for field in form %} +
+ {{ field.errors }} +
+ {{ field.label_tag }} + {{ field }} +
+
+ {% endfor %} +
+
+ + {% translate 'Cancel' %} +
+
+{% endblock %} diff --git a/src/backend/core/templates/admin/core/user/change_list.html b/src/backend/core/templates/admin/core/user/change_list.html new file mode 100644 index 000000000..f02108495 --- /dev/null +++ b/src/backend/core/templates/admin/core/user/change_list.html @@ -0,0 +1,11 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} +{{ block.super }} +
  • + + {% translate "Add passwordless user" %} + +
  • +{% endblock %} diff --git a/src/backend/core/tests/api/test_mailbox_access.py b/src/backend/core/tests/api/test_mailbox_access.py index c6bb05657..3bb859545 100644 --- a/src/backend/core/tests/api/test_mailbox_access.py +++ b/src/backend/core/tests/api/test_mailbox_access.py @@ -118,7 +118,7 @@ def fixture_access_m2d1_alpha(mailbox2_domain1, user_alpha): ) -class TestMailboxAccessViewSet: +class TestMailboxAccessViewSet: # pylint: disable=too-many-public-methods """Tests for the MailboxAccessViewSet API endpoints.""" BASE_URL_LIST_CREATE_SUFFIX = "-list" @@ -309,6 +309,32 @@ def test_admin_maildomain_mailbox_create_access_success( mailbox=mailbox1_domain1, user=user_alpha, role=MailboxRoleChoices.EDITOR ).exists() + def test_admin_maildomain_mailbox_create_access_invites_unknown_email( + self, + api_client, + domain_admin_user, + mailbox1_domain1, + ): + """POSTing an unknown email should auto-create a passwordless stub user.""" + api_client.force_authenticate(user=domain_admin_user) + + unknown_email = "invitee@example.com" + assert not models.User.objects.filter(email=unknown_email).exists() + + data = {"user": unknown_email, "role": "editor"} + response = api_client.post( + self.list_create_url(mailbox_id=mailbox1_domain1.pk), data + ) + + assert response.status_code == status.HTTP_201_CREATED + stub = models.User.objects.get(email=unknown_email) + assert stub.sub is None + assert not stub.has_usable_password() + assert response.data["user"] == stub.pk + assert models.MailboxAccess.objects.filter( + mailbox=mailbox1_domain1, user=stub, role=MailboxRoleChoices.EDITOR + ).exists() + def test_admin_maildomain_mailbox_create_access_by_mailbox_admin_for_unmanaged_mailbox_forbidden( self, api_client, mailbox1_admin_user, mailbox1_domain2, user_beta ): diff --git a/src/backend/core/tests/api/test_maildomain_access.py b/src/backend/core/tests/api/test_maildomain_access.py index f32964713..2b69c9f69 100644 --- a/src/backend/core/tests/api/test_maildomain_access.py +++ b/src/backend/core/tests/api/test_maildomain_access.py @@ -220,6 +220,31 @@ def test_admin_api_maildomain_accesses_create_access_success( role=MailDomainAccessRoleChoices.ADMIN, ).exists() + def test_admin_api_maildomain_accesses_create_invites_unknown_email( + self, api_client, super_user, maildomain_1 + ): + """POSTing an unknown email should auto-create a passwordless stub user.""" + api_client.force_authenticate(user=super_user) + + unknown_email = "domain-invitee@example.com" + assert not models.User.objects.filter(email=unknown_email).exists() + + data = {"user": unknown_email, "role": "admin"} + response = api_client.post( + self.list_create_url(maildomain_pk=maildomain_1.pk), data + ) + + assert response.status_code == status.HTTP_201_CREATED + stub = models.User.objects.get(email=unknown_email) + assert stub.sub is None + assert not stub.has_usable_password() + assert response.data["user"] == stub.pk + assert models.MailDomainAccess.objects.filter( + maildomain=maildomain_1, + user=stub, + role=MailDomainAccessRoleChoices.ADMIN, + ).exists() + def test_admin_api_maildomain_accesses_create_access_by_maildomain_admin_for_unmanaged_maildomain_forbidden( self, api_client, maildomain_1, md2_access, md2_admin_user, regular_user ): diff --git a/src/backend/core/tests/authentication/test_backends.py b/src/backend/core/tests/authentication/test_backends.py index 86f6ca45f..a81439a45 100644 --- a/src/backend/core/tests/authentication/test_backends.py +++ b/src/backend/core/tests/authentication/test_backends.py @@ -11,7 +11,7 @@ from cryptography.fernet import Fernet from lasuite.oidc_login.backends import get_oidc_refresh_token -from core import models +from core import factories, models from core.authentication.backends import OIDCAuthenticationBackend from core.factories import UserFactory @@ -151,6 +151,41 @@ def get_userinfo_mocked(*args): assert models.User.objects.count() == 1 +@override_settings( + MESSAGES_TESTDOMAIN=None, + OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False, + OIDC_ALLOW_DUPLICATE_EMAILS=False, + OIDC_CREATE_USER=True, +) +def test_authentication_getter_claims_passwordless_stub_by_email(monkeypatch): + """ + A user with no sub (an "invited" stub created via the access-grant API or + the admin) should always be claimed on first OIDC login by email, even + when OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION is disabled. Pre-existing + accesses must remain attached to the same row. + """ + + klass = OIDCAuthenticationBackend() + stub = UserFactory(sub=None, email="invitee@example.com", full_name=None) + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory(user=stub, mailbox=mailbox) + + def get_userinfo_mocked(*args): + return {"sub": "oidc-sub-xyz", "email": stub.email} + + 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.pk == stub.pk + assert user.sub == "oidc-sub-xyz" + assert models.User.objects.count() == 1 + # The pre-existing access still references the merged user. + assert models.MailboxAccess.objects.filter(user=user, mailbox=mailbox).exists() + + @override_settings(MESSAGES_TESTDOMAIN=None) def test_authentication_getter_existing_user_with_email(monkeypatch): """