Add permission table

This commit is contained in:
zandercymatics 2024-08-16 14:57:51 -06:00
parent 4f7414f695
commit e03e6f7d35
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
6 changed files with 303 additions and 96 deletions

View file

@ -112,6 +112,10 @@ def login_callback(request):
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)

View file

@ -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)

View file

@ -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")},
},
),
]

View file

@ -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)

View file

@ -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):
@ -435,6 +367,46 @@ class User(AbstractUser):
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"""
if self.is_org_user(request) and self.has_view_all_domains_permission():

View file

@ -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"<Roles: {self.portfolio_roles}>"
)
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)