merge main

This commit is contained in:
Rachid Mrad 2024-09-04 13:31:44 -04:00
commit dea71ce366
No known key found for this signature in database
21 changed files with 511 additions and 23 deletions

View file

@ -97,6 +97,7 @@ While on production (the sandbox referred to as `stable`), an existing analyst o
"username": "<UUID here>", "username": "<UUID here>",
"first_name": "", "first_name": "",
"last_name": "", "last_name": "",
"email": "",
}, },
... ...
] ]
@ -121,6 +122,7 @@ Analysts are a variant of the admin role with limited permissions. The process f
"username": "<UUID here>", "username": "<UUID here>",
"first_name": "", "first_name": "",
"last_name": "", "last_name": "",
"email": "",
}, },
... ...
] ]
@ -131,6 +133,20 @@ Analysts are a variant of the admin role with limited permissions. The process f
Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst` Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`
## Adding an email address to the email whitelist (sandboxes only)
On all non-production environments, we use an email whitelist table (called `Allowed emails`). This whitelist is not case sensitive, and it provides an inclusion for +1 emails (like example.person+1@igorville.gov). The content after the `+` can be any _digit_. The whitelist checks for the "base" email (example.person) so even if you only have the +1 email defined, an email will still be sent assuming that it follows those conventions.
To add yourself to this, you can go about it in three ways.
Permanent (all sandboxes):
1. In src/registrar/fixtures_users.py, add the "email" field to your user in either the ADMIN or STAFF table.
2. In src/registrar/fixtures_users.py, add the desired email address to the `ADDITIONAL_ALLOWED_EMAILS` list. This route is suggested for product.
Sandbox specific (wiped when the db is reset):
3. Create a new record on the `Allowed emails` table with your email address. This can be done through django admin.
More detailed instructions regarding #3 can be found [here](https://docs.google.com/document/d/1ebIz4PcUuoiT7LlVy83EAyHAk_nWPEc99neMp4QjzDs).
## Adding to CODEOWNERS (optional) ## Adding to CODEOWNERS (optional)
The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of. The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.

View file

@ -7,6 +7,7 @@ from django import forms
from django.db.models import Value, CharField, Q from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.conf import settings
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_information import DomainInformation from registrar.models.domain_information import DomainInformation
@ -1966,6 +1967,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
else: else:
obj.action_needed_reason_email = default_email obj.action_needed_reason_email = default_email
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
self._check_for_valid_email(request, obj)
# == Handle status == # # == Handle status == #
if obj.status == original_obj.status: if obj.status == original_obj.status:
# If the status hasn't changed, let the base function take care of it # If the status hasn't changed, let the base function take care of it
@ -1978,6 +1982,29 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if should_save: if should_save:
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
def _check_for_valid_email(self, request, obj):
"""Certain emails are whitelisted in non-production environments,
so we should display that information using this function.
"""
# TODO 2574: remove lines 1977-1978 (refactor as needed)
profile_flag = flag_is_active(request, "profile_feature")
if profile_flag and hasattr(obj, "creator"):
recipient = obj.creator
elif not profile_flag and hasattr(obj, "submitter"):
recipient = obj.submitter
else:
recipient = None
# Displays a warning in admin when an email cannot be sent
if recipient and recipient.email:
email = recipient.email
allowed = models.AllowedEmail.is_allowed_email(email)
error_message = f"Could not send email. The email '{email}' does not exist within the whitelist."
if not allowed:
messages.warning(request, error_message)
def _handle_status_change(self, request, obj, original_obj): def _handle_status_change(self, request, obj, original_obj):
""" """
Checks for various conditions when a status change is triggered. Checks for various conditions when a status change is triggered.
@ -3357,6 +3384,16 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
class AllowedEmailAdmin(ListHeaderAdmin):
class Meta:
model = models.AllowedEmail
list_display = ["email"]
search_fields = ["email"]
search_help_text = "Search by email."
ordering = ["email"]
admin.site.unregister(LogEntry) # Unregister the default registration admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin) admin.site.register(LogEntry, CustomLogEntryAdmin)
@ -3385,6 +3422,7 @@ admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin) admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin) admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
admin.site.register(models.AllowedEmail, AllowedEmailAdmin)
# Register our custom waffle implementations # Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin) admin.site.register(models.WaffleFlag, WaffleFlagAdmin)

View file

