diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 049eb38b4..4f220fb59 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -652,6 +652,9 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "is_superuser", "groups", "user_permissions", + "portfolio", + "portfolio_roles", + "portfolio_permissions", ) }, ), diff --git a/src/registrar/migrations/0113_user_portfolio_user_portfolio_permissions_and_more.py b/src/registrar/migrations/0113_user_portfolio_user_portfolio_permissions_and_more.py new file mode 100644 index 000000000..cbcad2c67 --- /dev/null +++ b/src/registrar/migrations/0113_user_portfolio_user_portfolio_permissions_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 4.2.10 on 2024-07-15 22:07 + +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", "0112_remove_contact_registrar_c_user_id_4059c4_idx_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="portfolio", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="user", + to="registrar.portfolio", + ), + ), + migrations.AddField( + model_name="user", + name="portfolio_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_domains", "View all domains and domain reports"), + ("edit_domains", "User is a manager on a domain"), + ("view_member", "View members"), + ("edit_member", "Create and edit members"), + ("view_requests", "View requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more permissions.", + null=True, + size=None, + ), + ), + migrations.AddField( + model_name="user", + name="portfolio_roles", + field=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, + ), + ), + migrations.AlterField( + model_name="portfolio", + name="creator", + field=models.ForeignKey( + help_text="Associated user", + on_delete=django.db.models.deletion.PROTECT, + related_name="creator", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index c72f95c33..8481b757b 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -23,7 +23,7 @@ class Portfolio(TimeStampedModel): # Stores who created this model. If no creator is specified in DJA, # then the creator will default to the current request user""" - creator = models.ForeignKey("registrar.User", on_delete=models.PROTECT, help_text="Associated user", unique=False) + creator = models.ForeignKey("registrar.User", on_delete=models.PROTECT, help_text="Associated user", related_name="creator", unique=False) notes = models.TextField( null=True, diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 87b7799d3..561bdce20 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -11,6 +11,7 @@ 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 phonenumber_field.modelfields import PhoneNumberField # type: ignore @@ -60,6 +61,56 @@ class User(AbstractUser): # after they login. FIXTURE_USER = "fixture_user", "Created by fixtures" + class UserPortfolioRoleChoices(models.TextChoices): + """ + """ + + ORGANIZATION_ADMIN = "organization_admin", "Admin" + ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" + ORGANIZATION_MEMBER = "organization_member", "Member" + + class UserPortfolioPermissionChoices(models.TextChoices): + """ + """ + + VIEW_DOMAINS = "view_domains", "View all domains and domain reports" + # EDIT_DOMAINS is really self.domains. We add is hear and leverage it in has_permission + # so we have one way to test for portfolio and domain edit permissions + # Do we need to check for portfolio domains specifically? + EDIT_DOMAINS = "edit_domains", "User is a manager on a domain" + + VIEW_MEMBER = "view_member", "View members" + EDIT_MEMBER = "edit_member", "Create and edit members" + + VIEW_REQUESTS = "view_requests", "View requests" + EDIT_REQUESTS = "edit_requests", "Create and edit requests" + + VIEW_PORTFOLIO = "view_portfolio", "View organization" + EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" + + + PORTFOLIO_ROLE_PERMISSIONS = { + UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ + UserPortfolioPermissionChoices.VIEW_DOMAINS, + UserPortfolioPermissionChoices.VIEW_MEMBER, + UserPortfolioPermissionChoices.EDIT_MEMBER, + UserPortfolioPermissionChoices.VIEW_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + ], + UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [ + UserPortfolioPermissionChoices.VIEW_DOMAINS, + UserPortfolioPermissionChoices.VIEW_MEMBER, + UserPortfolioPermissionChoices.VIEW_REQUESTS, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + ], + UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + ], + } + + # #### Constants for choice fields #### RESTRICTED = "restricted" STATUS_CHOICES = ((RESTRICTED, RESTRICTED),) @@ -80,6 +131,34 @@ class User(AbstractUser): related_name="users", ) + portfolio = models.ForeignKey( + "registrar.Portfolio", + null=True, + blank=True, + related_name="user", + on_delete=models.PROTECT, + ) + + portfolio_roles = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioRoleChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more roles.", + ) + + portfolio_permissions = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioPermissionChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more permissions.", + ) + phone = PhoneNumberField( null=True, blank=True, @@ -168,6 +247,31 @@ class User(AbstractUser): def has_contact_info(self): return bool(self.title or self.email or self.phone) + + def has_role(self, role): + """Do not rely on roles when testing for perms in views""" + return role in self.portfolio_roles if self.portfolio_roles else False + + def has_portfolio_permissions(self, portfolio_permission): + """The views should only call this guy when testing for perms and not rely on roles""" + + if portfolio_permission == self.UserPortfolioPermissionChoices.EDIT_DOMAINS and self.domains.exists(): + return self.domains + + return portfolio_permission in self.portfolio_permissions if self.portfolio_permissions else False + + def save(self, *args, **kwargs): + self.update_permissions_from_roles() + super().save(*args, **kwargs) + + def update_permissions_from_roles(self): + print('update permissions when saving') + new_portfolio_permissions = set(self.portfolio_permissions or []) + print(f'new_portfolio_permissions {new_portfolio_permissions}') + for role in self.portfolio_roles or []: + print(f'role {role}') + new_portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) + self.portfolio_permissions = list(new_portfolio_permissions) @classmethod def needs_identity_verification(cls, email, uuid): diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 82ee9d0fc..bd7dce19e 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -146,9 +146,15 @@ class CheckPortfolioMiddleware: if current_path == self.home: if has_organization_feature_flag: if request.user.is_authenticated: - user_portfolios = Portfolio.objects.filter(creator=request.user) - if user_portfolios.exists(): - first_portfolio = user_portfolios.first() - home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id}) + # user_portfolios = Portfolio.objects.filter(creator=request.user) + + required_permission = User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO + + if request.user.has_portfolio_permissions(required_permission): + print('user has portfolio') + portfolio = request.user.portfolio + home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": portfolio.id}) return HttpResponseRedirect(home_with_portfolio) + + print('user does not have a portfolio') return None diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 926ee4a8c..55b62f31d 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -9,6 +9,7 @@ from registrar.models import ( DomainInformation, UserDomainRole, ) +from registrar.models.user import User import logging @@ -398,3 +399,28 @@ class UserProfilePermission(PermissionsLoginMixin): return False return True + + +class PortfolioBasePermission(PermissionsLoginMixin): + """Permission mixin that redirects to portfolio pages if user + has access, otherwise 403""" + + def has_permission(self): + """Check if this user has access to this portfolio. + + The user is in self.request.user and the portfolio can be looked + up from the portfolio's primary key in self.kwargs["pk"] + """ + if not self.request.user.is_authenticated: + return False + + # portfolio_id = self.kwargs["pk"] + # portfolio = Portfolio.objects.get(pk=portfolio_id) + + # The 'Base' portfolio permission is VIEW_PORTFOLIO + required_permission = User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO + + if not self.request.user.has_portfolio_permissions(required_permission): + return False + + return True diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index db727c26e..afc7d74c1 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -3,7 +3,7 @@ import abc # abstract base class from django.views.generic import DetailView, DeleteView, TemplateView -from registrar.models import Domain, DomainRequest, DomainInvitation +from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio from registrar.models.user import User from registrar.models.user_domain_role import UserDomainRole @@ -15,6 +15,7 @@ from .mixins import ( DomainRequestWizardPermission, UserDeleteDomainRolePermission, UserProfilePermission, + PortfolioBasePermission, ) import logging @@ -163,3 +164,23 @@ class UserProfilePermissionView(UserProfilePermission, DetailView, abc.ABC): @abc.abstractmethod def template_name(self): raise NotImplementedError + + +class PortfolioPermissionView(PortfolioBasePermission, DetailView, abc.ABC): + """Abstract base view for portfolio views that enforces permissions. + + This abstract view cannot be instantiated. Actual views must specify + `template_name`. + """ + + # DetailView property for what model this is viewing + model = Portfolio + # variable name in template context for the model object + context_object_name = "portfolio" + + # Abstract property enforces NotImplementedError on an attribute. + @property + @abc.abstractmethod + def template_name(self): + raise NotImplementedError +