additional cleanup of permission views and mixins, handling of domain invitations and user domain roles in decorators

This commit is contained in:
David Kennedy 2025-02-12 08:16:16 -05:00
parent 40737cbcf7
commit e4021b76c1
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
7 changed files with 196 additions and 283 deletions

View file

@ -364,7 +364,7 @@ urlpatterns = [
name="user-profile",
),
path(
"invitation/<int:pk>/cancel",
"invitation/<int:domain_invitation_pk>/cancel",
views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
name="invitation-cancel",
),

View file

@ -1,7 +1,7 @@
import functools
from django.core.exceptions import PermissionDenied
from django.utils.decorators import method_decorator
from registrar.models import Domain, DomainInformation, DomainRequest, UserDomainRole
from registrar.models import Domain, DomainInformation, DomainInvitation, DomainRequest, UserDomainRole
# Constants for clarity
ALL = "all"
@ -18,7 +18,6 @@ HAS_PORTFOLIO_DOMAINS_VIEW_ALL = "has_portfolio_domains_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM = "has_portfolio_domain_requests_any_perm"
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL = "has_portfolio_domain_requests_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT = "has_portfolio_domain_requests_edit"
# HAS_PORTFOLIO_DOMAINS_VIEW_MANAGED = "has_portfolio_domains_view_managed"
def grant_access(*rules):
@ -90,13 +89,11 @@ def _user_has_permission(user, request, rules, **kwargs):
conditions_met.append(user.is_superuser)
if not any(conditions_met) and IS_DOMAIN_MANAGER in rules:
domain_id = kwargs.get("domain_pk")
has_permission = _is_domain_manager(user, domain_id)
has_permission = _is_domain_manager(user, **kwargs)
conditions_met.append(has_permission)
if not any(conditions_met) and IS_STAFF_MANAGING_DOMAIN in rules:
domain_id = kwargs.get("domain_pk")
has_permission = _can_access_other_user_domains(request, domain_id)
has_permission = _is_staff_managing_domain(request, **kwargs)
conditions_met.append(has_permission)
if not any(conditions_met) and IS_PORTFOLIO_MEMBER in rules:
@ -115,13 +112,11 @@ def _user_has_permission(user, request, rules, **kwargs):
conditions_met.append(has_permission)
if not any(conditions_met) and IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER in rules:
domain_id = kwargs.get("domain_pk")
has_permission = _is_domain_manager(user, domain_id) and _is_portfolio_member(request)
has_permission = _is_domain_manager(user, **kwargs) and _is_portfolio_member(request)
conditions_met.append(has_permission)
if not any(conditions_met) and IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER in rules:
domain_id = kwargs.get("domain_pk")
has_permission = _is_domain_manager(user, domain_id) and not _is_portfolio_member(request)
has_permission = _is_domain_manager(user, **kwargs) and not _is_portfolio_member(request)
conditions_met.append(has_permission)
if not any(conditions_met) and IS_DOMAIN_REQUEST_CREATOR in rules:
@ -156,10 +151,24 @@ def _has_portfolio_domain_requests_edit(user, request, domain_request_id):
return user.is_org_user(request) and user.has_edit_request_portfolio_permission(request.session.get("portfolio"))
def _is_domain_manager(user, domain_pk):
"""Checks to see if the user is a domain manager of the
domain with domain_pk."""
return UserDomainRole.objects.filter(user=user, domain_id=domain_pk).exists()
def _is_domain_manager(user, **kwargs):
"""
Determines if the given user is a domain manager for a specified domain.
- First, it checks if 'domain_pk' is present in the URL parameters.
- If 'domain_pk' exists, it verifies if the user has a domain role for that domain.
- If 'domain_pk' is absent, it checks for 'domain_invitation_pk' to determine if the user has domain permissions through an invitation.
Returns:
bool: True if the user is a domain manager, False otherwise.
"""
domain_id = kwargs.get("domain_pk")
if domain_id:
return UserDomainRole.objects.filter(user=user, domain_id=domain_id).exists()
domain_invitation_id = kwargs.get("domain_invitation_pk")
if domain_invitation_id:
return DomainInvitation.objects.filter(id=domain_invitation_id, domain__permissions__user=user).exists()
return False
def _is_domain_request_creator(user, domain_request_pk):
@ -176,10 +185,35 @@ def _is_portfolio_member(request):
return request.user.is_org_user(request)
def _can_access_other_user_domains(request, domain_pk):
"""Checks to see if an authorized user (staff or superuser)
can access a domain that they did not create or were invited to.
def _is_staff_managing_domain(request, **kwargs):
"""
Determines whether a staff user (analyst or superuser) has permission to manage a domain
that they did not create or were not invited to.
The function enforces:
1. **User Authorization** - The user must have `analyst_access_permission` or `full_access_permission`.
2. **Valid Session Context** - The user must have explicitly selected the domain for management
via an 'analyst action' (e.g., by clicking 'Manage Domain' in the admin interface).
3. **Domain Status Check** - Only domains in specific statuses (e.g., APPROVED, IN_REVIEW, etc.)
can be managed, except in cases where the domain lacks a status due to errors.
Process:
- First, the function retrieves the `domain_pk` from the URL parameters.
- If `domain_pk` is not provided, it attempts to resolve the domain via `domain_invitation_pk`.
- It checks if the user has the required permissions.
- It verifies that the user has an active 'analyst action' session for the domain.
- Finally, it ensures that the domain is in a status that allows management.
Returns:
bool: True if the user is allowed to manage the domain, False otherwise.
"""
domain_id = kwargs.get("domain_pk")
if not domain_id:
domain_invitation_id = kwargs.get("domain_invitation_pk")
domain_invitation = DomainInvitation.objects.filter(id=domain_invitation_id).first()
if domain_invitation:
domain_id = domain_invitation.domain_id
# Check if the request user is permissioned...
user_is_analyst_or_superuser = request.user.has_perm(
@ -197,7 +231,7 @@ def _can_access_other_user_domains(request, domain_pk):
can_do_action = (
"analyst_action" in session
and "analyst_action_location" in session
and session["analyst_action_location"] == domain_pk
and session["analyst_action_location"] == domain_id
)
if not can_do_action:
@ -215,7 +249,7 @@ def _can_access_other_user_domains(request, domain_pk):
None,
]
requested_domain = DomainInformation.objects.filter(domain_id=domain_pk).first()
requested_domain = DomainInformation.objects.filter(domain_id=domain_id).first()
# if no domain information or domain request exist, the user
# should be able to manage the domain; however, if domain information

View file

@ -154,7 +154,7 @@
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
<td>
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-cancel" pk=invitation.domain_invitation.id %}">
<form method="POST" action="{% url "invitation-cancel" domain_invitation_pk=invitation.domain_invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form>
{% endif %}

View file

@ -1,10 +1,3 @@
"""Views for a single Domain.
Authorization is handled by the `DomainPermissionView`. To ensure that only
authorized users can see information on a domain, every view here should
inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView).
"""
from datetime import date
import logging
import requests
@ -13,7 +6,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render, get_object_or_404
from django.urls import reverse
from django.views.generic import DeleteView
from django.views.generic import DeleteView, DetailView, UpdateView
from django.views.generic.edit import FormMixin
from django.conf import settings
from registrar.decorators import (
@ -49,7 +42,6 @@ from registrar.utility.errors import (
SecurityEmailErrorCodes,
)
from registrar.models.utility.contact_error import ContactError
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
from registrar.utility.waffle import flag_is_active_for_user
from registrar.views.utility.invitation_helper import (
get_org_membership,
@ -76,19 +68,22 @@ from epplibwrapper import (
from ..utility.email import send_templated_email, EmailSendingError
from ..utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
from django import forms
logger = logging.getLogger(__name__)
class DomainBaseView(DomainPermissionView):
class DomainBaseView(DetailView):
"""
Base View for the Domain. Handles getting and setting the domain
in session cache on GETs. Also provides methods for getting
and setting the domain in cache
"""
model = Domain
pk_url_kwarg = "domain_pk"
context_object_name = "domain"
def get(self, request, *args, **kwargs):
self._get_domain(request)
context = self.get_context_data(object=self.object)
@ -120,6 +115,134 @@ class DomainBaseView(DomainPermissionView):
domain_pk = "domain:" + str(self.kwargs.get("domain_pk"))
self.session[domain_pk] = self.object
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
context["is_analyst_or_superuser"] = user.has_perm("registrar.analyst_access_permission") or user.has_perm(
"registrar.full_access_permission"
)
context["is_domain_manager"] = UserDomainRole.objects.filter(user=user, domain=self.object).exists()
context["is_portfolio_user"] = self.can_access_domain_via_portfolio(self.object.pk)
context["is_editable"] = self.is_editable()
# Stored in a variable for the linter
action = "analyst_action"
action_location = "analyst_action_location"
# Flag to see if an analyst is attempting to make edits
if action in self.request.session:
context[action] = self.request.session[action]
if action_location in self.request.session:
context[action_location] = self.request.session[action_location]
return context
def is_editable(self):
"""Returns whether domain is editable in the context of the view"""
domain_editable = self.object.is_editable()
if not domain_editable:
return False
# if user is domain manager or analyst or admin, return True
if (
self.can_access_other_user_domains(self.object.id)
or UserDomainRole.objects.filter(user=self.request.user, domain=self.object).exists()
):
return True
return False
def can_access_domain_via_portfolio(self, pk):
"""Most views should not allow permission to portfolio users.
If particular views allow access to the domain pages, they will need to override
this function.
"""
return False
def has_permission(self):
"""Check if this user has access to this domain.
The user is in self.request.user and the domain needs to be looked
up from the domain's primary key in self.kwargs["domain_pk"]
"""
pk = self.kwargs["domain_pk"]
# test if domain in editable state
if not self.in_editable_state(pk):
return False
# if we need to check more about the nature of role, do it here.
return True
def in_editable_state(self, pk):
"""Is the domain in an editable state"""
requested_domain = None
if Domain.objects.filter(id=pk).exists():
requested_domain = Domain.objects.get(id=pk)
# if domain is editable return true
if requested_domain and requested_domain.is_editable():
return True
return False
def can_access_other_user_domains(self, pk):
"""Checks to see if an authorized user (staff or superuser)
can access a domain that they did not create or was invited to.
"""
# Check if the user is permissioned...
user_is_analyst_or_superuser = self.request.user.has_perm(
"registrar.analyst_access_permission"
) or self.request.user.has_perm("registrar.full_access_permission")
if not user_is_analyst_or_superuser:
return False
# Check if the user is attempting a valid edit action.
# In other words, if the analyst/admin did not click
# the 'Manage Domain' button in /admin,
# then they cannot access this page.
session = self.request.session
can_do_action = (
"analyst_action" in session
and "analyst_action_location" in session
and session["analyst_action_location"] == pk
)
if not can_do_action:
return False
# Analysts may manage domains, when they are in these statuses:
valid_domain_statuses = [
DomainRequest.DomainRequestStatus.APPROVED,
DomainRequest.DomainRequestStatus.IN_REVIEW,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
# Edge case - some domains do not have
# a status or DomainInformation... aka a status of 'None'.
# It is necessary to access those to correct errors.
None,
]
requested_domain = None
if DomainInformation.objects.filter(id=pk).exists():
requested_domain = DomainInformation.objects.get(id=pk)
# if no domain information or domain request exist, the user
# should be able to manage the domain; however, if domain information
# and domain request exist, and domain request is not in valid status,
# user should not be able to manage domain
if (
requested_domain
and requested_domain.domain_request
and requested_domain.domain_request.status not in valid_domain_statuses
):
return False
# Valid session keys exist,
# the user is permissioned,
# and it is in a valid status
return True
class DomainFormBaseView(DomainBaseView, FormMixin):
"""
@ -433,7 +556,7 @@ class DomainOrgNameAddressView(DomainFormBaseView):
return super().has_permission()
@grant_access(IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER)
@grant_access(IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN)
class DomainSubOrganizationView(DomainFormBaseView):
"""Suborganization view"""
@ -480,7 +603,7 @@ class DomainSubOrganizationView(DomainFormBaseView):
return super().form_valid(form)
@grant_access(IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER)
@grant_access(IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER, IS_STAFF_MANAGING_DOMAIN)
class DomainSeniorOfficialView(DomainFormBaseView):
"""Domain senior official editing view."""
@ -1307,8 +1430,9 @@ class DomainAddUserView(DomainFormBaseView):
@grant_access(IS_DOMAIN_MANAGER, IS_STAFF_MANAGING_DOMAIN)
class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
object: DomainInvitation
class DomainInvitationCancelView(SuccessMessageMixin, UpdateView):
model = DomainInvitation
pk_url_kwarg = "domain_invitation_pk"
fields = []
def post(self, request, *args, **kwargs):

