Merge pull request #490 from cisagov/nmb/user-invitations

Invite new users to manage domains
This commit is contained in:
Neil MartinsenBurrell 2023-04-04 15:49:31 -05:00 committed by GitHub
commit f82cb06ca7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 445 additions and 55 deletions

View file

@ -49,6 +49,8 @@ class OpenIdConnectBackend(ModelBackend):
user, created = UserModel.objects.update_or_create(**args)
if created:
user = self.configure_user(user, **kwargs)
# run a newly created user's callback for a first-time login
user.first_login()
else:
try:
user = UserModel.objects.get_by_natural_key(username)

View file

@ -51,7 +51,9 @@ class MyHostAdmin(AuditedAdmin):
admin.site.register(models.User, MyUserAdmin)
admin.site.register(models.UserDomainRole, AuditedAdmin)
admin.site.register(models.Contact, AuditedAdmin)
admin.site.register(models.DomainInvitation, AuditedAdmin)
admin.site.register(models.DomainApplication, AuditedAdmin)
admin.site.register(models.Domain, AuditedAdmin)
admin.site.register(models.Host, MyHostAdmin)

View file

@ -68,6 +68,11 @@ urlpatterns = [
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
path(
"invitation/<int:pk>/delete",
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
name="invitation-delete",
),
]

View file

@ -39,6 +39,11 @@ class UserFixture:
"first_name": "Logan",
"last_name": "",
},
{
"username": "2ffe71b0-cea4-4097-8fb6-7a35b901dd70",
"first_name": "Neil",
"last_name": "Martinsen-Burrell",
},
]
@classmethod

View file

@ -2,20 +2,9 @@
from django import forms
from registrar.models import User
class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
def clean_email(self):
requested_email = self.cleaned_data["email"]
try:
User.objects.get(email=requested_email)
except User.DoesNotExist:
# TODO: send an invitation email to a non-existent user
raise forms.ValidationError("That user does not exist in this system.")
return requested_email

View file

