Merge pull request #1876 from cisagov/za/1701-prevent-federal-agency-modifying-AO

(Not on a sandbox) Ticket #1701: Prevent AO modification for federal and tribal
This commit is contained in:
zandercymatics 2024-03-19 08:39:55 -06:00 committed by GitHub
commit 8f8402ffc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 473 additions and 23 deletions

View file

@ -38,3 +38,18 @@ legend.float-left-tablet + button.float-right-tablet {
margin-top: 1rem;
}
}
// Custom style for disabled inputs
@media (prefers-color-scheme: light) {
.usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
background-color: #eeeeee;
color: #666666;
}
}
@media (prefers-color-scheme: dark) {
.usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
background-color: var(--body-fg);
color: var(--close-button-hover-bg);
}
}

View file

@ -126,7 +126,6 @@ in the form $setting: value,
----------------------------*/
$theme-input-line-height: 5,
/*---------------------------
# Component settings
-----------------------------

View file

@ -1,10 +1,12 @@
"""Forms for domain management."""
import logging
from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.forms import formset_factory
from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.models.utility.domain_helper import DomainHelper
from registrar.utility.errors import (
NameserverError,
NameserverErrorCodes as nsErrorCodes,
@ -23,6 +25,9 @@ from .common import (
import re
logger = logging.getLogger(__name__)
class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain."""
@ -205,6 +210,13 @@ class ContactForm(forms.ModelForm):
"required": "Enter your email address in the required format, like name@example.com."
}
self.fields["phone"].error_messages["required"] = "Enter your phone number."
self.domainInfo = None
def set_domain_info(self, domainInfo):
"""Set the domain information for the form.
The form instance is associated with the contact itself. In order to access the associated
domain information object, this needs to be set in the form by the view."""
self.domainInfo = domainInfo
class AuthorizingOfficialContactForm(ContactForm):
@ -212,7 +224,7 @@ class AuthorizingOfficialContactForm(ContactForm):
JOIN = "authorizing_official"
def __init__(self, *args, **kwargs):
def __init__(self, disable_fields=False, *args, **kwargs):
super().__init__(*args, **kwargs)
# Overriding bc phone not required in this form
@ -232,20 +244,36 @@ class AuthorizingOfficialContactForm(ContactForm):
self.fields["email"].error_messages = {
"required": "Enter an email address in the required format, like name@example.com."
}
self.domainInfo = None
def set_domain_info(self, domainInfo):
"""Set the domain information for the form.
The form instance is associated with the contact itself. In order to access the associated
domain information object, this needs to be set in the form by the view."""
self.domainInfo = domainInfo
# All fields should be disabled if the domain is federal or tribal
if disable_fields:
DomainHelper.mass_disable_fields(fields=self.fields, disable_required=True, disable_maxlength=True)
def save(self, commit=True):
"""Override the save() method of the BaseModelForm."""
"""
Override the save() method of the BaseModelForm.
Used to perform checks on the underlying domain_information object.
If this doesn't exist, we just save as normal.
"""
# If the underlying Domain doesn't have a domainInfo object,
# just let the default super handle it.
if not self.domainInfo:
return super().save()
# Determine if the domain is federal or tribal
is_federal = self.domainInfo.organization_type == DomainRequest.OrganizationChoices.FEDERAL
is_tribal = self.domainInfo.organization_type == DomainRequest.OrganizationChoices.TRIBAL
# Get the Contact object from the db for the Authorizing Official
db_ao = Contact.objects.get(id=self.instance.id)
if self.domainInfo and db_ao.has_more_than_one_join("information_authorizing_official"):
if (is_federal or is_tribal) and self.has_changed():
# This action should be blocked by the UI, as the text fields are readonly.
# If they get past this point, we forbid it this way.
# This could be malicious, so lets reserve information for the backend only.
raise ValueError("Authorizing Official cannot be modified for federal or tribal domains.")
elif db_ao.has_more_than_one_join("information_authorizing_official"):
# Handle the case where the domain information object is available and the AO Contact
# has more than one joined object.
# In this case, create a new Contact, and update the new Contact with form data.
@ -254,6 +282,7 @@ class AuthorizingOfficialContactForm(ContactForm):
self.domainInfo.authorizing_official = Contact.objects.create(**data)
self.domainInfo.save()
else:
# If all checks pass, just save normally
super().save()
@ -304,11 +333,11 @@ class DomainOrgNameAddressForm(forms.ModelForm):
},
}
widgets = {
# We need to set the required attributed for federal_agency and
# state/territory because for these fields we are creating an individual
# We need to set the required attributed for State/territory
# because for this fields we are creating an individual
# instance of the Select. For the other fields we use the for loop to set
# the class's required attribute to true.
"federal_agency": forms.Select(attrs={"required": True}, choices=DomainInformation.AGENCY_CHOICES),
"federal_agency": forms.TextInput,
"organization_name": forms.TextInput,
"address_line1": forms.TextInput,
"address_line2": forms.TextInput,
@ -334,6 +363,46 @@ class DomainOrgNameAddressForm(forms.ModelForm):
self.fields["state_territory"].widget.attrs.pop("maxlength", None)
self.fields["zipcode"].widget.attrs.pop("maxlength", None)
self.is_federal = self.instance.organization_type == DomainRequest.OrganizationChoices.FEDERAL
self.is_tribal = self.instance.organization_type == DomainRequest.OrganizationChoices.TRIBAL
field_to_disable = None
if self.is_federal:
field_to_disable = "federal_agency"
elif self.is_tribal:
field_to_disable = "organization_name"
# Disable any field that should be disabled, if applicable
if field_to_disable is not None:
DomainHelper.disable_field(self.fields[field_to_disable], disable_required=True)
def save(self, commit=True):
"""Override the save() method of the BaseModelForm."""
if self.has_changed():
# This action should be blocked by the UI, as the text fields are readonly.
# If they get past this point, we forbid it this way.
# This could be malicious, so lets reserve information for the backend only.
if self.is_federal and not self._field_unchanged("federal_agency"):
raise ValueError("federal_agency cannot be modified when the organization_type is federal")
elif self.is_tribal and not self._field_unchanged("organization_name"):
raise ValueError("organization_name cannot be modified when the organization_type is tribal")
else:
super().save()
def _field_unchanged(self, field_name) -> bool:
"""
Checks if a specified field has not changed between the old value
and the new value.
The old value is grabbed from self.initial.
The new value is grabbed from self.cleaned_data.
"""
old_value = self.initial.get(field_name, None)
new_value = self.cleaned_data.get(field_name, None)
return old_value == new_value
class DomainDnssecForm(forms.Form):
"""Form for enabling and disabling dnssec"""

View file

@ -198,6 +198,7 @@ class Domain(TimeStampedModel, DomainHelper):
is called in the validate function on the request/domain page
throws- RegistryError or InvalidDomainError"""
if not cls.string_could_be_domain(domain):
logger.warning("Not a valid domain: %s" % str(domain))
# throw invalid domain error so that it can be caught in

View file

@ -188,3 +188,33 @@ class DomainHelper:
common_fields = model_1_fields & model_2_fields
return common_fields
@staticmethod
def mass_disable_fields(fields, disable_required=False, disable_maxlength=False):
"""
Given some fields, invoke .disabled = True on them.
disable_required: bool -> invokes .required = False on each field.
disable_maxlength: bool -> pops "maxlength" from each field.
"""
for field in fields.values():
field = DomainHelper.disable_field(field, disable_required, disable_maxlength)
return fields
@staticmethod
def disable_field(field, disable_required=False, disable_maxlength=False):
"""
Given a fields, invoke .disabled = True on it.
disable_required: bool -> invokes .required = False for the field.
disable_maxlength: bool -> pops "maxlength" for the field.
"""
field.disabled = True
if disable_required:
# if a field is disabled, it can't be required
field.required = False
if disable_maxlength:
# Remove the maxlength dialog
if "maxlength" in field.widget.attrs:
field.widget.attrs.pop("maxlength", None)
return field

View file

@ -11,12 +11,28 @@
<p>Your authorizing official is a person within your organization who can
authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'domains/eligibility/#you-must-have-approval-from-an-authorizing-official-within-your-organization' %}">who can serve as an authorizing official</a>.</p>
{% if organization_type == "federal" or organization_type == "tribal" %}
<p>
The authorizing official for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% else %}
{% include "includes/required_fields.html" %}
{% endif %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% if organization_type == "federal" or organization_type == "tribal" %}
{# If all fields are disabled, add SR content #}
<div class="usa-sr-only" aria-labelledby="id_first_name" id="sr-ao-first-name">{{ form.first_name.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_last_name" id="sr-ao-last-name">{{ form.last_name.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_title" id="sr-ao-title">{{ form.title.value }}</div>
<div class="usa-sr-only" aria-labelledby="id_email" id="sr-ao-email">{{ form.email.value }}</div>
{% endif %}
{% input_with_errors form.first_name %}
{% input_with_errors form.last_name %}
@ -24,11 +40,9 @@
{% input_with_errors form.title %}
{% input_with_errors form.email %}
<button
type="submit"
class="usa-button"
>Save</button>
</form>
{% if organization_type != "federal" and organization_type != "tribal" %}
<button type="submit" class="usa-button">Save</button>
{% endif %}
</form>
{% endblock %} {# domain_content #}

View file

@ -11,6 +11,18 @@
<p>The name of your organization will be publicly listed as the domain registrant.</p>
{% if domain.domain_info.organization_type == "federal" %}
<p>
The federal agency for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% elif domain.domain_info.organization_type == "tribal" %}
<p>
Your organization name cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% endif %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">

View file

@ -1021,6 +1021,144 @@ class TestDomainAuthorizingOfficial(TestDomainOverview):
self.assertEqual("Testy2", self.domain_information.authorizing_official.first_name)
self.assertEqual(ao_pk, self.domain_information.authorizing_official.id)
def assert_all_form_fields_have_expected_values(self, form, test_cases, test_for_disabled=False):
"""
Asserts that each specified form field has the expected value and, optionally, checks if the field is disabled.
This method iterates over a list of tuples, where each
tuple contains a field name and the expected value for that field.
It uses subtests to isolate each assertion, allowing multiple field
checks within a single test method without stopping at the first failure.
Example usage:
test_cases = [
("first_name", "John"),
("last_name", "Doe"),
("email", "john.doe@example.com"),
]
self.assert_all_form_fields_have_expected_values(my_form, test_cases, test_for_disabled=True)
"""
for field_name, expected_value in test_cases:
with self.subTest(field_name=field_name, expected_value=expected_value):
# Test that each field has the value we expect
self.assertEqual(expected_value, form[field_name].value)
if test_for_disabled:
# Test for disabled on each field
self.assertTrue("disabled" in form[field_name].attrs)
def test_domain_edit_authorizing_official_federal(self):
"""Tests that no edit can occur when the underlying domain is federal"""
# Set the org type to federal
self.domain_information.organization_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.save()
# Add an AO. We can do this at the model level, just not the form level.
self.domain_information.authorizing_official = Contact(
first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov"
)
self.domain_information.authorizing_official.save()
self.domain_information.save()
ao_page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test if the form is populating data correctly
ao_form = ao_page.forms[0]
test_cases = [
("first_name", "Apple"),
("last_name", "Tester"),
("title", "CIO"),
("email", "nobody@igorville.gov"),
]
self.assert_all_form_fields_have_expected_values(ao_form, test_cases, test_for_disabled=True)
# Attempt to change data on each field. Because this domain is federal,
# this should not succeed.
ao_form["first_name"] = "Orange"
ao_form["last_name"] = "Smoothie"
ao_form["title"] = "Cat"
ao_form["email"] = "somebody@igorville.gov"
submission = ao_form.submit()
# A 302 indicates this page underwent a redirect.
self.assertEqual(submission.status_code, 302)
followed_submission = submission.follow()
# Test the returned form for data accuracy. These values should be unchanged.
new_form = followed_submission.forms[0]
self.assert_all_form_fields_have_expected_values(new_form, test_cases, test_for_disabled=True)
# refresh domain information. Test these values in the DB.
self.domain_information.refresh_from_db()
# All values should be unchanged. These are defined manually for code clarity.
self.assertEqual("Apple", self.domain_information.authorizing_official.first_name)
self.assertEqual("Tester", self.domain_information.authorizing_official.last_name)
self.assertEqual("CIO", self.domain_information.authorizing_official.title)
self.assertEqual("nobody@igorville.gov", self.domain_information.authorizing_official.email)
def test_domain_edit_authorizing_official_tribal(self):
"""Tests that no edit can occur when the underlying domain is tribal"""
# Set the org type to federal
self.domain_information.organization_type = DomainInformation.OrganizationChoices.TRIBAL
self.domain_information.save()
# Add an AO. We can do this at the model level, just not the form level.
self.domain_information.authorizing_official = Contact(
first_name="Apple", last_name="Tester", title="CIO", email="nobody@igorville.gov"
)
self.domain_information.authorizing_official.save()
self.domain_information.save()
ao_page = self.app.get(reverse("domain-authorizing-official", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test if the form is populating data correctly
ao_form = ao_page.forms[0]
test_cases = [
("first_name", "Apple"),
("last_name", "Tester"),
("title", "CIO"),
("email", "nobody@igorville.gov"),
]
self.assert_all_form_fields_have_expected_values(ao_form, test_cases, test_for_disabled=True)
# Attempt to change data on each field. Because this domain is federal,
# this should not succeed.
ao_form["first_name"] = "Orange"
ao_form["last_name"] = "Smoothie"
ao_form["title"] = "Cat"
ao_form["email"] = "somebody@igorville.gov"
submission = ao_form.submit()
# A 302 indicates this page underwent a redirect.
self.assertEqual(submission.status_code, 302)
followed_submission = submission.follow()
# Test the returned form for data accuracy. These values should be unchanged.
new_form = followed_submission.forms[0]
self.assert_all_form_fields_have_expected_values(new_form, test_cases, test_for_disabled=True)
# refresh domain information. Test these values in the DB.
self.domain_information.refresh_from_db()
# All values should be unchanged. These are defined manually for code clarity.
self.assertEqual("Apple", self.domain_information.authorizing_official.first_name)
self.assertEqual("Tester", self.domain_information.authorizing_official.last_name)
self.assertEqual("CIO", self.domain_information.authorizing_official.title)
self.assertEqual("nobody@igorville.gov", self.domain_information.authorizing_official.email)
def test_domain_edit_authorizing_official_creates_new(self):
"""When editing an authorizing official for domain information and AO IS
joined to another object"""
@ -1088,6 +1226,149 @@ class TestDomainOrganization(TestDomainOverview):
self.assertContains(success_result_page, "Not igorville")
self.assertContains(success_result_page, "Faketown")
def test_domain_org_name_address_form_tribal(self):
"""
Submitting a change to organization_name is blocked for tribal domains
"""
# Set the current domain to a tribal organization with a preset value.
# Save first, so we can test if saving is unaffected (it should be).
tribal_org_type = DomainInformation.OrganizationChoices.TRIBAL
self.domain_information.organization_type = tribal_org_type
self.domain_information.save()
try:
# Add an org name
self.domain_information.organization_name = "Town of Igorville"
self.domain_information.save()
except ValueError as err:
self.fail(f"A ValueError was caught during the test: {err}")
self.assertEqual(self.domain_information.organization_type, tribal_org_type)
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
form = org_name_page.forms[0]
# Check the value of the input field
organization_name_input = form.fields["organization_name"][0]
self.assertEqual(organization_name_input.value, "Town of Igorville")
# Check if the input field is disabled
self.assertTrue("disabled" in organization_name_input.attrs)
self.assertEqual(organization_name_input.attrs.get("disabled"), "")
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["organization_name"] = "Not igorville"
org_name_page.form["city"] = "Faketown"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Make the change. The org name should be unchanged, but city should be modifiable.
success_result_page = org_name_page.form.submit()
self.assertEqual(success_result_page.status_code, 200)
# Check for the old and new value
self.assertContains(success_result_page, "Town of Igorville")
self.assertNotContains(success_result_page, "Not igorville")
# Do another check on the form itself
form = success_result_page.forms[0]
# Check the value of the input field
organization_name_input = form.fields["organization_name"][0]
self.assertEqual(organization_name_input.value, "Town of Igorville")
# Check if the input field is disabled
self.assertTrue("disabled" in organization_name_input.attrs)
self.assertEqual(organization_name_input.attrs.get("disabled"), "")
# Check for the value we want to update
self.assertContains(success_result_page, "Faketown")
def test_domain_org_name_address_form_federal(self):
"""
Submitting a change to federal_agency is blocked for federal domains
"""
# Set the current domain to a tribal organization with a preset value.
# Save first, so we can test if saving is unaffected (it should be).
fed_org_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.organization_type = fed_org_type
self.domain_information.save()
try:
self.domain_information.federal_agency = "AMTRAK"
self.domain_information.save()
except ValueError as err:
self.fail(f"A ValueError was caught during the test: {err}")
self.assertEqual(self.domain_information.organization_type, fed_org_type)
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
form = org_name_page.forms[0]
# Check the value of the input field
agency_input = form.fields["federal_agency"][0]
self.assertEqual(agency_input.value, "AMTRAK")
# Check if the input field is disabled
self.assertTrue("disabled" in agency_input.attrs)
self.assertEqual(agency_input.attrs.get("disabled"), "")
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["federal_agency"] = "Department of State"
org_name_page.form["city"] = "Faketown"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Make the change. The agency should be unchanged, but city should be modifiable.
success_result_page = org_name_page.form.submit()
self.assertEqual(success_result_page.status_code, 200)
# Check for the old and new value
self.assertContains(success_result_page, "AMTRAK")
self.assertNotContains(success_result_page, "Department of State")
# Do another check on the form itself
form = success_result_page.forms[0]
# Check the value of the input field
organization_name_input = form.fields["federal_agency"][0]
self.assertEqual(organization_name_input.value, "AMTRAK")
# Check if the input field is disabled
self.assertTrue("disabled" in organization_name_input.attrs)
self.assertEqual(organization_name_input.attrs.get("disabled"), "")
# Check for the value we want to update
self.assertContains(success_result_page, "Faketown")
def test_federal_agency_submit_blocked(self):
"""
Submitting a change to federal_agency is blocked for federal domains
"""
# Set the current domain to a tribal organization with a preset value.
# Save first, so we can test if saving is unaffected (it should be).
federal_org_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.organization_type = federal_org_type
self.domain_information.save()
old_federal_agency_value = ("AMTRAK", "AMTRAK")
try:
# Add a federal agency. Defined as a tuple since this list may change order.
self.domain_information.federal_agency = old_federal_agency_value
self.domain_information.save()
except ValueError as err:
self.fail(f"A ValueError was caught during the test: {err}")
self.assertEqual(self.domain_information.organization_type, federal_org_type)
new_value = ("Department of State", "Department of State")
self.client.post(
reverse("domain-org-name-address", kwargs={"pk": self.domain.id}),
{
"federal_agency": new_value,
},
)
self.assertEqual(self.domain_information.federal_agency, old_federal_agency_value)
self.assertNotEqual(self.domain_information.federal_agency, new_value)
class TestDomainContactInformation(TestDomainOverview):
def test_domain_your_contact_information(self):

View file

@ -18,6 +18,8 @@ from django.conf import settings
from registrar.models import (
Domain,
DomainRequest,
DomainInformation,
DomainInvitation,
User,
UserDomainRole,
@ -134,6 +136,20 @@ class DomainFormBaseView(DomainBaseView, FormMixin):
# superclass has the redirect
return super().form_invalid(form)
def get_domain_info_from_domain(self) -> DomainInformation | None:
"""
Grabs the underlying domain_info object based off of self.object.name.
Returns None if nothing is found.
"""
_domain_info = DomainInformation.objects.filter(domain__name=self.object.name)
current_domain_info = None
if _domain_info.exists() and _domain_info.count() == 1:
current_domain_info = _domain_info.get()
else:
logger.error("Could get domain_info. No domain info exists, or duplicates exist.")
return current_domain_info
class DomainView(DomainBaseView):
"""Domain detail overview page."""
@ -217,16 +233,29 @@ class DomainAuthorizingOfficialView(DomainFormBaseView):
"""Add domain_info.authorizing_official instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["instance"] = self.object.domain_info.authorizing_official
domain_info = self.get_domain_info_from_domain()
invalid_fields = [DomainRequest.OrganizationChoices.FEDERAL, DomainRequest.OrganizationChoices.TRIBAL]
is_federal_or_tribal = domain_info and (domain_info.organization_type in invalid_fields)
form_kwargs["disable_fields"] = is_federal_or_tribal
return form_kwargs
def get_context_data(self, **kwargs):
"""Adds custom context."""
context = super().get_context_data(**kwargs)
context["organization_type"] = self.object.domain_info.organization_type
return context
def get_success_url(self):
"""Redirect to the overview page for the domain."""
return reverse("domain-authorizing-official", kwargs={"pk": self.object.pk})
def form_valid(self, form):
"""The form is valid, save the authorizing official."""
# Set the domain information in the form so that it can be accessible
# to associate a new Contact as authorizing official, if new Contact is needed
# to associate a new Contact, if a new Contact is needed
# in the save() method
form.set_domain_info(self.object.domain_info)
form.save()