View file

@ -2,8 +2,6 @@ from .steps_helper import StepsHelper
from .always_404 import always_404
from .permission_views import (
DomainPermissionView,
PortfolioMembersPermission,
DomainInvitationPermissionCancelView,
)
from .api_views import get_senior_official_from_federal_agency_json

View file

@ -1,14 +1,6 @@
"""Permissions-related mixin classes."""
from django.contrib.auth.mixins import PermissionRequiredMixin
from registrar.models import (
Domain,
DomainRequest,
DomainInvitation,
DomainInformation,
UserDomainRole,
)
import logging
@ -195,151 +187,6 @@ class PortfolioReportsPermission(PermissionsLoginMixin):
return self.request.user.is_org_user(self.request)
class DomainPermission(PermissionsLoginMixin):
"""Permission mixin that redirects to domain if user has access,
otherwise 403"""
def has_permission(self):
"""Check if this user has access to this domain.
The user is in self.request.user and the domain needs to be looked
up from the domain's primary key in self.kwargs["domain_pk"]
"""
pk = self.kwargs["domain_pk"]
# test if domain in editable state
if not self.in_editable_state(pk):
return False
# if we need to check more about the nature of role, do it here.
return True
def in_editable_state(self, pk):
"""Is the domain in an editable state"""
requested_domain = None
if Domain.objects.filter(id=pk).exists():
requested_domain = Domain.objects.get(id=pk)
# if domain is editable return true
if requested_domain and requested_domain.is_editable():
return True
return False
def can_access_other_user_domains(self, pk):
"""Checks to see if an authorized user (staff or superuser)
can access a domain that they did not create or was invited to.
"""
# Check if the user is permissioned...
user_is_analyst_or_superuser = self.request.user.has_perm(
"registrar.analyst_access_permission"
) or self.request.user.has_perm("registrar.full_access_permission")
if not user_is_analyst_or_superuser:
return False
# Check if the user is attempting a valid edit action.
# In other words, if the analyst/admin did not click
# the 'Manage Domain' button in /admin,
# then they cannot access this page.
session = self.request.session
can_do_action = (
"analyst_action" in session
and "analyst_action_location" in session
and session["analyst_action_location"] == pk
)
if not can_do_action:
return False
# Analysts may manage domains, when they are in these statuses:
valid_domain_statuses = [
DomainRequest.DomainRequestStatus.APPROVED,
DomainRequest.DomainRequestStatus.IN_REVIEW,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
# Edge case - some domains do not have
# a status or DomainInformation... aka a status of 'None'.
# It is necessary to access those to correct errors.
None,
]
requested_domain = None
if DomainInformation.objects.filter(id=pk).exists():
requested_domain = DomainInformation.objects.get(id=pk)
# if no domain information or domain request exist, the user
# should be able to manage the domain; however, if domain information
# and domain request exist, and domain request is not in valid status,
# user should not be able to manage domain
if (
requested_domain
and requested_domain.domain_request
and requested_domain.domain_request.status not in valid_domain_statuses
):
return False
# Valid session keys exist,
# the user is permissioned,
# and it is in a valid status
return True
class UserDeleteDomainRolePermission(PermissionsLoginMixin):
"""Permission mixin for UserDomainRole if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to this domain request.
The user is in self.request.user and the domain needs to be looked
up from the domain's primary key in self.kwargs["pk"]
"""
domain_pk = self.kwargs["pk"]
user_pk = self.kwargs["user_pk"]
if not self.request.user.is_authenticated:
return False
# Check if the UserDomainRole object exists, then check
# if the user requesting the delete has permissions to do so
has_delete_permission = UserDomainRole.objects.filter(
user=user_pk,
domain=domain_pk,
domain__permissions__user=self.request.user,
).exists()
user_is_analyst_or_superuser = self.request.user.has_perm(
"registrar.analyst_access_permission"
) or self.request.user.has_perm("registrar.full_access_permission")
if not (has_delete_permission or user_is_analyst_or_superuser):
return False
return True
class DomainInvitationPermission(PermissionsLoginMixin):
"""Permission mixin that redirects to domain invitation if user has
access, otherwise 403"
A user has access to a domain invitation if they have a role on the
associated domain.
"""
def has_permission(self):
"""Check if this user has a role on the domain of this invitation."""
if not self.request.user.is_authenticated:
return False
if not DomainInvitation.objects.filter(
id=self.kwargs["pk"], domain__permissions__user=self.request.user
).exists():
return False
return True
class UserProfilePermission(PermissionsLoginMixin):
"""Permission mixin that redirects to user profile if user
has access, otherwise 403"""

