basic infra

This commit is contained in:
Rachid Mrad 2024-07-15 18:40:24 -04:00
parent b331f61d0d
commit b1ae220602
No known key found for this signature in database
7 changed files with 244 additions and 6 deletions

View file

@ -652,6 +652,9 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"is_superuser", "is_superuser",
"groups", "groups",
"user_permissions", "user_permissions",
"portfolio",
"portfolio_roles",
"portfolio_permissions",
) )
}, },
), ),

View file

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

View file

@ -23,7 +23,7 @@ class Portfolio(TimeStampedModel):
# Stores who created this model. If no creator is specified in DJA, # Stores who created this model. If no creator is specified in DJA,
# then the creator will default to the current request user""" # 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( notes = models.TextField(
null=True, null=True,

View file

@ -11,6 +11,7 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff from .verified_by_staff import VerifiedByStaff
from .domain import Domain from .domain import Domain
from .domain_request import DomainRequest from .domain_request import DomainRequest
from django.contrib.postgres.fields import ArrayField
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -60,6 +61,56 @@ class User(AbstractUser):
# after they login. # after they login.
FIXTURE_USER = "fixture_user", "Created by fixtures" 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 #### # #### Constants for choice fields ####
RESTRICTED = "restricted" RESTRICTED = "restricted"
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),) STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
@ -80,6 +131,34 @@ class User(AbstractUser):
related_name="users", 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( phone = PhoneNumberField(
null=True, null=True,
blank=True, blank=True,
@ -168,6 +247,31 @@ class User(AbstractUser):
def has_contact_info(self): def has_contact_info(self):
return bool(self.title or self.email or self.phone) 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 @classmethod
def needs_identity_verification(cls, email, uuid): def needs_identity_verification(cls, email, uuid):

View file

@ -146,9 +146,15 @@ class CheckPortfolioMiddleware:
if current_path == self.home: if current_path == self.home:
if has_organization_feature_flag: if has_organization_feature_flag:
if request.user.is_authenticated: if request.user.is_authenticated:
user_portfolios = Portfolio.objects.filter(creator=request.user) # user_portfolios = Portfolio.objects.filter(creator=request.user)
if user_portfolios.exists():
first_portfolio = user_portfolios.first() required_permission = User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO
home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id})
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) return HttpResponseRedirect(home_with_portfolio)
print('user does not have a portfolio')
return None return None

View file

@ -9,6 +9,7 @@ from registrar.models import (
DomainInformation, DomainInformation,
UserDomainRole, UserDomainRole,
) )
from registrar.models.user import User
import logging import logging
@ -398,3 +399,28 @@ class UserProfilePermission(PermissionsLoginMixin):
return False return False
return True 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

View file

@ -3,7 +3,7 @@
import abc # abstract base class import abc # abstract base class
from django.views.generic import DetailView, DeleteView, TemplateView 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 import User
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
@ -15,6 +15,7 @@ from .mixins import (
DomainRequestWizardPermission, DomainRequestWizardPermission,
UserDeleteDomainRolePermission, UserDeleteDomainRolePermission,
UserProfilePermission, UserProfilePermission,
PortfolioBasePermission,
) )
import logging import logging
@ -163,3 +164,23 @@ class UserProfilePermissionView(UserProfilePermission, DetailView, abc.ABC):
@abc.abstractmethod @abc.abstractmethod
def template_name(self): def template_name(self):
raise NotImplementedError 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