@ -519,7 +519,6 @@ function initializeWidgetOnList(list, parentId) {
var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea") var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea")
// Edit e-mail modal (and its confirmation button) // Edit e-mail modal (and its confirmation button)
var actionNeededEmailAlreadySentModal = document.querySelector("#email-already-sent-modal")
var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button") var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button")
// Headers and footers (which change depending on if the e-mail was sent or not) // Headers and footers (which change depending on if the e-mail was sent or not)
@ -561,11 +560,11 @@ function initializeWidgetOnList(list, parentId) {
updateActionNeededEmailDisplay(reason) updateActionNeededEmailDisplay(reason)
}); });
editEmailButton.addEventListener("click", function() { // editEmailButton.addEventListener("click", function() {
if (!checkEmailAlreadySent()) { // if (!checkEmailAlreadySent()) {
showEmail(canEdit=true) // showEmail(canEdit=true)
} // }
}); // });
confirmEditEmailButton.addEventListener("click", function() { confirmEditEmailButton.addEventListener("click", function() {
// Show editable view // Show editable view

View file

@ -546,7 +546,7 @@ button .usa-icon,
#submitRowToggle { #submitRowToggle {
color: var(--body-fg); color: var(--body-fg);
} }
.requested-domain-sticky { .submit-row-sticky {
max-width: 325px; max-width: 325px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;

View file

@ -6,6 +6,7 @@ from registrar.models import (
User, User,
UserGroup, UserGroup,
) )
from registrar.models.allowed_email import AllowedEmail
fake = Faker() fake = Faker()
@ -32,6 +33,7 @@ class UserFixture:
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf", "username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
"first_name": "Aditi", "first_name": "Aditi",
"last_name": "Green", "last_name": "Green",
"email": "aditidevelops+01@gmail.com",
}, },
{ {
"username": "be17c826-e200-4999-9389-2ded48c43691", "username": "be17c826-e200-4999-9389-2ded48c43691",
@ -42,11 +44,13 @@ class UserFixture:
"username": "5f283494-31bd-49b5-b024-a7e7cae00848", "username": "5f283494-31bd-49b5-b024-a7e7cae00848",
"first_name": "Rachid", "first_name": "Rachid",
"last_name": "Mrad", "last_name": "Mrad",
"email": "rachid.mrad@associates.cisa.dhs.gov",
}, },
{ {
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74", "username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
"first_name": "Alysia", "first_name": "Alysia",
"last_name": "Broddrick", "last_name": "Broddrick",
"email": "abroddrick@truss.works",
}, },
{ {
"username": "8f8e7293-17f7-4716-889b-1990241cbd39", "username": "8f8e7293-17f7-4716-889b-1990241cbd39",
@ -63,6 +67,7 @@ class UserFixture:
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c", "username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
"first_name": "Cameron", "first_name": "Cameron",
"last_name": "Dixon", "last_name": "Dixon",
"email": "cameron.dixon@cisa.dhs.gov",
}, },
{ {
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea", "username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
@ -83,16 +88,19 @@ class UserFixture:
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78", "username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
"first_name": "Rebecca", "first_name": "Rebecca",
"last_name": "Hsieh", "last_name": "Hsieh",
"email": "rebecca.hsieh@truss.works",
}, },
{ {
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52", "username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
"first_name": "David", "first_name": "David",
"last_name": "Kennedy", "last_name": "Kennedy",
"email": "david.kennedy@ecstech.com",
}, },
{ {
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d", "username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
"first_name": "Nicolle", "first_name": "Nicolle",
"last_name": "LeClair", "last_name": "LeClair",
"email": "nicolle.leclair@ecstech.com",
}, },
{ {
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da", "username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
@ -141,6 +149,7 @@ class UserFixture:
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54", "username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
"first_name": "Aditi-Analyst", "first_name": "Aditi-Analyst",
"last_name": "Green-Analyst", "last_name": "Green-Analyst",
"email": "aditidevelops+02@gmail.com",
}, },
{ {
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99", "username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
@ -183,6 +192,7 @@ class UserFixture:
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c", "username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
"first_name": "David-Analyst", "first_name": "David-Analyst",
"last_name": "Kennedy-Analyst", "last_name": "Kennedy-Analyst",
"email": "david.kennedy@associates.cisa.dhs.gov",
}, },
{ {
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47", "username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
@ -194,7 +204,7 @@ class UserFixture:
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3", "username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
"first_name": "Nicolle-Analyst", "first_name": "Nicolle-Analyst",
"last_name": "LeClair-Analyst", "last_name": "LeClair-Analyst",
"email": "nicolle.leclair@ecstech.com", "email": "nicolle.leclair@gmail.com",
}, },
{ {
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9", "username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
@ -240,6 +250,9 @@ class UserFixture:
}, },
] ]
# Additional emails to add to the AllowedEmail whitelist.
ADDITIONAL_ALLOWED_EMAILS: list[str] = ["davekenn4242@gmail.com", "rachid_mrad@hotmail.com"]
def load_users(cls, users, group_name, are_superusers=False): def load_users(cls, users, group_name, are_superusers=False):
logger.info(f"Going to load {len(users)} users in group {group_name}") logger.info(f"Going to load {len(users)} users in group {group_name}")
for user_data in users: for user_data in users:
@ -264,6 +277,32 @@ class UserFixture:
logger.warning(e) logger.warning(e)
logger.info(f"All users in group {group_name} loaded.") logger.info(f"All users in group {group_name} loaded.")
def load_allowed_emails(cls, users, additional_emails):
"""Populates a whitelist of allowed emails (as defined in this list)"""
logger.info(f"Going to load allowed emails for {len(users)} users")
if additional_emails:
logger.info(f"Going to load {len(additional_emails)} additional allowed emails")
# Load user emails
allowed_emails = []
for user_data in users:
user_email = user_data.get("email")
if user_email and user_email not in allowed_emails:
allowed_emails.append(AllowedEmail(email=user_email))
else:
first_name = user_data.get("first_name")
last_name = user_data.get("last_name")
logger.warning(f"Could not add email to whitelist for {first_name} {last_name}.")
# Load additional emails
allowed_emails.extend([AllowedEmail(email=email) for email in additional_emails])
if allowed_emails:
AllowedEmail.objects.bulk_create(allowed_emails)
logger.info(f"Loaded {len(allowed_emails)} allowed emails")
else:
logger.info("No allowed emails to load")
@classmethod @classmethod
def load(cls): def load(cls):
# Lumped under .atomic to ensure we don't make redundant DB calls. # Lumped under .atomic to ensure we don't make redundant DB calls.
@ -275,3 +314,7 @@ class UserFixture:
with transaction.atomic(): with transaction.atomic():
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True) cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
cls.load_users(cls, cls.STAFF, "cisa_analysts_group") cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
# Combine ADMINS and STAFF lists
all_users = cls.ADMINS + cls.STAFF
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)

