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

@ -111,6 +111,10 @@ def login_callback(request):
if not user.verification_type or is_fixture_user: if not user.verification_type or is_fixture_user:
user.set_user_verification_type() user.set_user_verification_type()
user.save() user.save()
if not user.last_selected_portfolio:
user.set_default_last_selected_portfolio()
user.save()
login(request, user) login(request, user)
logger.info("Successfully logged in user %s" % user) logger.info("Successfully logged in user %s" % user)

View file

@ -118,6 +118,23 @@ class FilteredSelectMultipleArrayWidget(FilteredSelectMultiple):
return context 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): class MyUserAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs. """This form utilizes the custom widget for its class's ManyToMany UIs.
@ -130,14 +147,6 @@ class MyUserAdminForm(UserChangeForm):
widgets = { widgets = {
"groups": NoAutocompleteFilteredSelectMultiple("groups", False), "groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", 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): def __init__(self, *args, **kwargs):
@ -709,9 +718,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"is_superuser", "is_superuser",
"groups", "groups",
"user_permissions", "user_permissions",
"portfolio", "last_selected_portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
) )
}, },
), ),
@ -719,7 +726,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
) )
autocomplete_fields = [ autocomplete_fields = [
"portfolio", "last_selected_portfolio",
] ]
readonly_fields = ("verification_type",) readonly_fields = ("verification_type",)
@ -741,9 +748,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"fields": ( "fields": (
"is_active", "is_active",
"groups", "groups",
"portfolio", "last_selected_portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
) )
}, },
), ),
@ -798,9 +803,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"Important dates", "Important dates",
"last_login", "last_login",
"date_joined", "date_joined",
"portfolio", "last_selected_portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
] ]
# TODO: delete after we merge organization feature # TODO: delete after we merge organization feature
@ -1208,6 +1211,27 @@ class UserDomainRoleResource(resources.ModelResource):
class Meta: class Meta:
model = models.UserDomainRole 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): class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom user domain role admin class.""" """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.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin) admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
# Register our custom waffle implementations # Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin) 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 .domain_group import DomainGroup
from .suborganization import Suborganization from .suborganization import Suborganization
from .senior_official import SeniorOfficial from .senior_official import SeniorOfficial
from .user_portfolio_permission import UserPortfolioPermission
__all__ = [ __all__ = [
@ -46,6 +47,7 @@ __all__ = [
"DomainGroup", "DomainGroup",
"Suborganization", "Suborganization",
"SeniorOfficial", "SeniorOfficial",
"UserPortfolioPermission",
] ]
auditlog.register(Contact) auditlog.register(Contact)
@ -70,3 +72,4 @@ auditlog.register(Portfolio)
auditlog.register(DomainGroup) auditlog.register(DomainGroup)
auditlog.register(Suborganization) auditlog.register(Suborganization)
auditlog.register(SeniorOfficial) 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.db.models import Q
from django.forms import ValidationError from django.forms import ValidationError
from registrar.models.domain_information import DomainInformation from registrar.models import DomainInformation, UserDomainRole
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation from .domain_invitation import DomainInvitation
@ -112,34 +111,14 @@ class User(AbstractUser):
related_name="users", related_name="users",
) )
portfolio = models.ForeignKey( last_selected_portfolio = models.ForeignKey(
"registrar.Portfolio", "registrar.Portfolio",
null=True, null=True,
blank=True, blank=True,
related_name="user", related_name="portfolio_selected_by_users",
on_delete=models.SET_NULL, 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( phone = PhoneNumberField(
null=True, null=True,
blank=True, blank=True,
@ -234,64 +213,17 @@ class User(AbstractUser):
"""Extends clean method to perform additional validation, which can raise errors in django admin.""" """Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean() 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.") 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.") raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
def _get_portfolio_permissions(self): def set_default_last_selected_portfolio(self):
""" permission = self.portfolio_permissions.first()
Retrieve the permissions for the user's portfolio roles. if permission:
""" self.last_selected_portfolio = permission.portfolio
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)
@classmethod @classmethod
def needs_identity_verification(cls, email, uuid): def needs_identity_verification(cls, email, uuid):
@ -434,6 +366,46 @@ class User(AbstractUser):
def is_org_user(self, request): def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature") has_organization_feature_flag = flag_is_active(request, "organization_feature")
return has_organization_feature_flag and self.has_base_portfolio_permission() 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): def get_user_domain_ids(self, request):
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio""" """Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""

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)