@ -0,0 +1,51 @@
# Generated by Django 4.1.6 on 2023-03-24 16:56
from django.db import migrations, models
import django.db.models.deletion
import django_fsm # type: ignore
class Migration(migrations.Migration):
dependencies = [
("registrar", "0015_remove_domain_owners_userdomainrole_user_domains_and_more"),
]
operations = [
migrations.CreateModel(
name="DomainInvitation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("email", models.EmailField(max_length=254)),
(
"status",
django_fsm.FSMField(
choices=[("sent", "sent"), ("retrieved", "retrieved")],
default="sent",
max_length=50,
protected=True,
),
),
(
"domain",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to="registrar.domain",
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -5,6 +5,7 @@ from .domain_application import DomainApplication
from .domain import Domain
from .host_ip import HostIP
from .host import Host
from .domain_invitation import DomainInvitation
from .nameserver import Nameserver
from .user_domain_role import UserDomainRole
from .public_contact import PublicContact
@ -15,6 +16,7 @@ __all__ = [
"Contact",
"DomainApplication",
"Domain",
"DomainInvitation",
"HostIP",
"Host",
"Nameserver",
@ -27,6 +29,7 @@ __all__ = [
auditlog.register(Contact)
auditlog.register(DomainApplication)
auditlog.register(Domain)
auditlog.register(DomainInvitation)
auditlog.register(HostIP)
auditlog.register(Host)
auditlog.register(Nameserver)

View file

@ -281,3 +281,6 @@ class Domain(TimeStampedModel):
# ManyToManyField on User creates a "users" member for all of the
# users who have some role on this domain
# ForeignKey on DomainInvitation creates an "invitations" member for
# all of the invitations that have been sent for this domain

View file

@ -476,6 +476,7 @@ class DomainApplication(TimeStampedModel):
try:
send_templated_email(
"emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt",
self.submitter.email,
context={"id": self.id, "domain_name": self.requested_domain.name},
)

View file

@ -0,0 +1,73 @@
"""People are invited by email to administer domains."""
import logging
from django.contrib.auth import get_user_model
from django.db import models
from django_fsm import FSMField, transition # type: ignore
from .utility.time_stamped_model import TimeStampedModel
from .user_domain_role import UserDomainRole
logger = logging.getLogger(__name__)
class DomainInvitation(TimeStampedModel):
INVITED = "invited"
RETRIEVED = "retrieved"
email = models.EmailField(
null=False,
blank=False,
)
domain = models.ForeignKey(
"registrar.Domain",
on_delete=models.CASCADE, # delete domain, then get rid of invitations
null=False,
related_name="invitations",
)
status = FSMField(
choices=[
(INVITED, INVITED),
(RETRIEVED, RETRIEVED),
],
default=INVITED,
protected=True, # can't alter state except through transition methods!
)
def __str__(self):
return f"Invitation for {self.email} on {self.domain} is {self.status}"
@transition(field="status", source=INVITED, target=RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.
Raises:
RuntimeError if no matching user can be found.
"""
# get a user with this email address
User = get_user_model()
try:
user = User.objects.get(email=self.email)
except User.DoesNotExist:
# should not happen because a matching user should exist before
# we retrieve this invitation
raise RuntimeError(
"Cannot find the user to retrieve this domain invitation."
)
# and create a role for that user on this domain
_, created = UserDomainRole.objects.get_or_create(
user=user, domain=self.domain, role=UserDomainRole.Roles.ADMIN
)
if not created:
# something strange happened and this role already existed when
# the invitation was retrieved. Log that this occurred.
logger.warn(
"Invitation %s was retrieved for a role that already exists.", self
)

View file

@ -1,9 +1,16 @@
import logging
from django.contrib.auth.models import AbstractUser
from django.db import models
from .domain_invitation import DomainInvitation
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
logger = logging.getLogger(__name__)
class User(AbstractUser):
"""
A custom user model that performs identically to the default user model
@ -31,3 +38,23 @@ class User(AbstractUser):
return self.email
else:
return self.username
def first_login(self):
"""Callback when the user is authenticated for the very first time.
When a user first arrives on the site, we need to retrieve any domain
invitations that match their email address.
"""
for invitation in DomainInvitation.objects.filter(
email=self.email, status=DomainInvitation.INVITED
):
try:
invitation.retrieve()
invitation.save()
except RuntimeError:
# retrieving should not fail because of a missing user, but
# if it does fail, log the error so a new user can continue
# logging in
logger.warn(
"Failed to retrieve invitation %s", invitation, exc_info=True
)

View file

@ -4,13 +4,6 @@
{% block title %}Add another user{% endblock %}
{% block domain_content %}
<p><a href="{% url "domain-users" pk=domain.id %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
Back to user management
</a>
</p>
<h1>Add another user</h1>
<p>You can add another user to help manage your domain. They will need to sign

View file

@ -20,15 +20,6 @@
<div class="grid-col-9">
<main id="main-content" class="grid-container">
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
<div class="usa-alert__body">
{{ message }}
</div>
</div>
{% endfor %}
{% endif %}
<a href="{% url 'home' %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
@ -38,6 +29,17 @@
</p>
</a>
{# messages block is under the back breadcrumb link #}
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3">
<div class="usa-alert__body">
{{ message }}
</div>
</div>
{% endfor %}
{% endif %}
{% block domain_content %}
<h1 class="break-word">{{ domain.name }}</h1>

View file

@ -18,7 +18,7 @@
<tbody>
{% for permission in domain.permissions.all %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Domain name">
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Email">
{{ permission.user.email }}
</th>
<td data-label="Role">{{ permission.role|title }}</td>
@ -38,4 +38,34 @@
</svg><span class="margin-left-05">Add another user</span>
</a>
{% if domain.invitations.exists %}
<h2>Invitations</h2>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<caption class="sr-only">Domain invitations</caption>
<thead>
<tr>
<th data-sortable scope="col" role="columnheader">Email</th>
<th data-sortable scope="col" role="columnheader">Date created</th>
<th data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr>
</thead>
<tbody>
{% for invitation in domain.invitations.all %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Email">
{{ invitation.email }}
</th>
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
<td data-label="Status">{{ invitation.status|title }}</td>
<td><form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled" value="Cancel">
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %} {# domain_content #}

View file

@ -0,0 +1,6 @@
You have been invited to manage the domain {{ domain.name }} on get.gov,
the registrar for .gov domain names.
To accept your invitation, go to <{{ domain_url }}>.
You will need to log in with a Login.gov account using this email address.

View file

@ -0,0 +1 @@
You are invited to manage {{ domain.name }} on get.gov

View file

@ -0,0 +1 @@
Thank you for applying for a .gov domain

View file

@ -7,6 +7,7 @@ from registrar.models import (
User,
Website,
Domain,
DomainInvitation,
UserDomainRole,
)
from unittest import skip
@ -164,6 +165,47 @@ class TestPermissions(TestCase):
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
class TestInvitations(TestCase):
"""Test the retrieval of invitations."""
def setUp(self):
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.email = "mayor@igorville.gov"
self.invitation, _ = DomainInvitation.objects.get_or_create(
email=self.email, domain=self.domain
)
self.user, _ = User.objects.get_or_create(email=self.email)
# clean out the roles each time
UserDomainRole.objects.all().delete()
def test_retrieval_creates_role(self):
self.invitation.retrieve()
self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain))
def test_retrieve_missing_user_error(self):
# get rid of matching users
User.objects.filter(email=self.email).delete()
with self.assertRaises(RuntimeError):
self.invitation.retrieve()
def test_retrieve_existing_role_no_error(self):
# make the overlapping role
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN
)
# this is not an error but does produce a console warning
with less_console_noise():
self.invitation.retrieve()
self.assertEqual(self.invitation.status, DomainInvitation.RETRIEVED)
def test_retrieve_on_first_login(self):
"""A new user's first_login callback retrieves their invitations."""
self.user.first_login()
self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain))
@skip("Not implemented yet.")
class TestDomainApplicationLifeCycle(TestCase):
def test_application_approval(self):

View file

@ -1,4 +1,5 @@
from unittest import skip
from unittest.mock import MagicMock, ANY
from django.conf import settings
from django.test import Client, TestCase
@ -9,7 +10,15 @@ from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore
from registrar.models import DomainApplication, Domain, Contact, Website, UserDomainRole
from registrar.models import (
DomainApplication,
Domain,
DomainInvitation,
Contact,
Website,
UserDomainRole,
User,
)
from registrar.views.application import ApplicationWizard, Step
from .common import less_console_noise
@ -1128,3 +1137,87 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, "mayor@igorville.gov")
@boto3_mocking.patching
def test_domain_invitation_created(self):
"""Add user on a nonexistent email creates an invitation.
Adding a non-existent user sends an email as a side-effect, so mock
out the boto3 SES email sending here.
"""
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
add_page = self.app.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = EMAIL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, EMAIL)
self.assertContains(success_page, "Cancel") # link to cancel invitation
self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists())
@boto3_mocking.patching
def test_domain_invitation_email_sent(self):
"""Inviting a non-existent user sends them an email."""
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = EMAIL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
# check the mock instance to see if `send_email` was called right
mock_client_instance.send_email.assert_called_once_with(
FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
Destination={"ToAddresses": [EMAIL]},
Content=ANY,
)
def test_domain_invitation_cancel(self):
"""Posting to the delete view deletes an invitation."""
EMAIL = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(
domain=self.domain, email=EMAIL
)
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id)
@boto3_mocking.patching
def test_domain_invitation_flow(self):
"""Send an invitation to a new user, log in and load the dashboard."""
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
add_page = self.app.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = EMAIL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
# user was invited, create them
new_user = User.objects.create(username=EMAIL, email=EMAIL)
# log them in to `self.app`
self.app.set_user(new_user.username)
# and manually call the first login callback
new_user.first_login()
# Now load the home page and make sure our domain appears there
home_page = self.app.get(reverse("home"))
self.assertContains(home_page, self.domain.name)

