diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 57c8cdfaf..cb198cb6b 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -132,14 +132,6 @@ class MyUserAdminForm(UserChangeForm):
widgets = {
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
- "portfolio_roles": FilteredSelectMultipleArrayWidget(
- "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
- ),
- "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
- "portfolio_additional_permissions",
- is_stacked=False,
- choices=UserPortfolioPermissionChoices.choices,
- ),
}
def __init__(self, *args, **kwargs):
@@ -171,6 +163,22 @@ class MyUserAdminForm(UserChangeForm):
)
+class UserPortfolioPermissionsForm(forms.ModelForm):
+ class Meta:
+ model = models.UserPortfolioPermission
+ fields = "__all__"
+ widgets = {
+ "roles": FilteredSelectMultipleArrayWidget(
+ "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
+ ),
+ "additional_permissions": FilteredSelectMultipleArrayWidget(
+ "additional_permissions",
+ is_stacked=False,
+ choices=UserPortfolioPermissionChoices.choices,
+ ),
+ }
+
+
class PortfolioInvitationAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
@@ -744,19 +752,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"is_superuser",
"groups",
"user_permissions",
- "portfolio",
- "portfolio_roles",
- "portfolio_additional_permissions",
)
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
- autocomplete_fields = [
- "portfolio",
- ]
-
readonly_fields = ("verification_type",)
analyst_fieldsets = (
@@ -776,9 +777,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"fields": (
"is_active",
"groups",
- "portfolio",
- "portfolio_roles",
- "portfolio_additional_permissions",
)
},
),
@@ -833,9 +831,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"Important dates",
"last_login",
"date_joined",
- "portfolio",
- "portfolio_roles",
- "portfolio_additional_permissions",
]
# TODO: delete after we merge organization feature
@@ -1244,6 +1239,26 @@ class UserDomainRoleResource(resources.ModelResource):
model = models.UserDomainRole
+class UserPortfolioPermissionAdmin(ListHeaderAdmin):
+ form = UserPortfolioPermissionsForm
+
+ class Meta:
+ """Contains meta information about this class"""
+
+ model = models.UserPortfolioPermission
+ fields = "__all__"
+
+ _meta = Meta()
+
+ # Columns
+ list_display = [
+ "user",
+ "portfolio",
+ ]
+
+ autocomplete_fields = ["user", "portfolio"]
+
+
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom user domain role admin class."""
@@ -3262,6 +3277,7 @@ admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
+admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
# Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py
index ee5f8aee1..ea04dca80 100644
--- a/src/registrar/context_processors.py
+++ b/src/registrar/context_processors.py
@@ -61,27 +61,37 @@ def add_has_profile_feature_flag_to_context(request):
def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context"""
try:
- if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"):
+ portfolio = request.session.get("portfolio")
+ if portfolio:
return {
- "has_base_portfolio_permission": False,
- "has_domains_portfolio_permission": False,
- "has_domain_requests_portfolio_permission": False,
- "portfolio": None,
- "has_organization_feature_flag": False,
+ "has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio),
+ "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(portfolio),
+ "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(
+ portfolio
+ ),
+ "has_view_suborganization": request.user.has_view_suborganization(portfolio),
+ "has_edit_suborganization": request.user.has_edit_suborganization(portfolio),
+ "portfolio": portfolio,
+ "has_organization_feature_flag": True,
}
return {
- "has_base_portfolio_permission": request.user.has_base_portfolio_permission(),
- "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
- "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
- "portfolio": request.user.portfolio,
- "has_organization_feature_flag": True,
+ "has_base_portfolio_permission": False,
+ "has_domains_portfolio_permission": False,
+ "has_domain_requests_portfolio_permission": False,
+ "has_view_suborganization": False,
+ "has_edit_suborganization": False,
+ "portfolio": None,
+ "has_organization_feature_flag": False,
}
+
except AttributeError:
# Handles cases where request.user might not exist
return {
"has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False,
+ "has_view_suborganization": False,
+ "has_edit_suborganization": False,
"portfolio": None,
"has_organization_feature_flag": False,
}
diff --git a/src/registrar/migrations/0119_remove_user_portfolio_and_more.py b/src/registrar/migrations/0119_remove_user_portfolio_and_more.py
new file mode 100644
index 000000000..84ed45cd1
--- /dev/null
+++ b/src/registrar/migrations/0119_remove_user_portfolio_and_more.py
@@ -0,0 +1,97 @@
+# Generated by Django 4.2.10 on 2024-08-19 20:24
+
+from django.conf import settings
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0118_alter_portfolio_options_alter_portfolio_creator_and_more"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="user",
+ name="portfolio",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="portfolio_additional_permissions",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="portfolio_roles",
+ ),
+ migrations.CreateModel(
+ name="UserPortfolioPermission",
+ 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)),
+ (
+ "roles",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[
+ ("organization_admin", "Admin"),
+ ("organization_admin_read_only", "Admin read only"),
+ ("organization_member", "Member"),
+ ],
+ max_length=50,
+ ),
+ blank=True,
+ help_text="Select one or more roles.",
+ null=True,
+ size=None,
+ ),
+ ),
+ (
+ "additional_permissions",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[
+ ("view_all_domains", "View all domains and domain reports"),
+ ("view_managed_domains", "View managed domains"),
+ ("view_member", "View members"),
+ ("edit_member", "Create and edit members"),
+ ("view_all_requests", "View all requests"),
+ ("view_created_requests", "View created requests"),
+ ("edit_requests", "Create and edit requests"),
+ ("view_portfolio", "View organization"),
+ ("edit_portfolio", "Edit organization"),
+ ("view_suborganization", "View suborganization"),
+ ("edit_suborganization", "Edit suborganization"),
+ ],
+ max_length=50,
+ ),
+ blank=True,
+ help_text="Select one or more additional permissions.",
+ null=True,
+ size=None,
+ ),
+ ),
+ (
+ "portfolio",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="portfolio_users",
+ to="registrar.portfolio",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="portfolio_permissions",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "unique_together": {("user", "portfolio")},
+ },
+ ),
+ ]
diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py
index 1e0aad0b1..c1023cafe 100644
--- a/src/registrar/models/__init__.py
+++ b/src/registrar/models/__init__.py
@@ -21,6 +21,7 @@ from .portfolio import Portfolio
from .domain_group import DomainGroup
from .suborganization import Suborganization
from .senior_official import SeniorOfficial
+from .user_portfolio_permission import UserPortfolioPermission
__all__ = [
@@ -46,6 +47,7 @@ __all__ = [
"DomainGroup",
"Suborganization",
"SeniorOfficial",
+ "UserPortfolioPermission",
]
auditlog.register(Contact)
@@ -70,3 +72,4 @@ auditlog.register(Portfolio)
auditlog.register(DomainGroup)
auditlog.register(Suborganization)
auditlog.register(SeniorOfficial)
+auditlog.register(UserPortfolioPermission)
diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py
index 2ad780429..46d7bf124 100644
--- a/src/registrar/models/portfolio_invitation.py
+++ b/src/registrar/models/portfolio_invitation.py
@@ -1,13 +1,11 @@
"""People are invited by email to administer domains."""
import logging
-
from django.contrib.auth import get_user_model
from django.db import models
-
from django_fsm import FSMField, transition
+from registrar.models.user_portfolio_permission import UserPortfolioPermission
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
-
from .utility.time_stamped_model import TimeStampedModel
from django.contrib.postgres.fields import ArrayField
@@ -87,9 +85,11 @@ class PortfolioInvitation(TimeStampedModel):
raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.")
# and create a role for that user on this portfolio
- user.portfolio = self.portfolio
+ user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
+ portfolio=self.portfolio, user=user
+ )
if self.portfolio_roles and len(self.portfolio_roles) > 0:
- user.portfolio_roles = self.portfolio_roles
+ user_portfolio_permission.roles = self.portfolio_roles
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
- user.portfolio_additional_permissions = self.portfolio_additional_permissions
- user.save()
+ user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
+ user_portfolio_permission.save()
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 81d3b9b61..a7ea1e14a 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -3,10 +3,9 @@ import logging
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Q
-from django.forms import ValidationError
+from django.http import HttpRequest
-from registrar.models.domain_information import DomainInformation
-from registrar.models.user_domain_role import UserDomainRole
+from registrar.models import DomainInformation, UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation
@@ -15,7 +14,6 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .domain import Domain
from .domain_request import DomainRequest
-from django.contrib.postgres.fields import ArrayField
from waffle.decorators import flag_is_active
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@@ -112,34 +110,6 @@ class User(AbstractUser):
related_name="users",
)
- portfolio = models.ForeignKey(
- "registrar.Portfolio",
- null=True,
- blank=True,
- related_name="user",
- on_delete=models.SET_NULL,
- )
-
- portfolio_roles = ArrayField(
- models.CharField(
- max_length=50,
- choices=UserPortfolioRoleChoices.choices,
- ),
- null=True,
- blank=True,
- help_text="Select one or more roles.",
- )
-
- portfolio_additional_permissions = ArrayField(
- models.CharField(
- max_length=50,
- choices=UserPortfolioPermissionChoices.choices,
- ),
- null=True,
- blank=True,
- help_text="Select one or more additional permissions.",
- )
-
phone = PhoneNumberField(
null=True,
blank=True,
@@ -230,68 +200,50 @@ class User(AbstractUser):
def has_contact_info(self):
return bool(self.title or self.email or self.phone)
- def clean(self):
- """Extends clean method to perform additional validation, which can raise errors in django admin."""
- super().clean()
-
- if self.portfolio is None and self._get_portfolio_permissions():
- raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.")
-
- if self.portfolio is not None and not self._get_portfolio_permissions():
- raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
-
- def _get_portfolio_permissions(self):
- """
- Retrieve the permissions for the user's portfolio roles.
- """
- portfolio_permissions = set() # Use a set to avoid duplicate permissions
-
- if self.portfolio_roles:
- for role in self.portfolio_roles:
- if role in self.PORTFOLIO_ROLE_PERMISSIONS:
- portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS[role])
- if self.portfolio_additional_permissions:
- portfolio_permissions.update(self.portfolio_additional_permissions)
- return list(portfolio_permissions) # Convert back to list if necessary
-
- def _has_portfolio_permission(self, portfolio_permission):
+ def _has_portfolio_permission(self, portfolio, portfolio_permission):
"""The views should only call this function when testing for perms and not rely on roles."""
- if not self.portfolio:
+ if not portfolio:
return False
- portfolio_permissions = self._get_portfolio_permissions()
+ user_portfolio_perms = self.portfolio_permissions.filter(portfolio=portfolio, user=self).first()
+ if not user_portfolio_perms:
+ return False
- return portfolio_permission in portfolio_permissions
+ return portfolio_permission in user_portfolio_perms._get_portfolio_permissions()
- # the methods below are checks for individual portfolio permissions. They are defined here
- # to make them easier to call elsewhere throughout the application
- def has_base_portfolio_permission(self):
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
+ def has_base_portfolio_permission(self, portfolio):
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
- def has_edit_org_portfolio_permission(self):
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
+ def has_edit_org_portfolio_permission(self, portfolio):
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
- def has_domains_portfolio_permission(self):
+ def has_domains_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(
- UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
- ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
+ portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
+ ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
- def has_domain_requests_portfolio_permission(self):
+ def has_domain_requests_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(
- UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
- ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
+ portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
+ ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
- def has_view_all_domains_permission(self):
+ def has_view_all_domains_permission(self, portfolio):
"""Determines if the current user can view all available domains in a given portfolio"""
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
# Field specific permission checks
- def has_view_suborganization(self):
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
+ def has_view_suborganization(self, portfolio):
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
- def has_edit_suborganization(self):
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
+ def has_edit_suborganization(self, portfolio):
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
+
+ def get_first_portfolio(self):
+ permission = self.portfolio_permissions.first()
+ if permission:
+ return permission.portfolio
+ return None
@classmethod
def needs_identity_verification(cls, email, uuid):
@@ -406,7 +358,14 @@ class User(AbstractUser):
for invitation in PortfolioInvitation.objects.filter(
email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED
):
- if self.portfolio is None:
+ # need to create a bogus request and assign user to it, in order to pass request
+ # to flag_is_active
+ request = HttpRequest()
+ request.user = self
+ only_single_portfolio = (
+ not flag_is_active(request, "multiple_portfolios") and self.get_first_portfolio() is None
+ )
+ if only_single_portfolio or flag_is_active(None, "multiple_portfolios"):
try:
invitation.retrieve()
invitation.save()
@@ -431,13 +390,17 @@ class User(AbstractUser):
self.check_domain_invitations_on_login()
self.check_portfolio_invitations_on_login()
+ # NOTE TO DAVE: I'd simply suggest that we move these functions outside of the user object,
+ # and move them to some sort of utility file. That way we aren't calling request inside here.
def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature")
- return has_organization_feature_flag and self.has_base_portfolio_permission()
+ portfolio = request.session.get("portfolio")
+ return has_organization_feature_flag and self.has_base_portfolio_permission(portfolio)
def get_user_domain_ids(self, request):
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
- if self.is_org_user(request) and self.has_view_all_domains_permission():
- return DomainInformation.objects.filter(portfolio=self.portfolio).values_list("domain_id", flat=True)
+ portfolio = request.session.get("portfolio")
+ if self.is_org_user(request) and self.has_view_all_domains_permission(portfolio):
+ return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
else:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
new file mode 100644
index 000000000..bf1c3e566
--- /dev/null
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -0,0 +1,119 @@
+from django.db import models
+from django.forms import ValidationError
+from django.http import HttpRequest
+from waffle import flag_is_active
+from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
+from .utility.time_stamped_model import TimeStampedModel
+from django.contrib.postgres.fields import ArrayField
+
+
+class UserPortfolioPermission(TimeStampedModel):
+ """This is a linking table that connects a user with a role on a portfolio."""
+
+ class Meta:
+ unique_together = ["user", "portfolio"]
+
+ PORTFOLIO_ROLE_PERMISSIONS = {
+ UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
+ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
+ UserPortfolioPermissionChoices.VIEW_MEMBER,
+ UserPortfolioPermissionChoices.EDIT_MEMBER,
+ UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
+ UserPortfolioPermissionChoices.EDIT_REQUESTS,
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
+ # Domain: field specific permissions
+ UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
+ UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
+ ],
+ UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
+ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
+ UserPortfolioPermissionChoices.VIEW_MEMBER,
+ UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ # Domain: field specific permissions
+ UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
+ ],
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ ],
+ }
+
+ user = models.ForeignKey(
+ "registrar.User",
+ null=False,
+ # when a user is deleted, permissions are too
+ on_delete=models.CASCADE,
+ related_name="portfolio_permissions",
+ )
+
+ portfolio = models.ForeignKey(
+ "registrar.Portfolio",
+ null=False,
+ # when a portfolio is deleted, permissions are too
+ on_delete=models.CASCADE,
+ related_name="portfolio_users",
+ )
+
+ roles = ArrayField(
+ models.CharField(
+ max_length=50,
+ choices=UserPortfolioRoleChoices.choices,
+ ),
+ null=True,
+ blank=True,
+ help_text="Select one or more roles.",
+ )
+
+ additional_permissions = ArrayField(
+ models.CharField(
+ max_length=50,
+ choices=UserPortfolioPermissionChoices.choices,
+ ),
+ null=True,
+ blank=True,
+ help_text="Select one or more additional permissions.",
+ )
+
+ def __str__(self):
+ return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"