View file

@ -0,0 +1,25 @@
# Generated by Django 4.2.10 on 2024-08-29 18:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0120_add_domainrequest_submission_dates"),
]
operations = [
migrations.CreateModel(
name="AllowedEmail",
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=320, unique=True)),
],
options={
"abstract": False,
},
),
]

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0079 (which populates federal agencies)
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
# in the user_group model then:
# [NOT RECOMMENDED]
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
# step 3: fake run the latest migration in the migrations list
# [RECOMMENDED]
# Alternatively:
# step 1: duplicate the migration that loads data
# step 2: docker-compose exec app ./manage.py migrate
from django.db import migrations
from registrar.models import UserGroup
from typing import Any
# For linting: RunPython expects a function reference,
# so let's give it one
def create_groups(apps, schema_editor) -> Any:
UserGroup.create_cisa_analyst_group(apps, schema_editor)
UserGroup.create_full_access_group(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0121_allowedemail"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -22,6 +22,7 @@ from .domain_group import DomainGroup
from .suborganization import Suborganization from .suborganization import Suborganization
from .senior_official import SeniorOfficial from .senior_official import SeniorOfficial
from .user_portfolio_permission import UserPortfolioPermission from .user_portfolio_permission import UserPortfolioPermission
from .allowed_email import AllowedEmail
__all__ = [ __all__ = [
@ -48,6 +49,7 @@ __all__ = [
"Suborganization", "Suborganization",
"SeniorOfficial", "SeniorOfficial",
"UserPortfolioPermission", "UserPortfolioPermission",
"AllowedEmail",
] ]
auditlog.register(Contact) auditlog.register(Contact)
@ -73,3 +75,4 @@ auditlog.register(DomainGroup)
auditlog.register(Suborganization) auditlog.register(Suborganization)
auditlog.register(SeniorOfficial) auditlog.register(SeniorOfficial)
auditlog.register(UserPortfolioPermission) auditlog.register(UserPortfolioPermission)
auditlog.register(AllowedEmail)

View file

@ -0,0 +1,52 @@
from django.db import models
from django.db.models import Q
import re
from .utility.time_stamped_model import TimeStampedModel
class AllowedEmail(TimeStampedModel):
"""
AllowedEmail is a whitelist for email addresses that we can send to
in non-production environments.
"""
email = models.EmailField(
unique=True,
null=False,
blank=False,
max_length=320,
)
@classmethod
def is_allowed_email(cls, email):
"""Given an email, check if this email exists within our AllowEmail whitelist"""
if not email:
return False
# Split the email into a local part and a domain part
local, domain = email.split("@")
# If the email exists within the whitelist, then do nothing else.
email_exists = cls.objects.filter(email__iexact=email).exists()
if email_exists:
return True
# Check if there's a '+' in the local part
if "+" in local:
base_local = local.split("+")[0]
base_email_exists = cls.objects.filter(Q(email__iexact=f"{base_local}@{domain}")).exists()
# Given an example email, such as "joe.smoe+1@igorville.com"
# The full regex statement will be: "^joe.smoe\\+\\d+@igorville.com$"
pattern = f"^{re.escape(base_local)}\\+\\d+@{re.escape(domain)}$"
return base_email_exists and re.match(pattern, email)
else:
# Edge case, the +1 record exists but the base does not,
# and the record we are checking is the base record.
pattern = f"^{re.escape(local)}\\+\\d+@{re.escape(domain)}$"
plus_email_exists = cls.objects.filter(Q(email__iregex=pattern)).exists()
return plus_email_exists
def __str__(self):
return str(self.email)

View file

@ -594,6 +594,12 @@ class DomainRequest(TimeStampedModel):
blank=True, blank=True,
) )
@classmethod
def get_statuses_that_send_emails(cls):
"""Returns a list of statuses that send an email to the user"""
excluded_statuses = [cls.DomainRequestStatus.INELIGIBLE, cls.DomainRequestStatus.IN_REVIEW]
return [status for status in cls.DomainRequestStatus if status not in excluded_statuses]
def sync_organization_type(self): def sync_organization_type(self):
""" """
Updates the organization_type (without saving) to match Updates the organization_type (without saving) to match

View file

@ -32,6 +32,8 @@
{% include "django/admin/includes/descriptions/website_description.html" %} {% include "django/admin/includes/descriptions/website_description.html" %}
{% elif opts.model_name == 'portfolioinvitation' %} {% elif opts.model_name == 'portfolioinvitation' %}
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %} {% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
{% elif opts.model_name == 'allowedemail' %}
{% include "django/admin/includes/descriptions/allowed_email_description.html" %}
{% else %} {% else %}
<p>This table does not have a description yet.</p> <p>This table does not have a description yet.</p>
{% endif %} {% endif %}

View file

@ -120,7 +120,7 @@
</button> </button>
</span> </span>
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 requested-domain-sticky float-right visible-768"> <p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
Requested domain: <strong>{{ original.requested_domain.name }}</strong> Requested domain: <strong>{{ original.requested_domain.name }}</strong>
</p> </p>
{{ block.super }} {{ block.super }}

View file

@ -0,0 +1,6 @@
<p>This table is an email allow list for <strong>non-production</strong> environments.</p>
<p>
If an email is sent out and the email does not exist within this table (or is not a subset of it),
then no email will be sent.
</p>
<p>If this table is populated in a production environment, no change will occur as it will simply be ignored.</p>

View file

@ -347,7 +347,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{{ field.contents|safe }} {{ field.contents|safe }}
</div> </div>
</details> </details>
{% elif field.field.name == "state_territory" %} {% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
<div class="flex-container margin-top-2"> <div class="flex-container margin-top-2">
<span> <span>
CISA region: CISA region:

View file

@ -1,4 +1,5 @@
{% extends 'django/admin/email_clipboard_change_form.html' %} {% extends 'django/admin/email_clipboard_change_form.html' %}
{% load custom_filters %}
{% load i18n static %} {% load i18n static %}
{% block content %} {% block content %}
@ -22,3 +23,22 @@
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %} {% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
{% block submit_buttons_bottom %}
<div class="submit-row-wrapper">
<span class="submit-row-toggle padding-1 padding-right-2 visible-desktop">
<button type="button" class="usa-button usa-button--unstyled" id="submitRowToggle">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
</svg>
<span>Hide</span>
</button>
</span>
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
Organization Name: <strong>{{ original.organization_name }}</strong>
</p>
{{ block.super }}
</div>
<span class="scroll-indicator"></span>
{% endblock %}

View file

@ -169,3 +169,8 @@ def has_contact_info(user):
return False return False
else: else:
return bool(user.title or user.email or user.phone) return bool(user.title or user.email or user.phone)
@register.filter
def model_name_lowercase(instance):
return instance.__class__.__name__.lower()

View file

@ -23,6 +23,7 @@ from registrar.models import (
Website, Website,
SeniorOfficial, SeniorOfficial,
Portfolio, Portfolio,
AllowedEmail,
) )
from .common import ( from .common import (
MockSESClient, MockSESClient,
@ -70,6 +71,8 @@ class TestDomainRequestAdmin(MockEppLib):
model=DomainRequest, model=DomainRequest,
) )
self.mock_client = MockSESClient() self.mock_client = MockSESClient()
allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")]
AllowedEmail.objects.bulk_create(allowed_emails)
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
@ -86,6 +89,7 @@ class TestDomainRequestAdmin(MockEppLib):
def tearDownClass(self): def tearDownClass(self):
super().tearDownClass() super().tearDownClass()
User.objects.all().delete() User.objects.all().delete()
AllowedEmail.objects.all().delete()
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_request_senior_official_is_alphabetically_sorted(self): def test_domain_request_senior_official_is_alphabetically_sorted(self):
@ -599,7 +603,8 @@ class TestDomainRequestAdmin(MockEppLib):
): ):
"""Helper method for the email test cases.""" """Helper method for the email test cases."""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client), ExitStack() as stack:
stack.enter_context(patch.object(messages, "warning"))
# Create a mock request # Create a mock request
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
@ -626,7 +631,8 @@ class TestDomainRequestAdmin(MockEppLib):
): ):
"""Helper method for the email test cases. """Helper method for the email test cases.
email_index is the index of the email in mock_client.""" email_index is the index of the email in mock_client."""
AllowedEmail.objects.get_or_create(email=email_address)
AllowedEmail.objects.get_or_create(email=bcc_email_address)
with less_console_noise(): with less_console_noise():
# Access the arguments passed to send_email # Access the arguments passed to send_email
call_args = self.mock_client.EMAILS_SENT call_args = self.mock_client.EMAILS_SENT
@ -1174,6 +1180,7 @@ class TestDomainRequestAdmin(MockEppLib):
with ExitStack() as stack: with ExitStack() as stack:
stack.enter_context(patch.object(messages, "error")) stack.enter_context(patch.object(messages, "error"))
stack.enter_context(patch.object(messages, "warning"))
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
self.admin.save_model(request, domain_request, None, True) self.admin.save_model(request, domain_request, None, True)
@ -1202,6 +1209,7 @@ class TestDomainRequestAdmin(MockEppLib):
with ExitStack() as stack: with ExitStack() as stack:
stack.enter_context(patch.object(messages, "error")) stack.enter_context(patch.object(messages, "error"))
stack.enter_context(patch.object(messages, "warning"))
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
@ -1253,11 +1261,13 @@ class TestDomainRequestAdmin(MockEppLib):
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Modify the domain request's property with ExitStack() as stack:
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED stack.enter_context(patch.object(messages, "warning"))
# Modify the domain request's property
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
# Use the model admin's save_model method # Use the model admin's save_model method
self.admin.save_model(request, domain_request, form=None, change=True) self.admin.save_model(request, domain_request, form=None, change=True)
# Test that approved domain exists and equals requested domain # Test that approved domain exists and equals requested domain
self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name) self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name)
@ -1717,6 +1727,8 @@ class TestDomainRequestAdmin(MockEppLib):
# Patch Domain.is_active and django.contrib.messages.error simultaneously # Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active)) stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error")) stack.enter_context(patch.object(messages, "error"))
stack.enter_context(patch.object(messages, "warning"))
stack.enter_context(patch.object(messages, "success"))
domain_request.status = another_state domain_request.status = another_state

View file

@ -2,11 +2,12 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from django.test import TestCase from django.test import TestCase, override_settings
from waffle.testutils import override_flag from waffle.testutils import override_flag
from registrar.utility import email from registrar.utility import email
from registrar.utility.email import send_templated_email from registrar.utility.email import send_templated_email
from .common import completed_domain_request from .common import completed_domain_request
from registrar.models import AllowedEmail
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from datetime import datetime from datetime import datetime
@ -14,10 +15,33 @@ import boto3_mocking # type: ignore
class TestEmails(TestCase): class TestEmails(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
allowed_emails = [
AllowedEmail(email="doesnotexist@igorville.com"),
AllowedEmail(email="testy@town.com"),
AllowedEmail(email="mayor@igorville.gov"),
AllowedEmail(email="testy2@town.com"),
AllowedEmail(email="cisaRep@igorville.gov"),
AllowedEmail(email="sender@example.com"),
AllowedEmail(email="recipient@example.com"),
]
AllowedEmail.objects.bulk_create(allowed_emails)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
AllowedEmail.objects.all().delete()
def setUp(self): def setUp(self):
self.mock_client_class = MagicMock() self.mock_client_class = MagicMock()
self.mock_client = self.mock_client_class.return_value self.mock_client = self.mock_client_class.return_value
def tearDown(self):
super().tearDown()
@boto3_mocking.patching @boto3_mocking.patching
@override_flag("disable_email_sending", active=True) @override_flag("disable_email_sending", active=True)
@less_console_noise_decorator @less_console_noise_decorator
@ -235,3 +259,59 @@ class TestEmails(TestCase):
self.assertIn("Content-Transfer-Encoding: base64", call_args["RawMessage"]["Data"]) self.assertIn("Content-Transfer-Encoding: base64", call_args["RawMessage"]["Data"])
self.assertIn("Content-Disposition: attachment;", call_args["RawMessage"]["Data"]) self.assertIn("Content-Disposition: attachment;", call_args["RawMessage"]["Data"])
self.assertNotIn("Attachment file content", call_args["RawMessage"]["Data"]) self.assertNotIn("Attachment file content", call_args["RawMessage"]["Data"])
class TestAllowedEmail(TestCase):
"""Tests our allowed email whitelist"""
def setUp(self):
self.mock_client_class = MagicMock()
self.mock_client = self.mock_client_class.return_value
self.email = "mayor@igorville.gov"
self.email_2 = "cake@igorville.gov"
self.plus_email = "mayor+1@igorville.gov"
self.invalid_plus_email = "1+mayor@igorville.gov"
def tearDown(self):
super().tearDown()
AllowedEmail.objects.all().delete()
@boto3_mocking.patching
@override_settings(IS_PRODUCTION=True)
@less_console_noise_decorator
def test_email_whitelist_disabled_in_production(self):
"""Tests if the whitelist is disabled in production"""
# Ensure that the given email isn't in the whitelist
is_in_whitelist = AllowedEmail.objects.filter(email=self.email).exists()
self.assertFalse(is_in_whitelist)
# The submit should work as normal
domain_request = completed_domain_request(has_anything_else=False)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Anything else", body)
# spacing should be right between adjacent elements
self.assertRegex(body, r"5557\n\n----")
@boto3_mocking.patching
@override_settings(IS_PRODUCTION=False)
@less_console_noise_decorator
def test_email_whitelist(self):
"""Tests the email whitelist is enabled elsewhere"""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
expected_message = "Could not send email. "
"The email 'doesnotexist@igorville.com' does not exist within the whitelist."
with self.assertRaisesRegex(email.EmailSendingError, expected_message):
send_templated_email(
"test content",
"test subject",
"doesnotexist@igorville.com",
context={"domain_request": self},
bcc_address=None,
)
# Assert that an email wasn't sent
self.assertFalse(self.mock_client.send_email.called)

View file

@ -18,6 +18,7 @@ from registrar.models import (
UserDomainRole, UserDomainRole,
FederalAgency, FederalAgency,
UserPortfolioPermission, UserPortfolioPermission,
AllowedEmail,
) )
import boto3_mocking import boto3_mocking
@ -236,7 +237,7 @@ class TestDomainRequest(TestCase):
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com" self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
): ):
"""Check if an email was sent after performing an action.""" """Check if an email was sent after performing an action."""
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
with self.subTest(msg=msg, action=action): with self.subTest(msg=msg, action=action):
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Perform the specified action # Perform the specified action
@ -255,6 +256,8 @@ class TestDomainRequest(TestCase):
email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"] email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn(expected_content, email_content) self.assertIn(expected_content, email_content)
email_allowed.delete()
@override_flag("profile_feature", active=False) @override_flag("profile_feature", active=False)
@less_console_noise_decorator @less_console_noise_decorator
def test_submit_from_started_sends_email(self): def test_submit_from_started_sends_email(self):
@ -2505,3 +2508,103 @@ class TestPortfolio(TestCase):
self.assertEqual(portfolio.urbanization, "test123") self.assertEqual(portfolio.urbanization, "test123")
self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.PUERTO_RICO) self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.PUERTO_RICO)
class TestAllowedEmail(TestCase):
"""Tests our allowed email whitelist"""
@less_console_noise_decorator
def setUp(self):
self.email = "mayor@igorville.gov"
self.email_2 = "cake@igorville.gov"
self.plus_email = "mayor+1@igorville.gov"
self.invalid_plus_email = "1+mayor@igorville.gov"
def tearDown(self):
super().tearDown()
AllowedEmail.objects.all().delete()
def test_email_in_whitelist(self):
"""Test for a normal email defined in the whitelist"""
AllowedEmail.objects.create(email=self.email)
is_allowed = AllowedEmail.is_allowed_email(self.email)
self.assertTrue(is_allowed)
def test_email_not_in_whitelist(self):
"""Test for a normal email NOT defined in the whitelist"""
# Check a email not in the list
is_allowed = AllowedEmail.is_allowed_email(self.email_2)
self.assertFalse(AllowedEmail.objects.filter(email=self.email_2).exists())
self.assertFalse(is_allowed)
def test_plus_email_in_whitelist(self):
"""Test for a +1 email defined in the whitelist"""
AllowedEmail.objects.create(email=self.plus_email)
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
self.assertTrue(plus_email_allowed)
def test_plus_email_not_in_whitelist(self):
"""Test for a +1 email not defined in the whitelist"""
# This email should not be allowed.
# Checks that we do more than just a regex check on the record.
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
self.assertFalse(plus_email_allowed)
def test_plus_email_not_in_whitelist_but_base_email_is(self):
"""
Test for a +1 email NOT defined in the whitelist, but the normal one is defined.
Example:
normal (in whitelist) - joe@igorville.com
+1 email (not in whitelist) - joe+1@igorville.com
"""
AllowedEmail.objects.create(email=self.email)
base_email_allowed = AllowedEmail.is_allowed_email(self.email)
self.assertTrue(base_email_allowed)
# The plus email should also be allowed
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
self.assertTrue(plus_email_allowed)
# This email shouldn't exist in the DB
self.assertFalse(AllowedEmail.objects.filter(email=self.plus_email).exists())
def test_plus_email_in_whitelist_but_base_email_is_not(self):
"""
Test for a +1 email defined in the whitelist, but the normal is NOT defined.
Example:
normal (not in whitelist) - joe@igorville.com
+1 email (in whitelist) - joe+1@igorville.com
"""
AllowedEmail.objects.create(email=self.plus_email)
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
self.assertTrue(plus_email_allowed)
# The base email should also be allowed
base_email_allowed = AllowedEmail.is_allowed_email(self.email)
self.assertTrue(base_email_allowed)
# This email shouldn't exist in the DB
self.assertFalse(AllowedEmail.objects.filter(email=self.email).exists())
def test_invalid_regex_for_plus_email(self):
"""
Test for an invalid email that contains a '+'.
This base email should still pass, but the regex rule should not.
Our regex should only pass for emails that end with a '+'
Example:
Invalid email - 1+joe@igorville.com
Valid email: - joe+1@igorville.com
"""
AllowedEmail.objects.create(email=self.invalid_plus_email)
invalid_plus_email = AllowedEmail.is_allowed_email(self.invalid_plus_email)
# We still expect that this will pass, it exists in the db
self.assertTrue(invalid_plus_email)
# The base email SHOULD NOT pass, as it doesn't match our regex
base_email = AllowedEmail.is_allowed_email(self.email)
self.assertFalse(base_email)
# For good measure, also check the other plus email
regular_plus_email = AllowedEmail.is_allowed_email(self.plus_email)
self.assertFalse(regular_plus_email)

View file

@ -27,6 +27,7 @@ from registrar.models import (
Domain, Domain,
DomainInformation, DomainInformation,
DomainInvitation, DomainInvitation,
AllowedEmail,
Contact, Contact,
PublicContact, PublicContact,
Host, Host,
@ -349,6 +350,22 @@ class TestDomainDetail(TestDomainOverview):
class TestDomainManagers(TestDomainOverview): class TestDomainManagers(TestDomainOverview):
@classmethod
def setUpClass(cls):
super().setUpClass()
allowed_emails = [
AllowedEmail(email=""),
AllowedEmail(email="testy@town.com"),
AllowedEmail(email="mayor@igorville.gov"),
AllowedEmail(email="testy2@town.com"),
]
AllowedEmail.objects.bulk_create(allowed_emails)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
AllowedEmail.objects.all().delete()
def tearDown(self): def tearDown(self):
"""Ensure that the user has its original permissions""" """Ensure that the user has its original permissions"""
super().tearDown() super().tearDown()
@ -465,6 +482,7 @@ class TestDomainManagers(TestDomainOverview):
"""Inviting a non-existent user sends them an email.""" """Inviting a non-existent user sends them an email."""
# make sure there is no user with this email # make sure there is no user with this email
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
allowed_email, _ = AllowedEmail.objects.get_or_create(email=email_address)
User.objects.filter(email=email_address).delete() User.objects.filter(email=email_address).delete()
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
@ -484,6 +502,7 @@ class TestDomainManagers(TestDomainOverview):
Destination={"ToAddresses": [email_address]}, Destination={"ToAddresses": [email_address]},
Content=ANY, Content=ANY,
) )
allowed_email.delete()
@boto3_mocking.patching @boto3_mocking.patching
@less_console_noise_decorator @less_console_noise_decorator
@ -569,6 +588,7 @@ class TestDomainManagers(TestDomainOverview):
"""Inviting a user sends them an email, with email as the name.""" """Inviting a user sends them an email, with email as the name."""
# Create a fake user object # Create a fake user object
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
AllowedEmail.objects.get_or_create(email=email_address)
User.objects.get_or_create(email=email_address, username="fakeuser@fakeymail.com") User.objects.get_or_create(email=email_address, username="fakeuser@fakeymail.com")
# Make sure the user is staff # Make sure the user is staff