View file

@ -13,16 +13,22 @@ class EmailSendingError(RuntimeError):
pass
def send_templated_email(template_name: str, to_address: str, context={}):
def send_templated_email(
template_name: str, subject_template_name: str, to_address: str, context={}
):
"""Send an email built from a template to one email address.
template_name is relative to the same template context as Django's HTML
templates. context gives additional information that the template may use.
template_name and subject_template_name are relative to the same template
context as Django's HTML templates. context gives additional information
that the template may use.
"""
template = get_template(template_name)
email_body = template.render(context=context)
subject_template = get_template(subject_template_name)
subject = subject_template.render(context=context)
try:
ses_client = boto3.client(
"sesv2",
@ -40,7 +46,7 @@ def send_templated_email(template_name: str, to_address: str, context={}):
Destination={"ToAddresses": [to_address]},
Content={
"Simple": {
"Subject": {"Data": "Thank you for applying for a .gov domain"},
"Subject": {"Data": subject},
"Body": {"Text": {"Data": email_body}},
},
},

View file

@ -1,5 +1,10 @@
from .application import *
from .domain import DomainView, DomainUsersView, DomainAddUserView
from .domain import (
DomainView,
DomainUsersView,
DomainAddUserView,
DomainInvitationDeleteView,
)
from .health import *
from .index import *
from .whoami import *

View file

@ -1,18 +1,25 @@
"""View for a single Domain."""
from django import forms
import logging
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import DetailView
from django.views.generic.edit import FormMixin
from django.views.generic.edit import DeleteView, FormMixin
from registrar.models import Domain, User, UserDomainRole
from registrar.models import Domain, DomainInvitation, User, UserDomainRole
from ..forms import DomainAddUserForm
from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermission
logger = logging.getLogger(__name__)
class DomainView(DomainPermission, DetailView):
"""Domain detail overview page."""
@ -31,22 +38,6 @@ class DomainUsersView(DomainPermission, DetailView):
context_object_name = "domain"
class DomainAddUserForm(DomainPermission, forms.Form):
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
def clean_email(self):
requested_email = self.cleaned_data["email"]
try:
User.objects.get(email=requested_email)
except User.DoesNotExist:
# TODO: send an invitation email to a non-existent user
raise forms.ValidationError("That user does not exist in this system.")
return requested_email
class DomainAddUserView(DomainPermission, FormMixin, DetailView):
"""Inside of a domain's user management, a form for adding users.
@ -66,16 +57,63 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
# there is a valid email address in the form
return self.form_valid(form)
else:
return self.form_invalid(form)
def _domain_abs_url(self):
"""Get an absolute URL for this domain."""
return self.request.build_absolute_uri(
reverse("domain", kwargs={"pk": self.object.id})
)
def _make_invitation(self, email_address):
"""Make a Domain invitation for this email and redirect with a message."""
invitation, created = DomainInvitation.objects.get_or_create(
email=email_address, domain=self.object
)
if not created:
# that invitation already existed
messages.warning(
self.request,
f"{email_address} has already been invited to this domain.",
)
else:
# created a new invitation in the database, so send an email
try:
send_templated_email(
"emails/domain_invitation.txt",
"emails/domain_invitation_subject.txt",
to_address=email_address,
context={
"domain_url": self._domain_abs_url(),
"domain": self.object,
},
)
except EmailSendingError:
messages.warning(self.request, "Could not send email invitation.")
logger.warn(
"Could not sent email invitation to %s for domain %s",
email_address,
self.object,
exc_info=True,
)
else:
messages.success(
self.request, f"Invited {email_address} to this domain."
)
return redirect(self.get_success_url())
def form_valid(self, form):
"""Add the specified user on this domain."""
requested_email = form.cleaned_data["email"]
# look up a user with that email
# they should exist because we checked in clean_email
requested_user = User.objects.get(email=requested_email)
try:
requested_user = User.objects.get(email=requested_email)
except User.DoesNotExist:
# no matching user, go make an invitation
return self._make_invitation(requested_email)
try:
UserDomainRole.objects.create(
@ -87,3 +125,14 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
messages.success(self.request, f"Added user {requested_email}.")
return redirect(self.get_success_url())
class DomainInvitationDeleteView(SuccessMessageMixin, DeleteView):
model = DomainInvitation
object: DomainInvitation # workaround for type mismatch in DeleteView
def get_success_url(self):
return reverse("domain-users", kwargs={"pk": self.object.domain.id})
def get_success_message(self, cleaned_data):
return f"Successfully canceled invitation for {self.object.email}."

View file

@ -51,6 +51,7 @@
10038 OUTOFSCOPE http://app:8080/(robots.txt|sitemap.xml|TODO|edit/)
10038 OUTOFSCOPE http://app:8080/users
10038 OUTOFSCOPE http://app:8080/users/add
10038 OUTOFSCOPE http://app:8080/delete
# This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers