Merge pull request #459 from cisagov/nmb/rbac

Role-based Access Control
This commit is contained in:
Neil MartinsenBurrell 2023-03-20 12:04:34 -05:00 committed by GitHub
commit 1e69eb3c10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 413 additions and 8 deletions

View file

@ -0,0 +1,29 @@
# 19. Role-based Access Control
Date: 2023-03-17
## Status
Approved
## Context
In the registrar application, a single user might be associated with many
domains, and they might have different levels of access to view or change
those domains.
## Decision
To use a role-based access control system where we have a model of different
roles and an association that links a user to a specific role with a specified
role. Each role would have some associated permissions in the application and
we can enforce those permissions by using decorators on our Django views.
## Consequences
There is no enterprise model here of users belonging to an “organization” with
a role on all of its associated domain names. Instead, the association is
per-domain and a user would have to be granted the role on each domain
individually. There is also no process designed yet for how and whether users
can grant other users roles on a domain.

View file

@ -0,0 +1,40 @@
# User Permissions
In our registrar application, we need authenticated users (via Login.gov) to
be able to access domains that they are authorized to and not to access
domains that they are not authorized to. In our initial MVP design, this
access is controlled at the domain level, there is no "enterprise" or
"organization" layer for assigning permissions in bulk. (See [this
ADR](../architecture/decisions/0019-role-based-access-control.md) for more on
that decision.)
## Data modeling
We need a way to associate a particular user with a particular domain and the
role or set of permissions that they have. We use a `UserDomainRole`
[model](../../src/registrar/models/user_domain_role.py) with `ForeignKey`s to
`User` and `Domain` and a `role` field. There are reverse relationships called
`permissions` for a user and for a domain to get a list of all of the
`UserDomainRole`s that involve the user or the domain. In addition, there is a
`User.domains` many-to-many relationship that works through the
`UserDomainRole` link table.
## Permission decorator
The Django objects that need to be permission controlled are various views.
For that purpose, we add a very simple permission mixin
[`DomainPermission`](../../src/registrar/views/utility/mixins.py) that can be
added to a view to require that (a) there is a logged-in user and (b) that the
logged in user has a role that permits access to that view. This mixin is the
place where the details of the permissions are enforced. It can allow a view
to load, or deny access with various status codes, e.g. "403 Forbidden".
## Adding roles
The current MVP design uses only a single role called
`UserDomainRole.Roles.ADMIN` that has all access on a domain. As such, the
permission mixin doesn't need to examine the `role` field carefully. In the
future, as we add additional roles that our product vision calls for
(read-only? editing only some information?), we need to add conditional
behavior in the permission mixin, or additional mixins that more clearly
express what is allowed for those new roles.

View file

@ -61,6 +61,7 @@ urlpatterns = [
lambda r: always_404(r, "We forgot to include this link, sorry."), lambda r: always_404(r, "We forgot to include this link, sorry."),
name="todo", name="todo",
), ),
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
] ]

View file

@ -0,0 +1,66 @@
# Generated by Django 4.1.6 on 2023-03-10 15:32
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0014_user_phone_alter_contact_user"),
]
operations = [
migrations.RemoveField(
model_name="domain",
name="owners",
),
migrations.CreateModel(
name="UserDomainRole",
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)),
("role", models.TextField(choices=[("admin", "Admin")])),
(
"domain",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="permissions",
to="registrar.domain",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="permissions",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddField(
model_name="user",
name="domains",
field=models.ManyToManyField(
related_name="users",
through="registrar.UserDomainRole",
to="registrar.domain",
),
),
migrations.AddConstraint(
model_name="userdomainrole",
constraint=models.UniqueConstraint(
fields=("user", "domain"), name="unique_user_domain_role"
),
),
]

View file

@ -6,6 +6,7 @@ from .domain import Domain
from .host_ip import HostIP from .host_ip import HostIP
from .host import Host from .host import Host
from .nameserver import Nameserver from .nameserver import Nameserver
from .user_domain_role import UserDomainRole
from .public_contact import PublicContact from .public_contact import PublicContact
from .user import User from .user import User
from .website import Website from .website import Website
@ -17,6 +18,7 @@ __all__ = [
"HostIP", "HostIP",
"Host", "Host",
"Nameserver", "Nameserver",
"UserDomainRole",
"PublicContact", "PublicContact",
"User", "User",
"Website", "Website",
@ -28,6 +30,7 @@ auditlog.register(Domain)
auditlog.register(HostIP) auditlog.register(HostIP)
auditlog.register(Host) auditlog.register(Host)
auditlog.register(Nameserver) auditlog.register(Nameserver)
auditlog.register(UserDomainRole)
auditlog.register(PublicContact) auditlog.register(PublicContact)
auditlog.register(User) auditlog.register(User)
auditlog.register(Website) auditlog.register(Website)

View file

@ -276,9 +276,8 @@ class Domain(TimeStampedModel):
help_text="Domain is live in the registry", help_text="Domain is live in the registry",
) )
# TODO: determine the relationship between this field # ForeignKey on UserDomainRole creates a "permissions" member for
# and the domain application's `creator` and `submitter` # all of the user-roles that are in place for this domain
owners = models.ManyToManyField(
"registrar.User", # ManyToManyField on User creates a "users" member for all of the
help_text="", # users who have some role on this domain
)