View file

@ -4,6 +4,7 @@ import boto3
import logging import logging
import textwrap import textwrap
from datetime import datetime from datetime import datetime
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.template.loader import get_template from django.template.loader import get_template
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
@ -27,7 +28,7 @@ def send_templated_email(
to_address: str, to_address: str,
bcc_address="", bcc_address="",
context={}, context={},
attachment_file: str = None, attachment_file=None,
wrap_email=False, wrap_email=False,
): ):
"""Send an email built from a template to one email address. """Send an email built from a template to one email address.
@ -39,9 +40,11 @@ def send_templated_email(
Raises EmailSendingError if SES client could not be accessed Raises EmailSendingError if SES client could not be accessed
""" """
if flag_is_active(None, "disable_email_sending") and not settings.IS_PRODUCTION: # type: ignore if not settings.IS_PRODUCTION: # type: ignore
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'." # Split into a function: C901 'send_templated_email' is too complex.
raise EmailSendingError(message) # Raises an error if we cannot send an email (due to restrictions).
# Does nothing otherwise.
_can_send_email(to_address, bcc_address)
template = get_template(template_name) template = get_template(template_name)
email_body = template.render(context=context) email_body = template.render(context=context)
@ -71,7 +74,7 @@ def send_templated_email(
destination["BccAddresses"] = [bcc_address] destination["BccAddresses"] = [bcc_address]
try: try:
if attachment_file is None: if not attachment_file:
# Wrap the email body to a maximum width of 80 characters per line. # Wrap the email body to a maximum width of 80 characters per line.
# Not all email clients support CSS to do this, and our .txt files require parsing. # Not all email clients support CSS to do this, and our .txt files require parsing.
if wrap_email: if wrap_email:
@ -102,6 +105,24 @@ def send_templated_email(
raise EmailSendingError("Could not send SES email.") from exc raise EmailSendingError("Could not send SES email.") from exc
def _can_send_email(to_address, bcc_address):
"""Raises an EmailSendingError if we cannot send an email. Does nothing otherwise."""
if flag_is_active(None, "disable_email_sending"): # type: ignore
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'."
raise EmailSendingError(message)
else:
# Raise an email sending error if these doesn't exist within our whitelist.
# If these emails don't exist, this function can handle that elsewhere.
AllowedEmail = apps.get_model("registrar", "AllowedEmail")
message = "Could not send email. The email '{}' does not exist within the whitelist."
if to_address and not AllowedEmail.is_allowed_email(to_address):
raise EmailSendingError(message.format(to_address))
if bcc_address and not AllowedEmail.is_allowed_email(bcc_address):
raise EmailSendingError(message.format(bcc_address))
def wrap_text_and_preserve_paragraphs(text, width): def wrap_text_and_preserve_paragraphs(text, width):
""" """
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'. Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.