From e03e6f7d35bdd985e6fa0ae4af6fe9bdb9df28ba Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 16 Aug 2024 14:57:51 -0600 Subject: [PATCH] Add permission table --- src/djangooidc/views.py | 4 + src/registrar/admin.py | 61 ++++++--- .../0119_remove_user_portfolio_and_more.py | 108 +++++++++++++++ src/registrar/models/__init__.py | 3 + src/registrar/models/user.py | 128 +++++++----------- .../models/user_portfolio_permission.py | 95 +++++++++++++ 6 files changed, 303 insertions(+), 96 deletions(-) create mode 100644 src/registrar/migrations/0119_remove_user_portfolio_and_more.py create mode 100644 src/registrar/models/user_portfolio_permission.py diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index 815df4ecf..1b8e7944d 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -111,6 +111,10 @@ def login_callback(request): if not user.verification_type or is_fixture_user: user.set_user_verification_type() user.save() + + if not user.last_selected_portfolio: + user.set_default_last_selected_portfolio() + user.save() login(request, user) logger.info("Successfully logged in user %s" % user) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 423c0a01b..85adeb663 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -118,6 +118,23 @@ class FilteredSelectMultipleArrayWidget(FilteredSelectMultiple): return context +class UserPortfolioPermissionsForm(forms.ModelForm): + class Meta: + model = models.UserPortfolioPermission + fields = "__all__" + field_classes = {"username": UsernameField} + widgets = { + "portfolio_roles": FilteredSelectMultipleArrayWidget( + "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices + ), + "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( + "portfolio_additional_permissions", + is_stacked=False, + choices=UserPortfolioPermissionChoices.choices, + ), + } + + class MyUserAdminForm(UserChangeForm): """This form utilizes the custom widget for its class's ManyToMany UIs. @@ -130,14 +147,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): @@ -709,9 +718,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "is_superuser", "groups", "user_permissions", - "portfolio", - "portfolio_roles", - "portfolio_additional_permissions", + "last_selected_portfolio", ) }, ), @@ -719,7 +726,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): ) autocomplete_fields = [ - "portfolio", + "last_selected_portfolio", ] readonly_fields = ("verification_type",) @@ -741,9 +748,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "fields": ( "is_active", "groups", - "portfolio", - "portfolio_roles", - "portfolio_additional_permissions", + "last_selected_portfolio", ) }, ), @@ -798,9 +803,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "Important dates", "last_login", "date_joined", - "portfolio", - "portfolio_roles", - "portfolio_additional_permissions", + "last_selected_portfolio", ] # TODO: delete after we merge organization feature @@ -1208,6 +1211,27 @@ class UserDomainRoleResource(resources.ModelResource): class Meta: 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.""" @@ -3176,6 +3200,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/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..ee65a91b7 --- /dev/null +++ b/src/registrar/migrations/0119_remove_user_portfolio_and_more.py @@ -0,0 +1,108 @@ +# Generated by Django 4.2.10 on 2024-08-16 20:41 + +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.AddField( + model_name="user", + name="last_selected_portfolio", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="portfolio_selected_by_users", + to="registrar.portfolio", + ), + ), + 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)), + ( + "portfolio_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, + ), + ), + ( + "portfolio_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..944eeafdb 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) \ No newline at end of file diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 81d3b9b61..46094367b 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -5,8 +5,7 @@ from django.db import models from django.db.models import Q from django.forms import ValidationError -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 @@ -112,34 +111,14 @@ class User(AbstractUser): related_name="users", ) - portfolio = models.ForeignKey( + last_selected_portfolio = models.ForeignKey( "registrar.Portfolio", null=True, blank=True, - related_name="user", + related_name="portfolio_selected_by_users", 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, @@ -234,64 +213,17 @@ class User(AbstractUser): """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(): + portfolio_perms = self.portfolio_permissions.filter(portfolio=self.last_selected_portfolio).first() + if self.last_selected_portfolio is None and portfolio_perms._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(): + if self.last_selected_portfolio is not None and not portfolio_perms._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): - """The views should only call this function when testing for perms and not rely on roles.""" - - if not self.portfolio: - return False - - portfolio_permissions = self._get_portfolio_permissions() - - return portfolio_permission in 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_edit_org_portfolio_permission(self): - return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO) - - def has_domains_portfolio_permission(self): - return self._has_portfolio_permission( - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS - ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) - - def has_domain_requests_portfolio_permission(self): - return self._has_portfolio_permission( - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS - ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) - - def has_view_all_domains_permission(self): - """Determines if the current user can view all available domains in a given portfolio""" - return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) - - # Field specific permission checks - def has_view_suborganization(self): - return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION) - - def has_edit_suborganization(self): - return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) + def set_default_last_selected_portfolio(self): + permission = self.portfolio_permissions.first() + if permission: + self.last_selected_portfolio = permission.portfolio @classmethod def needs_identity_verification(cls, email, uuid): @@ -434,6 +366,46 @@ class User(AbstractUser): 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() + + def _has_portfolio_permission(self, portfolio_permission): + """The views should only call this function when testing for perms and not rely on roles.""" + + if not self.last_selected_portfolio: + return False + + portfolio_perms = self.portfolio_permissions.filter(portfolio=self.last_selected_portfolio).first() + if not portfolio_perms: + return False + + portfolio_permissions = portfolio_perms._get_portfolio_permissions() + return portfolio_permission in portfolio_permissions + + def has_base_portfolio_permission(self): + return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO) + + def has_edit_org_portfolio_permission(self): + return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO) + + def has_domains_portfolio_permission(self): + return self._has_portfolio_permission( + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS + ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) + + def has_domain_requests_portfolio_permission(self): + return self._has_portfolio_permission( + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS + ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) + + def has_view_all_domains_permission(self): + """Determines if the current user can view all available domains in a given portfolio""" + return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) + + # Field specific permission checks + def has_view_suborganization(self): + return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION) + + def has_edit_suborganization(self): + return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) def get_user_domain_ids(self, request): """Returns either the domains ids associated with this user on UserDomainRole or Portfolio""" diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py new file mode 100644 index 000000000..d88c2b4f9 --- /dev/null +++ b/src/registrar/models/user_portfolio_permission.py @@ -0,0 +1,95 @@ +from django.db import models +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 portfolio 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", + ) + + 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.", + ) + + def __str__(self): + return ( + f"User '{self.user}' on Portfolio '{self.portfolio}' " + f"" + ) + + def _get_portfolio_permissions(self): + """ + Retrieve the permissions for the user's portfolio roles. + """ + # Use a set to avoid duplicate permissions + portfolio_permissions = set() + + if self.portfolio_roles: + for role in self.portfolio_roles: + portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) + + if self.portfolio_additional_permissions: + portfolio_permissions.update(self.portfolio_additional_permissions) + + return list(portfolio_permissions)