Skip to content

Commit 37c0c90

Browse files
committed
✨(invites) enable inviting users that haven't logged in yet
No invitation email for now
1 parent 7a0f6fb commit 37c0c90

8 files changed

Lines changed: 215 additions & 18 deletions

File tree

src/backend/core/admin.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,25 @@ def retry_send_messages_action(__, request, queryset):
133133
)
134134

135135

136+
class PasswordlessUserForm(forms.Form):
137+
"""Minimal form to create a passwordless (sub-less) User from the admin."""
138+
139+
email = forms.EmailField(label="Email", required=True)
140+
141+
def clean_email(self):
142+
"""Reject emails that are already in use."""
143+
email = self.cleaned_data["email"]
144+
if models.User.objects.filter(email=email).exists():
145+
raise forms.ValidationError("A user with this email already exists.")
146+
return email
147+
148+
136149
@admin.register(models.User)
137150
class UserAdmin(auth_admin.UserAdmin):
138151
"""Admin class for the User model"""
139152

153+
change_list_template = "admin/core/user/change_list.html"
154+
140155
fieldsets = (
141156
(
142157
None,
@@ -213,6 +228,48 @@ class UserAdmin(auth_admin.UserAdmin):
213228
)
214229
search_fields = ("id", "sub", "admin_email", "email", "full_name")
215230

231+
def get_urls(self):
232+
urls = super().get_urls()
233+
custom_urls = [
234+
path(
235+
"add-passwordless/",
236+
self.admin_site.admin_view(self.add_passwordless_view),
237+
name="core_user_add_passwordless",
238+
),
239+
]
240+
return custom_urls + urls
241+
242+
def add_passwordless_view(self, request):
243+
"""Create a passwordless (sub-less) user from a single email field.
244+
245+
These users cannot authenticate locally (unusable password, no
246+
``admin_email``) and will be claimed on first OIDC login by
247+
``UserManager.get_user_by_sub_or_email``.
248+
"""
249+
if request.method == "POST":
250+
form = PasswordlessUserForm(request.POST)
251+
if form.is_valid():
252+
user = models.User(email=form.cleaned_data["email"])
253+
user.set_unusable_password()
254+
user.save()
255+
messages.success(
256+
request,
257+
f"Passwordless user created: {user.email}",
258+
)
259+
return redirect("admin:core_user_changelist")
260+
else:
261+
form = PasswordlessUserForm()
262+
263+
context = {
264+
**self.admin_site.each_context(request),
265+
"title": "Add passwordless user",
266+
"form": form,
267+
"opts": self.model._meta, # noqa: SLF001
268+
}
269+
return TemplateResponse(
270+
request, "admin/core/user/add_passwordless.html", context
271+
)
272+
216273

217274
class MailDomainAccessInline(admin.TabularInline):
218275
"""Inline class for the MailDomainAccess model"""

src/backend/core/api/serializers.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,23 +1085,23 @@ class Meta:
10851085
read_only_fields = fields # All fields are effectively read-only from this serializer's perspective
10861086

10871087

1088-
class UserField(serializers.PrimaryKeyRelatedField):
1089-
"""Custom field that accepts either UUID or email address for user lookup."""
1088+
class UserAccessWriteField(serializers.PrimaryKeyRelatedField):
1089+
"""Custom field that accepts either UUID or email address for user lookup.
1090+
1091+
When an email is provided and no matching user exists, a passwordless
1092+
"stub" user is created. The stub has no OIDC ``sub`` and will be claimed
1093+
on first OIDC login by ``UserManager.get_user_by_sub_or_email``.
1094+
"""
10901095

10911096
def to_internal_value(self, data):
10921097
"""Convert UUID string or email to User instance."""
1093-
if isinstance(data, str):
1094-
if "@" in data:
1095-
# It's an email address, look up the user
1096-
try:
1097-
return models.User.objects.get(email=data)
1098-
except models.User.DoesNotExist as e:
1099-
raise serializers.ValidationError(
1100-
f"No user found with email: {data}"
1101-
) from e
1102-
else:
1103-
# It's a UUID, use the parent method
1104-
return super().to_internal_value(data)
1098+
if isinstance(data, str) and "@" in data:
1099+
user = models.User.objects.filter(email=data).first()
1100+
if user is None:
1101+
user = models.User(email=data)
1102+
user.set_unusable_password()
1103+
user.save()
1104+
return user
11051105
return super().to_internal_value(data)
11061106

11071107

@@ -1111,7 +1111,7 @@ class MailboxAccessWriteSerializer(serializers.ModelSerializer):
11111111
"""
11121112

11131113
role = IntegerChoicesField(choices_class=models.MailboxRoleChoices)
1114-
user = UserField(
1114+
user = UserAccessWriteField(
11151115
queryset=models.User.objects.all(), help_text="User ID (UUID) or email address"
11161116
)
11171117

@@ -1201,7 +1201,7 @@ class MaildomainAccessWriteSerializer(serializers.ModelSerializer):
12011201
"""
12021202

12031203
role = IntegerChoicesField(choices_class=models.MailDomainAccessRoleChoices)
1204-
user = UserField(
1204+
user = UserAccessWriteField(
12051205
queryset=models.User.objects.all(), help_text="User ID (UUID) or email address"
12061206
)
12071207

src/backend/core/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ def get_user_by_sub_or_email(self, sub, email):
117117
if not email:
118118
return None
119119

120+
# Always claim sub-less "stub" users (created via invite/admin) by email,
121+
# regardless of OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION: a stub has no
122+
# other way to ever be linked to its OIDC identity.
123+
try:
124+
return self.get(email=email, sub__isnull=True)
125+
except self.model.DoesNotExist:
126+
pass
127+
120128
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
121129
try:
122130
return self.get(email=email)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{% extends "admin/base_site.html" %}
2+
{% load i18n admin_urls %}
3+
4+
{% block breadcrumbs %}
5+
<div class="breadcrumbs">
6+
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
7+
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
8+
&rsaquo; <a href="{% url 'admin:core_user_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
9+
&rsaquo; {% translate "Add passwordless user" %}
10+
</div>
11+
{% endblock %}
12+
13+
{% block content %}
14+
<form method="post">
15+
{% csrf_token %}
16+
<fieldset class="module aligned">
17+
<p class="help">
18+
{% 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." %}
19+
</p>
20+
{% for field in form %}
21+
<div class="form-row{% if field.errors %} errors{% endif %}">
22+
{{ field.errors }}
23+
<div>
24+
{{ field.label_tag }}
25+
{{ field }}
26+
</div>
27+
</div>
28+
{% endfor %}
29+
</fieldset>
30+
<div class="submit-row">
31+
<input type="submit" value="{% translate 'Create' %}" class="default">
32+
<a href="{% url 'admin:core_user_changelist' %}" class="button cancel-link">{% translate 'Cancel' %}</a>
33+
</div>
34+
</form>
35+
{% endblock %}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{% extends "admin/change_list.html" %}
2+
{% load i18n admin_urls %}
3+
4+
{% block object-tools-items %}
5+
{{ block.super }}
6+
<li>
7+
<a href="{% url 'admin:core_user_add_passwordless' %}" class="addlink">
8+
{% translate "Add passwordless user" %}
9+
</a>
10+
</li>
11+
{% endblock %}

src/backend/core/tests/api/test_mailbox_access.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def fixture_access_m2d1_alpha(mailbox2_domain1, user_alpha):
118118
)
119119

120120

121-
class TestMailboxAccessViewSet:
121+
class TestMailboxAccessViewSet: # pylint: disable=too-many-public-methods
122122
"""Tests for the MailboxAccessViewSet API endpoints."""
123123

124124
BASE_URL_LIST_CREATE_SUFFIX = "-list"
@@ -309,6 +309,32 @@ def test_admin_maildomain_mailbox_create_access_success(
309309
mailbox=mailbox1_domain1, user=user_alpha, role=MailboxRoleChoices.EDITOR
310310
).exists()
311311

312+
def test_admin_maildomain_mailbox_create_access_invites_unknown_email(
313+
self,
314+
api_client,
315+
domain_admin_user,
316+
mailbox1_domain1,
317+
):
318+
"""POSTing an unknown email should auto-create a passwordless stub user."""
319+
api_client.force_authenticate(user=domain_admin_user)
320+
321+
unknown_email = "invitee@example.com"
322+
assert not models.User.objects.filter(email=unknown_email).exists()
323+
324+
data = {"user": unknown_email, "role": "editor"}
325+
response = api_client.post(
326+
self.list_create_url(mailbox_id=mailbox1_domain1.pk), data
327+
)
328+
329+
assert response.status_code == status.HTTP_201_CREATED
330+
stub = models.User.objects.get(email=unknown_email)
331+
assert stub.sub is None
332+
assert not stub.has_usable_password()
333+
assert response.data["user"] == stub.pk
334+
assert models.MailboxAccess.objects.filter(
335+
mailbox=mailbox1_domain1, user=stub, role=MailboxRoleChoices.EDITOR
336+
).exists()
337+
312338
def test_admin_maildomain_mailbox_create_access_by_mailbox_admin_for_unmanaged_mailbox_forbidden(
313339
self, api_client, mailbox1_admin_user, mailbox1_domain2, user_beta
314340
):

src/backend/core/tests/api/test_maildomain_access.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,31 @@ def test_admin_api_maildomain_accesses_create_access_success(
220220
role=MailDomainAccessRoleChoices.ADMIN,
221221
).exists()
222222

223+
def test_admin_api_maildomain_accesses_create_invites_unknown_email(
224+
self, api_client, super_user, maildomain_1
225+
):
226+
"""POSTing an unknown email should auto-create a passwordless stub user."""
227+
api_client.force_authenticate(user=super_user)
228+
229+
unknown_email = "domain-invitee@example.com"
230+
assert not models.User.objects.filter(email=unknown_email).exists()
231+
232+
data = {"user": unknown_email, "role": "admin"}
233+
response = api_client.post(
234+
self.list_create_url(maildomain_pk=maildomain_1.pk), data
235+
)
236+
237+
assert response.status_code == status.HTTP_201_CREATED
238+
stub = models.User.objects.get(email=unknown_email)
239+
assert stub.sub is None
240+
assert not stub.has_usable_password()
241+
assert response.data["user"] == stub.pk
242+
assert models.MailDomainAccess.objects.filter(
243+
maildomain=maildomain_1,
244+
user=stub,
245+
role=MailDomainAccessRoleChoices.ADMIN,
246+
).exists()
247+
223248
def test_admin_api_maildomain_accesses_create_access_by_maildomain_admin_for_unmanaged_maildomain_forbidden(
224249
self, api_client, maildomain_1, md2_access, md2_admin_user, regular_user
225250
):

src/backend/core/tests/authentication/test_backends.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from cryptography.fernet import Fernet
1212
from lasuite.oidc_login.backends import get_oidc_refresh_token
1313

14-
from core import models
14+
from core import factories, models
1515
from core.authentication.backends import OIDCAuthenticationBackend
1616
from core.factories import UserFactory
1717

@@ -151,6 +151,41 @@ def get_userinfo_mocked(*args):
151151
assert models.User.objects.count() == 1
152152

153153

154+
@override_settings(
155+
MESSAGES_TESTDOMAIN=None,
156+
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
157+
OIDC_ALLOW_DUPLICATE_EMAILS=False,
158+
OIDC_CREATE_USER=True,
159+
)
160+
def test_authentication_getter_claims_passwordless_stub_by_email(monkeypatch):
161+
"""
162+
A user with no sub (an "invited" stub created via the access-grant API or
163+
the admin) should always be claimed on first OIDC login by email, even
164+
when OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION is disabled. Pre-existing
165+
accesses must remain attached to the same row.
166+
"""
167+
168+
klass = OIDCAuthenticationBackend()
169+
stub = UserFactory(sub=None, email="invitee@example.com", full_name=None)
170+
mailbox = factories.MailboxFactory()
171+
factories.MailboxAccessFactory(user=stub, mailbox=mailbox)
172+
173+
def get_userinfo_mocked(*args):
174+
return {"sub": "oidc-sub-xyz", "email": stub.email}
175+
176+
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
177+
178+
user = klass.get_or_create_user(
179+
access_token="test-token", id_token=None, payload=None
180+
)
181+
182+
assert user.pk == stub.pk
183+
assert user.sub == "oidc-sub-xyz"
184+
assert models.User.objects.count() == 1
185+
# The pre-existing access still references the merged user.
186+
assert models.MailboxAccess.objects.filter(user=user, mailbox=mailbox).exists()
187+
188+
154189
@override_settings(MESSAGES_TESTDOMAIN=None)
155190
def test_authentication_getter_existing_user_with_email(monkeypatch):
156191
"""

0 commit comments

Comments
 (0)