View file

@ -502,6 +502,25 @@ class DomainApplication(TimeStampedModel):
# This is a side-effect of the state transition # This is a side-effect of the state transition
self._send_confirmation_email() self._send_confirmation_email()
@transition(field="status", source=[SUBMITTED, INVESTIGATING], target=APPROVED)
def approve(self):
"""Approve an application that has been submitted.
This has substantial side-effects because it creates another database
object for the approved Domain and makes the user who created the
application into an admin on that domain.
"""
# create the domain if it doesn't exist
Domain = apps.get_model("registrar.Domain")
created_domain, _ = Domain.objects.get_or_create(name=self.requested_domain)
# create the permission for the user
UserDomainRole = apps.get_model("registrar.UserDomainRole")
UserDomainRole.objects.get_or_create(
user=self.creator, domain=created_domain, role=UserDomainRole.Roles.ADMIN
)
# ## Form policies ### # ## Form policies ###
# #
# These methods control what questions need to be answered by applicants # These methods control what questions need to be answered by applicants

View file

@ -1,4 +1,5 @@
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -9,6 +10,12 @@ class User(AbstractUser):
but can be customized later. but can be customized later.
""" """
domains = models.ManyToManyField(
"registrar.Domain",
through="registrar.UserDomainRole",
related_name="users",
)
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, null=True,
blank=True, blank=True,

View file

@ -0,0 +1,50 @@
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
class UserDomainRole(TimeStampedModel):
"""This is a linking table that connects a user with a role on a domain."""
class Roles(models.TextChoices):
"""The possible roles are listed here.
Implementation of the named roles for allowing particular operations happens
elsewhere.
"""
ADMIN = "admin"
user = models.ForeignKey(
"registrar.User",
null=False,
on_delete=models.CASCADE, # when a user is deleted, permissions are too
related_name="permissions",
)
domain = models.ForeignKey(
"registrar.Domain",
null=False,
on_delete=models.CASCADE, # when a domain is deleted, permissions are too
related_name="permissions",
)
role = models.TextField(
choices=Roles.choices,
null=False,
blank=False,
)
def __str__(self):
return "User {} is {} on domain {}".format(self.user, self.role, self.domain)
class Meta:
constraints = [
# a user can have only one role on a given domain, that is, there can
# be only a single row with a certain (user, domain) pair.
models.UniqueConstraint(
fields=["user", "domain"], name="unique_user_domain_role"
)
]

View file

