-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2659 from cisagov/za/2597-block-email-sending
Ticket #2597: Add an email whitelist for non-production environments
- Loading branch information
Showing
15 changed files
with
478 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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": "", | ||
}, | ||
... | ||
] | ||
|
@@ -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": "", | ||
}, | ||
... | ||
] | ||
|
@@ -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. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,6 +6,7 @@ | |
User, | ||
UserGroup, | ||
) | ||
from registrar.models.allowed_email import AllowedEmail | ||
|
||
|
||
fake = Faker() | ||
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -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: | ||
|
@@ -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. | ||
|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.