View file

@ -2,18 +2,14 @@
import abc # abstract base class
from django.views.generic import DetailView, DeleteView, UpdateView
from registrar.models import Domain, DomainInvitation, Portfolio
from django.views.generic import DetailView
from registrar.models import Portfolio
from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole
from .mixins import (
DomainPermission,
DomainInvitationPermission,
PortfolioMemberDomainsPermission,
PortfolioMemberDomainsEditPermission,
PortfolioMemberEditPermission,
UserDeleteDomainRolePermission,
UserProfilePermission,
PortfolioBasePermission,
PortfolioMembersPermission,
@ -24,92 +20,6 @@ import logging
logger = logging.getLogger(__name__)
class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
"""Abstract base view for domains that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = Domain
pk_url_kwarg = "domain_pk"
# variable name in template context for the model object
context_object_name = "domain"
# Adds context information for user permissions
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
context["is_analyst_or_superuser"] = user.has_perm("registrar.analyst_access_permission") or user.has_perm(
"registrar.full_access_permission"
)
context["is_domain_manager"] = UserDomainRole.objects.filter(user=user, domain=self.object).exists()
context["is_portfolio_user"] = self.can_access_domain_via_portfolio(self.object.pk)
context["is_editable"] = self.is_editable()
# Stored in a variable for the linter
action = "analyst_action"
action_location = "analyst_action_location"
# Flag to see if an analyst is attempting to make edits
if action in self.request.session:
context[action] = self.request.session[action]
if action_location in self.request.session:
context[action_location] = self.request.session[action_location]
return context
def is_editable(self):
"""Returns whether domain is editable in the context of the view"""
domain_editable = self.object.is_editable()
if not domain_editable:
return False
# if user is domain manager or analyst or admin, return True
if (
self.can_access_other_user_domains(self.object.id)
or UserDomainRole.objects.filter(user=self.request.user, domain=self.object).exists()
):
return True
return False
def can_access_domain_via_portfolio(self, pk):
"""Most views should not allow permission to portfolio users.
If particular views allow access to the domain pages, they will need to override
this function.
"""
return False
# Abstract property enforces NotImplementedError on an attribute.
@property
@abc.abstractmethod
def template_name(self):
raise NotImplementedError
class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC):
"""Abstract view for cancelling a DomainInvitation."""
model = DomainInvitation
object: DomainInvitation
class UserDomainRolePermissionDeleteView(UserDeleteDomainRolePermission, DeleteView, abc.ABC):
"""Abstract base view for deleting a UserDomainRole.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = UserDomainRole
# workaround for type mismatch in DeleteView
object: UserDomainRole
# variable name in template context for the model object
context_object_name = "userdomainrole"
class UserProfilePermissionView(UserProfilePermission, DetailView, abc.ABC):
"""Abstract base view for user profile view that enforces permissions.