Skip to content

Commit

Permalink
Merge pull request #2659 from cisagov/za/2597-block-email-sending
Browse files Browse the repository at this point in the history
Ticket #2597: Add an email whitelist for non-production environments
  • Loading branch information
zandercymatics authored Aug 30, 2024
2 parents ec56f17 + b5a6bb6 commit 54e6ce2
Show file tree
Hide file tree
Showing 15 changed files with 478 additions and 14 deletions.
16 changes: 16 additions & 0 deletions docs/developer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ While on production (the sandbox referred to as `stable`), an existing analyst o
"username": "<UUID here>",
"first_name": "",
"last_name": "",
"email": "",
},
...
]
Expand All @@ -121,6 +122,7 @@ Analysts are a variant of the admin role with limited permissions. The process f
"username": "<UUID here>",
"first_name": "",
"last_name": "",
"email": "",
},
...
]
Expand All @@ -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`

## 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 [email protected]). 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)

The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.
Expand Down
38 changes: 38 additions & 0 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from django.conf import settings
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_information import DomainInformation
Expand Down Expand Up @@ -1965,6 +1966,9 @@ def save_model(self, request, obj, form, change):
else:
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 == #
if obj.status == original_obj.status:
# If the status hasn't changed, let the base function take care of it
Expand All @@ -1977,6 +1981,29 @@ def save_model(self, request, obj, form, change):
if should_save:
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):
"""
Checks for various conditions when a status change is triggered.
Expand Down Expand Up @@ -3253,6 +3280,16 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
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.register(LogEntry, CustomLogEntryAdmin)
Expand Down Expand Up @@ -3281,6 +3318,7 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
admin.site.register(models.AllowedEmail, AllowedEmailAdmin)

# Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
Expand Down
45 changes: 44 additions & 1 deletion src/registrar/fixtures_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
User,
UserGroup,
)
from registrar.models.allowed_email import AllowedEmail


fake = Faker()
Expand All @@ -32,6 +33,7 @@ class UserFixture:
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
"first_name": "Aditi",
"last_name": "Green",
"email": "[email protected]",
},
{
"username": "be17c826-e200-4999-9389-2ded48c43691",
Expand All @@ -42,11 +44,13 @@ class UserFixture:
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
"first_name": "Rachid",
"last_name": "Mrad",
"email": "[email protected]",
},
{
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
"first_name": "Alysia",
"last_name": "Broddrick",
"email": "[email protected]",
},
{
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
Expand All @@ -63,6 +67,7 @@ class UserFixture:
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
"first_name": "Cameron",
"last_name": "Dixon",
"email": "[email protected]",
},
{
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
Expand All @@ -83,16 +88,19 @@ class UserFixture:
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
"first_name": "Rebecca",
"last_name": "Hsieh",
"email": "[email protected]",
},
{
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
"first_name": "David",
"last_name": "Kennedy",
"email": "[email protected]",
},
{
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
"first_name": "Nicolle",
"last_name": "LeClair",
"email": "[email protected]",
},
{
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
Expand Down Expand Up @@ -141,6 +149,7 @@ class UserFixture:
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
"first_name": "Aditi-Analyst",
"last_name": "Green-Analyst",
"email": "[email protected]",
},
{
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
Expand Down Expand Up @@ -183,6 +192,7 @@ class UserFixture:
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
"first_name": "David-Analyst",
"last_name": "Kennedy-Analyst",
"email": "[email protected]",
},
{
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
Expand All @@ -194,7 +204,7 @@ class UserFixture:
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
"first_name": "Nicolle-Analyst",
"last_name": "LeClair-Analyst",
"email": "nicolle.leclair@ecstech.com",
"email": "nicolle.leclair@gmail.com",
},
{
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
Expand Down Expand Up @@ -240,6 +250,9 @@ class UserFixture:
},
]

# Additional emails to add to the AllowedEmail whitelist.
ADDITIONAL_ALLOWED_EMAILS: list[str] = ["[email protected]", "[email protected]"]

def load_users(cls, users, group_name, are_superusers=False):
logger.info(f"Going to load {len(users)} users in group {group_name}")
for user_data in users:
Expand All @@ -264,6 +277,32 @@ def load_users(cls, users, group_name, are_superusers=False):
logger.warning(e)
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(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
def load(cls):
# Lumped under .atomic to ensure we don't make redundant DB calls.
Expand All @@ -275,3 +314,7 @@ def load(cls):
with transaction.atomic():
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
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)
25 changes: 25 additions & 0 deletions src/registrar/migrations/0121_allowedemail.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
37 changes: 37 additions & 0 deletions src/registrar/migrations/0122_create_groups_v16.py
Original file line number Diff line number Diff line change
@@ -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,
),
]
3 changes: 3 additions & 0 deletions src/registrar/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .suborganization import Suborganization
from .senior_official import SeniorOfficial
from .user_portfolio_permission import UserPortfolioPermission
from .allowed_email import AllowedEmail


__all__ = [
Expand All @@ -48,6 +49,7 @@
"Suborganization",
"SeniorOfficial",
"UserPortfolioPermission",
"AllowedEmail",
]

auditlog.register(Contact)
Expand All @@ -73,3 +75,4 @@
auditlog.register(Suborganization)
auditlog.register(SeniorOfficial)
auditlog.register(UserPortfolioPermission)
auditlog.register(AllowedEmail)
52 changes: 52 additions & 0 deletions src/registrar/models/allowed_email.py
Original file line number Diff line number Diff line change
@@ -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 "[email protected]"
# The full regex statement will be: "^joe.smoe\\+\\[email protected]$"
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)
6 changes: 6 additions & 0 deletions src/registrar/models/domain_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,12 @@ def get_action_needed_reason_label(cls, action_needed_reason: str):
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):
"""
Updates the organization_type (without saving) to match
Expand Down
Loading

0 comments on commit 54e6ce2

Please sign in to comment.