@ -0,0 +1,14 @@
{% extends "dashboard_base.html" %}
{% block title %}Domain {{ domain.name }}{% endblock %}
{% block content %}
<main id="main-content" class="grid-container">
<div class="tablet:grid-offset-1 desktop:grid-offset-2">
<h1>{{ domain.name }}</h1>
<p>Active: {% if domain.is_active %}Yes{% else %}No{% endif %}</p>
</div>
</main>
{% endblock %} {# content #}

View file

@ -16,7 +16,41 @@
<section class="dashboard tablet:grid-col-11 desktop:grid-col-10"> <section class="dashboard tablet:grid-col-11 desktop:grid-col-10">
<h2>Registered domains</h2> <h2>Registered domains</h2>
{% if domains %}
<table class="usa-table usa-table--borderless usa-table--stacked">
<caption class="sr-only">Your domain applications</caption>
<thead>
<tr>
<th data-sortable scope="col" role="columnheader">Domain name</th>
<th data-sortable scope="col" role="columnheader">Date created</th>
<th data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
</tr>
</thead>
<tbody>
{% for domain in domains %}
<tr>
<th th scope="row" role="rowheader" data-label="Domain name">
{{ domain.name }}
</th>
<td data-sort-value="{{ domain.created_time|date:"U" }}" data-label="Date created">{{ domain.created_time|date }}</td>
<td data-label="Status">{{ domain.application_status|title }}</td>
<td>
<a href="{% url "domain" pk=domain.pk %}">
Edit <span class="usa-sr-only">{{ domain.name }}</span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
{% else %}
<p>You don't have any registered domains yet</p> <p>You don't have any registered domains yet</p>
{% endif %}
</section> </section>
<section class="dashboard tablet:grid-col-11 desktop:grid-col-10"> <section class="dashboard tablet:grid-col-11 desktop:grid-col-10">

View file

@ -1,7 +1,14 @@
from django.test import TestCase from django.test import TestCase
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from registrar.models import Contact, DomainApplication, User, Website, Domain from registrar.models import (
Contact,
DomainApplication,
User,
Website,
Domain,
UserDomainRole,
)
from unittest import skip from unittest import skip
import boto3_mocking # type: ignore import boto3_mocking # type: ignore
@ -139,6 +146,24 @@ class TestDomain(TestCase):
d1.activate() d1.activate()
class TestPermissions(TestCase):
"""Test the User-Domain-Role connection."""
def test_approval_creates_role(self):
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(
creator=user, requested_domain=domain
)
# skip using the submit method
application.status = DomainApplication.SUBMITTED
application.approve()
# should be a role for this user
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
@skip("Not implemented yet.") @skip("Not implemented yet.")
class TestDomainApplicationLifeCycle(TestCase): class TestDomainApplicationLifeCycle(TestCase):
def test_application_approval(self): def test_application_approval(self):

View file

@ -9,7 +9,7 @@ from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore import boto3_mocking # type: ignore
from registrar.models import DomainApplication, Domain, Contact, Website from registrar.models import DomainApplication, Domain, Contact, Website, UserDomainRole
from registrar.views.application import ApplicationWizard, Step from registrar.views.application import ApplicationWizard, Step
from .common import less_console_noise from .common import less_console_noise
@ -76,6 +76,19 @@ class LoggedInTests(TestWithUser):
# clean up # clean up
application.delete() application.delete()
def test_home_lists_domains(self):
response = self.client.get("/")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.assertNotContains(response, "igorville.gov")
role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=domain, role=UserDomainRole.Roles.ADMIN
)
response = self.client.get("/")
# count = 2 because it is also in screenreader content
self.assertContains(response, "igorville.gov", count=2)
# clean up
role.delete()
def test_whoami_page(self): def test_whoami_page(self):
"""User information appears on the whoami page.""" """User information appears on the whoami page."""
response = self.client.get("/whoami/") response = self.client.get("/whoami/")
@ -1003,3 +1016,46 @@ class DomainApplicationTests(TestWithUser, WebTest):
# self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# page = self.app.get(url) # page = self.app.get(url)
# self.assertNotContains(page, "VALUE") # self.assertNotContains(page, "VALUE")
class TestDomainPermissions(TestWithUser):
def setUp(self):
super().setUp()
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN
)
def tearDown(self):
try:
self.domain.delete()
self.role.delete()
except ValueError: # pass if already deleted
pass
super().tearDown()
def test_not_logged_in(self):
"""Not logged in gets a redirect to Login."""
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
self.assertEqual(response.status_code, 302)
def test_no_domain_role(self):
"""Logged in but no role gets 403 Forbidden."""
self.client.force_login(self.user)
self.role.delete() # user no longer has a role on this domain
with less_console_noise():
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
self.assertEqual(response.status_code, 403)
class TestDomainDetail(TestDomainPermissions, WebTest):
def setUp(self):
super().setUp()
self.app.set_user(self.user.username)
def test_domain_detail_link_works(self):
home_page = self.app.get("/")
self.assertContains(home_page, "igorville.gov")
# click the "Edit" link
detail_page = home_page.click("Edit")
self.assertContains(detail_page, "igorville.gov")

View file

@ -1,4 +1,5 @@
from .application import * from .application import *
from .domain import *
from .health import * from .health import *
from .index import * from .index import *
from .whoami import * from .whoami import *

View file

@ -0,0 +1,13 @@
"""View for a single Domain."""
from django.views.generic import DetailView
from registrar.models import Domain
from .utility import DomainPermission
class DomainView(DomainPermission, DetailView):
model = Domain
template_name = "domain_detail.html"
context_object_name = "domain"

View file

@ -1,3 +1,4 @@
from django.db.models import F
from django.shortcuts import render from django.shortcuts import render
from registrar.models import DomainApplication from registrar.models import DomainApplication
@ -9,4 +10,13 @@ def index(request):
if request.user.is_authenticated: if request.user.is_authenticated:
applications = DomainApplication.objects.filter(creator=request.user) applications = DomainApplication.objects.filter(creator=request.user)
context["domain_applications"] = applications context["domain_applications"] = applications
domains = request.user.permissions.values(
"role",
pk=F("domain__id"),
name=F("domain__name"),
created_time=F("domain__created_at"),
application_status=F("domain__domain_application__status"),
)
context["domains"] = domains
return render(request, "home.html", context) return render(request, "home.html", context)

View file

@ -1,2 +1,3 @@
from .steps_helper import StepsHelper from .steps_helper import StepsHelper
from .always_404 import always_404 from .always_404 import always_404
from .mixins import DomainPermission

View file

@ -0,0 +1,37 @@
"""Permissions-related mixin classes."""
from django.contrib.auth.mixins import PermissionRequiredMixin
from registrar.models import UserDomainRole
class PermissionsLoginMixin(PermissionRequiredMixin):
"""Mixin that redirects to login page if not logged in, otherwise 403."""
def handle_no_permission(self):
self.raise_exception = self.request.user.is_authenticated
return super().handle_no_permission()
class DomainPermission(PermissionsLoginMixin):
"""Does the logged-in user have access to this domain?"""
def has_permission(self):
"""Check if this user has access to this domain.
The user is in self.request.user and the domain needs to be looked
up from the domain's primary key in self.kwargs["pk"]
"""
if not self.request.user.is_authenticated:
return False
# user needs to have a role on the domain
if not UserDomainRole.objects.filter(
user=self.request.user, domain__id=self.kwargs["pk"]
).exists():
return False
# if we need to check more about the nature of role, do it here.
return True