diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 88338b845..fa0dea885 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -72,6 +72,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ + logger = logging.getLogger(__name__) @@ -3144,9 +3145,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): Returns a tuple: (obj: DomainRequest, should_proceed: bool) """ - should_proceed = True error_message = None + domain_name = original_obj.requested_domain.name # Get the method that should be run given the status selected_method = self.get_status_method_mapping(obj) @@ -3168,6 +3169,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): # duplicated in the model and the error is raised from the model. # This avoids an ugly Django error screen. error_message = "This action is not permitted. The domain is already active." + elif ( + original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED + and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED + and original_obj.requested_domain is not None + and Domain.is_pending_delete(domain_name) + ): + # 1. If the domain request is not approved in previous state (original status) + # 2. If the new status that's supposed to be triggered IS approved + # 3. That it's a valid domain + # 4. AND that the domain is currently in pendingDelete state + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.DOMAIN_IS_PENDING_DELETE) elif ( original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 56f0cfd0f..ba9661d1c 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -171,6 +171,7 @@ urlpatterns = [ path( "admin/logout/", RedirectView.as_view(pattern_name="logout", permanent=False), + name="logout", ), path( "admin/analytics/export_data_type/", diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 233f8f77c..63483c18e 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -256,6 +256,29 @@ class Domain(TimeStampedModel, DomainHelper): req = commands.CheckDomain([domain_name]) return registry.send(req, cleaned=True).res_data[0].avail + @classmethod + def is_pending_delete(cls, domain: str) -> bool: + """Check if domain is pendingDelete state via response from registry.""" + domain_name = domain.lower() + + try: + info_req = commands.InfoDomain(domain_name) + info_response = registry.send(info_req, cleaned=True) + # Ensure res_data exists and is not empty + if info_response and info_response.res_data: + # Use _extract_data_from_response bc it's same thing but jsonified + domain_response = cls._extract_data_from_response(cls, info_response) # type: ignore + domain_status_state = domain_response.get("statuses") + if "pendingDelete" in str(domain_status_state): + return True + except RegistryError as err: + if not err.is_connection_error(): + logger.info(f"Domain does not exist yet so it won't be in pending delete -- {err}") + return False + else: + raise err + return False + @classmethod def registered(cls, domain: str) -> bool: """Check if a domain is _not_ available.""" @@ -2026,7 +2049,9 @@ class Domain(TimeStampedModel, DomainHelper): def _extract_data_from_response(self, data_response): """extract data from response from registry""" + data = data_response.res_data[0] + return { "auth_info": getattr(data, "auth_info", ...), "_contacts": getattr(data, "contacts", ...), diff --git a/src/registrar/permissions.py b/src/registrar/permissions.py new file mode 100644 index 000000000..6847c16d5 --- /dev/null +++ b/src/registrar/permissions.py @@ -0,0 +1,229 @@ +""" +Centralized permissions management for the registrar. +""" + +from django.urls import URLResolver, get_resolver, URLPattern +from registrar.decorators import ( + HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM, + IS_STAFF, + IS_DOMAIN_MANAGER, + IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER, + IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER, + IS_CISA_ANALYST, + IS_OMB_ANALYST, + IS_FULL_ACCESS, + IS_DOMAIN_REQUEST_CREATOR, + IS_STAFF_MANAGING_DOMAIN, + IS_PORTFOLIO_MEMBER, + HAS_PORTFOLIO_DOMAINS_ANY_PERM, + HAS_PORTFOLIO_DOMAINS_VIEW_ALL, + HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, + HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL, + HAS_PORTFOLIO_MEMBERS_EDIT, + HAS_PORTFOLIO_MEMBERS_ANY_PERM, + HAS_PORTFOLIO_MEMBERS_VIEW, + ALL, +) + +# Define permissions for each URL pattern by name +URL_PERMISSIONS = { + # Home & general pages + "home": [ALL], + "health": [ALL], # Intentionally no decorator + # Domain management + "domain": [HAS_PORTFOLIO_DOMAINS_VIEW_ALL, IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-dns": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-dns-nameservers": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-dns-dnssec": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-dns-dnssec-dsdata": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-org-name-address": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-suborganization": [IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-senior-official": [IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER, IS_STAFF_MANAGING_DOMAIN], + "domain-security-email": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-renewal": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-users": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-users-add": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + "domain-user-delete": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + # Portfolio management + "domains": [HAS_PORTFOLIO_DOMAINS_ANY_PERM], + "no-portfolio-domains": [IS_PORTFOLIO_MEMBER], + "no-organization-domains": [IS_PORTFOLIO_MEMBER], + "members": [HAS_PORTFOLIO_MEMBERS_ANY_PERM], + "member": [HAS_PORTFOLIO_MEMBERS_ANY_PERM], + "member-delete": [HAS_PORTFOLIO_MEMBERS_EDIT], + "member-permissions": [HAS_PORTFOLIO_MEMBERS_EDIT], + "member-domains": [HAS_PORTFOLIO_MEMBERS_ANY_PERM], + "member-domains-edit": [HAS_PORTFOLIO_MEMBERS_EDIT], + "invitedmember": [HAS_PORTFOLIO_MEMBERS_ANY_PERM], + "invitedmember-delete": [HAS_PORTFOLIO_MEMBERS_EDIT], + "invitedmember-permissions": [HAS_PORTFOLIO_MEMBERS_EDIT], + "invitedmember-domains": [HAS_PORTFOLIO_MEMBERS_ANY_PERM], + "invitedmember-domains-edit": [HAS_PORTFOLIO_MEMBERS_EDIT], + "new-member": [HAS_PORTFOLIO_MEMBERS_EDIT], + "domain-requests": [HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM], + "no-portfolio-requests": [IS_PORTFOLIO_MEMBER], + "organization": [IS_PORTFOLIO_MEMBER], + "senior-official": [IS_PORTFOLIO_MEMBER], + # Domain requests + "domain-request-status": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "domain-request-status-viewonly": [HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL], + "domain-request-withdraw-confirmation": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "domain-request-withdrawn": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "domain-request-delete": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "edit-domain-request": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + # Admin functions + "analytics": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "export_data_type": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "export_data_full": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "export_data_domain_requests_full": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "export_data_federal": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "export_domains_growth": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "export_requests_growth": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "export_managed_domains": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "export_unmanaged_domains": [IS_CISA_ANALYST, IS_FULL_ACCESS], + "transfer_user": [IS_CISA_ANALYST, IS_FULL_ACCESS], + # Analytics + "all-domain-metadata": [IS_STAFF], + "current-full": [IS_STAFF], + "all-domain-requests-metadata": [IS_STAFF], + "domain-growth": [IS_STAFF], + "request-growth": [IS_STAFF], + "managed-domains": [IS_STAFF], + "unmanaged-domains": [IS_STAFF], + # Reports + "export-user-domains-as-csv": [IS_STAFF], + "export-portfolio-members-as-csv": [IS_STAFF], + "export_members_portfolio": [HAS_PORTFOLIO_MEMBERS_VIEW], + "export_data_type_user": [ALL], + # API endpoints + "get-senior-official-from-federal-agency-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST], + "get-portfolio-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST], + "get-suborganization-list-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST], + "get-federal-and-portfolio-types-from-federal-agency-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST], + "get-action-needed-email-for-user-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST], + "get-rejection-email-for-user-json": [IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST], + "get_domains_json": [ALL], + "get_domain_requests_json": [ALL], + "get_portfolio_members_json": [HAS_PORTFOLIO_MEMBERS_ANY_PERM], + "get_member_domains_json": [HAS_PORTFOLIO_MEMBERS_ANY_PERM], + # User profile + "finish-user-profile-setup": [ALL], + "user-profile": [ALL], + # Invitation + "invitation-cancel": [IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN], + # DNS Hosting + "prototype-domain-dns": [IS_STAFF], + # Domain request wizard + "start": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "finished": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "generic_org_type": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "tribal_government": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "organization_federal": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "organization_election": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "organization_contact": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "about_your_organization": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "senior_official": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "current_sites": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "dotgov_domain": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "purpose": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "other_contacts": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "additional_details": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "requirements": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "review": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "portfolio_requesting_entity": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], + "portfolio_additional_details": [HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, IS_DOMAIN_REQUEST_CREATOR], +} + +UNCHECKED_URLS = [ + "health", + "openid/", + "get-current-federal", + "get-current-full", + "available", + "rdap", + "todo", + "logout", +] + + +def verify_all_urls_have_permissions(): + """ + Utility function to verify that all URLs in the application have defined permissions + in the permissions mapping. + """ + + resolver = get_resolver() + missing_permissions = [] + missing_names = [] + + # Collect all URL pattern names + for pattern in resolver.url_patterns: + # Skip URLResolver objects (like admin.site.urls) + if isinstance(pattern, URLResolver): + continue + + if hasattr(pattern, "name") and pattern.name: + if pattern.name not in URL_PERMISSIONS and pattern.name not in UNCHECKED_URLS: + missing_permissions.append(pattern.name) + else: + raise ValueError(f"URL pattern {pattern} has no name") + + if missing_names: + raise ValueError(f"The following URL patterns have no name: {missing_names}") + + return missing_permissions + + +def validate_permissions(): # noqa: C901 + """ + Validates that all URL patterns have consistent permission rules between + the centralized mapping and view decorators. + + Returns a dictionary of issues found. + """ + + resolver = get_resolver() + issues = { + "missing_in_mapping": [], # URLs with decorators but not in mapping + "missing_decorator": [], # URLs in mapping but missing decorators + "permission_mismatch": [], # URLs with different permissions + } + + def check_url_pattern(pattern, parent_path=""): + if isinstance(pattern, URLPattern): + view_func = pattern.callback + path = f"{parent_path}/{pattern.pattern}" + url_name = pattern.name + + if url_name: + # Skip check for endpoints that intentionally have no decorator + if url_name in UNCHECKED_URLS: + return + + # Check if view has decorator but missing from mapping + if getattr(view_func, "has_explicit_access", False) and url_name not in URL_PERMISSIONS: + issues["missing_in_mapping"].append((url_name, path)) + + # Check if view is in mapping but missing decorator + elif url_name in URL_PERMISSIONS and not getattr(view_func, "has_explicit_access", False): + issues["missing_decorator"].append((url_name, path)) + + # Check if permissions match (more complex, may need refinement) + elif getattr(view_func, "has_explicit_access", False) and url_name in URL_PERMISSIONS: + view_permissions = getattr(view_func, "_access_rules", set()) + mapping_permissions = set(URL_PERMISSIONS[url_name]) + + if view_permissions != mapping_permissions: + issues["permission_mismatch"].append((url_name, path, view_permissions, mapping_permissions)) + + elif isinstance(pattern, URLResolver): + # Handle included URL patterns (nested) + new_parent = f"{parent_path}/{pattern.pattern}" + for p in pattern.url_patterns: + check_url_pattern(p, new_parent) + + # Check all URL patterns + for pattern in resolver.url_patterns: + check_url_pattern(pattern) + + return issues diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 3d9eaa410..3d91bad24 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1092,7 +1092,7 @@ def completed_domain_request( # noqa email="testy@town.com", phone="(555) 555 5555", ) - domain, _ = DraftDomain.objects.get_or_create(name=name) + domain = DraftDomain.objects.create(name=name) other, _ = Contact.objects.get_or_create( first_name="Testy", last_name="Tester", diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index e6ad2ef3e..68fbf26e7 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -50,6 +50,7 @@ from unittest.mock import ANY, patch from django.conf import settings import boto3_mocking # type: ignore import logging +from django.contrib.messages.storage.fallback import FallbackStorage logger = logging.getLogger(__name__) @@ -2315,7 +2316,6 @@ class TestDomainRequestAdmin(MockEppLib): Used to test errors when saving a change with an active domain, also used to test side effects when saving a change goes through.""" - with less_console_noise(): # Create an instance of the model domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) @@ -2428,6 +2428,44 @@ class TestDomainRequestAdmin(MockEppLib): "Cannot approve. Requested domain is already in use.", ) + def test_error_when_approving_domain_in_pending_delete_state(self): + """If in pendingDelete state from in review -> approve not allowed.""" + + # 1. Create domain + to_be_in_pending_deleted = completed_domain_request( + status=DomainRequest.DomainRequestStatus.SUBMITTED, name="meoward1.gov" + ) + to_be_in_pending_deleted.creator = self.superuser + + # 2. Put domain into in-review state + to_be_in_pending_deleted.status = DomainRequest.DomainRequestStatus.IN_REVIEW + to_be_in_pending_deleted.save() + + # 3. Update request as a superuser + request = self.factory.post(f"/admin/registrar/domainrequest/{to_be_in_pending_deleted.pk}/change/") + request.user = self.superuser + request.session = {} + + setattr(request, "_messages", FallbackStorage(request)) + + # 4. Use ExitStack for combine patching like above + with boto3_mocking.clients.handler_for("sesv2", self.mock_client), ExitStack() as stack: + # Patch django.contrib.messages.error + stack.enter_context(patch.object(messages, "error")) + + with patch("registrar.models.domain.Domain.is_pending_delete", return_value=True): + # Attempt to approve to_be_in_pending_deleted + # (which should fail due is_pending_delete == True so nothing can get approved) + to_be_in_pending_deleted.status = DomainRequest.DomainRequestStatus.APPROVED + + # Save the model with the patched request + self.admin.save_model(request, to_be_in_pending_deleted, None, True) + + messages.error.assert_called_once_with( + request, + "Domain of same name is currently in pending delete state.", + ) + @less_console_noise def test_no_error_when_saving_to_approved_and_domain_exists(self): """The negative of the redundant admin check on model transition not allowed.""" diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index d3ec7b24e..53788aeff 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -666,6 +666,66 @@ class TestDomainAvailable(MockEppLib): self.assertFalse(available) patcher.stop() + def test_is_pending_delete(self): + """ + Scenario: Testing if a domain is in pendingDelete status from the registry + Should return True + + * Mock EPP response with pendingDelete status + * Validate InfoDomain command is called + * Validate response given mock + """ + + with patch("registrar.models.domain.registry.send") as mocked_send, patch( + "registrar.models.domain.Domain._extract_data_from_response" + ) as mocked_extract: + + # Mock the registry response + mock_response = MagicMock() + mock_response.res_data = [MagicMock(statuses=[MagicMock(state="pendingDelete")])] + mocked_send.return_value = mock_response + + # Mock JSONified response + mocked_extract.return_value = {"statuses": ["pendingDelete"]} + + result = Domain.is_pending_delete("is-pending-delete.gov") + + mocked_send.assert_called_once_with(commands.InfoDomain("is-pending-delete.gov"), cleaned=True) + mocked_extract.assert_called_once() + + self.assertTrue(result) + + def test_is_not_pending_delete(self): + """ + Scenario: Testing if a domain is NOT in pendingDelete status. + Should return False. + + * Mock EPP response without pendingDelete status (isserverTransferProhibited) + * Validate response given mock + """ + + with patch("registrar.models.domain.registry.send") as mocked_send, patch( + "registrar.models.domain.Domain._extract_data_from_response" + ) as mocked_extract: + + # Mock the registry response + mock_response = MagicMock() + mock_response.res_data = [ + MagicMock(statuses=[MagicMock(state="serverTransferProhibited"), MagicMock(state="ok")]) + ] + mocked_send.return_value = mock_response + + # Mock JSONified response + mocked_extract.return_value = {"statuses": ["serverTransferProhibited", "ok"]} + + result = Domain.is_pending_delete("is-not-pending.gov") + + # Assertions + mocked_send.assert_called_once_with(commands.InfoDomain("is-not-pending.gov"), cleaned=True) + mocked_extract.assert_called_once() + + self.assertFalse(result) + def test_domain_available_with_invalid_error(self): """ Scenario: Testing whether an invalid domain is available diff --git a/src/registrar/tests/test_permissions.py b/src/registrar/tests/test_permissions.py new file mode 100644 index 000000000..380518fdb --- /dev/null +++ b/src/registrar/tests/test_permissions.py @@ -0,0 +1,70 @@ +""" +Tests for validating the permissions system consistency. + +These tests ensure that: +1. All URLs have permissions defined in the centralized mapping +2. All views with permission decorators are in the mapping +3. The permissions in the decorators match those in the mapping +""" + +from django.test import TestCase +from registrar.permissions import verify_all_urls_have_permissions, validate_permissions + + +class TestPermissionsMapping(TestCase): + """Test the centralized permissions mapping for completeness and consistency.""" + + def test_all_urls_have_permissions(self): + """Verify that all URL patterns in the application have permissions defined in the mapping.""" + missing_urls = verify_all_urls_have_permissions() + + # Format URLs for better readability in case of failure + if missing_urls: + formatted_urls = "\n".join([f" - {url}" for url in missing_urls]) + self.fail( + f"The following URL patterns are missing from URL_PERMISSIONS mapping:\n{formatted_urls}\n" + f"Please add them to the URL_PERMISSIONS dictionary in registrar/permissions.py" + ) + + def test_permission_decorator_consistency(self): + """ + Test that all views have consistent permission rules between + the centralized mapping and view decorators. + """ + issues = validate_permissions() + + error_messages = [] + + if issues["missing_in_mapping"]: + urls = "\n".join([f" - {name} (at {path})" for name, path in issues["missing_in_mapping"]]) + error_messages.append( + f"The following URLs have permission decorators but are missing from the mapping:\n{urls}\n" + "Add these URLs to the URL_PERMISSIONS dictionary in registrar/permissions.py" + ) + + if issues["missing_decorator"]: + urls = "\n".join([f" - {name} (at {path})" for name, path in issues["missing_decorator"]]) + error_messages.append( + f"The following URLs are in the mapping but missing @grant_access decorators:\n{urls}\n" + "Add appropriate @grant_access decorators to these views" + ) + + if issues["permission_mismatch"]: + mismatches = [] + for name, path, view_perms, mapping_perms in issues["permission_mismatch"]: + view_perms_str = ", ".join(sorted(str(p) for p in view_perms)) + mapping_perms_str = ", ".join(sorted(str(p) for p in mapping_perms)) + mismatches.append( + f" - {name} (at {path}):\n" + f" Decorator: [{view_perms_str}]\n" + f" Mapping: [{mapping_perms_str}]" + ) + + error_messages.append( + f"The following URLs have mismatched permissions between decorators and mapping:\n" + f"{chr(10).join(mismatches)}\n" + "Update either the decorator or the mapping to ensure consistency" + ) + + if error_messages: + self.fail("\n\n".join(error_messages)) diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index 1e5e9562a..68ac12eb7 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -129,6 +129,7 @@ class FSMErrorCodes(IntEnum): - 3 INVESTIGATOR_NOT_STAFF Investigator is a non-staff user - 4 NO_REJECTION_REASON No rejection reason is specified - 5 NO_ACTION_NEEDED_REASON No action needed reason is specified + - 6 DOMAIN_IS_PENDING_DELETE Domain is in pending delete state """ APPROVE_DOMAIN_IN_USE = 1 @@ -136,6 +137,7 @@ class FSMErrorCodes(IntEnum): INVESTIGATOR_NOT_STAFF = 3 NO_REJECTION_REASON = 4 NO_ACTION_NEEDED_REASON = 5 + DOMAIN_IS_PENDING_DELETE = 6 class FSMDomainRequestError(Exception): @@ -150,6 +152,7 @@ class FSMDomainRequestError(Exception): FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."), FSMErrorCodes.NO_REJECTION_REASON: ("A reason is required for this status."), FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."), + FSMErrorCodes.DOMAIN_IS_PENDING_DELETE: ("Domain of same name is currently in pending delete state."), } def __init__(self, *args, code=None, **kwargs): diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index cfebb2d87..0f7876d25 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -15,6 +15,7 @@ from registrar.decorators import ( IS_DOMAIN_MANAGER, IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER, IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER, + IS_STAFF, IS_STAFF_MANAGING_DOMAIN, grant_access, ) @@ -710,6 +711,7 @@ class PrototypeDomainDNSRecordForm(forms.Form): ) +@grant_access(IS_STAFF) class PrototypeDomainDNSRecordView(DomainFormBaseView): template_name = "prototype_domain_dns.html" form_class = PrototypeDomainDNSRecordForm