Skip to content

Commit 03d5dad

Browse files
authored
rbac: add InitialPermissions (#13795)
* add `InitialPermissions` model to RBAC This is a powerful construct between Permission and Role to set initial permissions for newly created objects. * use safer `request.user` * fixup! use safer `request.user` * force all self-defined serializers to descend from our custom one See #10139 * reorganize initial permission assignment * fixup! reorganize initial permission assignment
1 parent 38a9e46 commit 03d5dad

File tree

21 files changed

+1047
-10
lines changed

21 files changed

+1047
-10
lines changed

authentik/blueprints/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
from rest_framework.fields import CharField, DateTimeField
88
from rest_framework.request import Request
99
from rest_framework.response import Response
10-
from rest_framework.serializers import ListSerializer, ModelSerializer
10+
from rest_framework.serializers import ListSerializer
1111
from rest_framework.viewsets import ModelViewSet
1212

1313
from authentik.blueprints.models import BlueprintInstance
1414
from authentik.blueprints.v1.importer import Importer
1515
from authentik.blueprints.v1.oci import OCI_PREFIX
1616
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
1717
from authentik.core.api.used_by import UsedByMixin
18-
from authentik.core.api.utils import JSONDictField, PassiveSerializer
18+
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
1919
from authentik.rbac.decorators import permission_required
2020

2121

authentik/core/api/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
raise_errors_on_nested_writes,
2121
)
2222

23+
from authentik.rbac.permissions import assign_initial_permissions
24+
2325

2426
def is_dict(value: Any):
2527
"""Ensure a value is a dictionary, useful for JSONFields"""
@@ -29,6 +31,14 @@ def is_dict(value: Any):
2931

3032

3133
class ModelSerializer(BaseModelSerializer):
34+
def create(self, validated_data):
35+
instance = super().create(validated_data)
36+
37+
request = self.context.get("request")
38+
if request and hasattr(request, "user") and not request.user.is_anonymous:
39+
assign_initial_permissions(request.user, instance)
40+
41+
return instance
3242

3343
def update(self, instance: Model, validated_data):
3444
raise_errors_on_nested_writes("update", self, validated_data)

authentik/core/tests/test_api_utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
"""Test API Utils"""
22

33
from rest_framework.exceptions import ValidationError
4+
from rest_framework.serializers import (
5+
HyperlinkedModelSerializer,
6+
)
7+
from rest_framework.serializers import (
8+
ModelSerializer as BaseModelSerializer,
9+
)
410
from rest_framework.test import APITestCase
511

12+
from authentik.core.api.utils import ModelSerializer as CustomModelSerializer
613
from authentik.core.api.utils import is_dict
14+
from authentik.lib.utils.reflection import all_subclasses
715

816

917
class TestAPIUtils(APITestCase):
@@ -14,3 +22,14 @@ def test_is_dict(self):
1422
self.assertIsNone(is_dict({}))
1523
with self.assertRaises(ValidationError):
1624
is_dict("foo")
25+
26+
def test_all_serializers_descend_from_custom(self):
27+
"""Test that every serializer we define descends from our own ModelSerializer"""
28+
# Weirdly, there's only one serializer in `rest_framework` which descends from
29+
# ModelSerializer: HyperlinkedModelSerializer
30+
expected = {CustomModelSerializer, HyperlinkedModelSerializer}
31+
actual = set(all_subclasses(BaseModelSerializer)) - set(
32+
all_subclasses(CustomModelSerializer)
33+
)
34+
35+
self.assertEqual(expected, actual)

authentik/enterprise/providers/ssf/views/stream.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
55
from rest_framework.request import Request
66
from rest_framework.response import Response
7-
from rest_framework.serializers import ModelSerializer
87
from structlog.stdlib import get_logger
98

10-
from authentik.core.api.utils import PassiveSerializer
9+
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
1110
from authentik.enterprise.providers.ssf.models import (
1211
DeliveryMethods,
1312
EventTypes,

authentik/enterprise/stages/authenticator_endpoint_gdtc/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
from rest_framework import mixins
44
from rest_framework.permissions import IsAdminUser
5-
from rest_framework.serializers import ModelSerializer
65
from rest_framework.viewsets import GenericViewSet, ModelViewSet
76
from structlog.stdlib import get_logger
87

98
from authentik.core.api.used_by import UsedByMixin
9+
from authentik.core.api.utils import ModelSerializer
1010
from authentik.enterprise.api import EnterpriseRequiredMixin
1111
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
1212
AuthenticatorEndpointGDTCStage,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""RBAC Initial Permissions"""
2+
3+
from rest_framework.serializers import ListSerializer
4+
from rest_framework.viewsets import ModelViewSet
5+
6+
from authentik.core.api.used_by import UsedByMixin
7+
from authentik.core.api.utils import ModelSerializer
8+
from authentik.rbac.api.rbac import PermissionSerializer
9+
from authentik.rbac.models import InitialPermissions
10+
11+
12+
class InitialPermissionsSerializer(ModelSerializer):
13+
"""InitialPermissions serializer"""
14+
15+
permissions_obj = ListSerializer(
16+
child=PermissionSerializer(),
17+
read_only=True,
18+
source="permissions",
19+
required=False,
20+
)
21+
22+
class Meta:
23+
model = InitialPermissions
24+
fields = [
25+
"pk",
26+
"name",
27+
"mode",
28+
"role",
29+
"permissions",
30+
"permissions_obj",
31+
]
32+
33+
34+
class InitialPermissionsViewSet(UsedByMixin, ModelViewSet):
35+
"""InitialPermissions viewset"""
36+
37+
queryset = InitialPermissions.objects.all()
38+
serializer_class = InitialPermissionsSerializer
39+
search_fields = ["name"]
40+
ordering = ["name"]
41+
filterset_fields = ["name"]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 5.0.13 on 2025-04-07 13:05
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("auth", "0012_alter_user_first_name_max_length"),
11+
("authentik_rbac", "0004_alter_systempermission_options"),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="InitialPermissions",
17+
fields=[
18+
(
19+
"id",
20+
models.AutoField(
21+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
22+
),
23+
),
24+
("name", models.TextField(max_length=150, unique=True)),
25+
("mode", models.CharField(choices=[("user", "User"), ("role", "Role")])),
26+
("permissions", models.ManyToManyField(blank=True, to="auth.permission")),
27+
(
28+
"role",
29+
models.ForeignKey(
30+
on_delete=django.db.models.deletion.CASCADE, to="authentik_rbac.role"
31+
),
32+
),
33+
],
34+
options={
35+
"verbose_name": "Initial Permissions",
36+
"verbose_name_plural": "Initial Permissions",
37+
},
38+
),
39+
]

authentik/rbac/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from uuid import uuid4
44

55
from django.contrib.auth.management import _get_all_permissions
6+
from django.contrib.auth.models import Permission
67
from django.db import models
78
from django.db.transaction import atomic
89
from django.utils.translation import gettext_lazy as _
@@ -75,6 +76,35 @@ class Meta:
7576
]
7677

7778

79+
class InitialPermissionsMode(models.TextChoices):
80+
"""Determines which entity the initial permissions are assigned to."""
81+
82+
USER = "user", _("User")
83+
ROLE = "role", _("Role")
84+
85+
86+
class InitialPermissions(SerializerModel):
87+
"""Assigns permissions for newly created objects."""
88+
89+
name = models.TextField(max_length=150, unique=True)
90+
mode = models.CharField(choices=InitialPermissionsMode.choices)
91+
role = models.ForeignKey(Role, on_delete=models.CASCADE)
92+
permissions = models.ManyToManyField(Permission, blank=True)
93+
94+
@property
95+
def serializer(self) -> type[BaseSerializer]:
96+
from authentik.rbac.api.initial_permissions import InitialPermissionsSerializer
97+
98+
return InitialPermissionsSerializer
99+
100+
def __str__(self) -> str:
101+
return f"Initial Permissions for Role #{self.role_id}, applying to #{self.mode}"
102+
103+
class Meta:
104+
verbose_name = _("Initial Permissions")
105+
verbose_name_plural = _("Initial Permissions")
106+
107+
78108
class SystemPermission(models.Model):
79109
"""System-wide permissions that are not related to any direct
80110
database model"""

authentik/rbac/permissions.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""RBAC Permissions"""
22

3+
from django.contrib.contenttypes.models import ContentType
34
from django.db.models import Model
5+
from guardian.shortcuts import assign_perm
46
from rest_framework.permissions import BasePermission, DjangoObjectPermissions
57
from rest_framework.request import Request
68

9+
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode
10+
711

812
class ObjectPermissions(DjangoObjectPermissions):
913
"""RBAC Permissions"""
@@ -51,3 +55,20 @@ def has_permission(self, request: Request, view):
5155
return bool(request.user and request.user.has_perms(perm))
5256

5357
return checker
58+
59+
60+
# TODO: add `user: User` type annotation without circular dependencies.
61+
# The author of this function isn't proficient/patient enough to do it.
62+
def assign_initial_permissions(user, instance: Model):
63+
# Performance here should not be an issue, but if needed, there are many optimization routes
64+
initial_permissions_list = InitialPermissions.objects.filter(role__group__in=user.groups.all())
65+
for initial_permissions in initial_permissions_list:
66+
for permission in initial_permissions.permissions.all():
67+
if permission.content_type != ContentType.objects.get_for_model(instance):
68+
continue
69+
assign_to = (
70+
user
71+
if initial_permissions.mode == InitialPermissionsMode.USER
72+
else initial_permissions.role.group
73+
)
74+
assign_perm(permission, assign_to, instance)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Test InitialPermissions"""
2+
3+
from django.contrib.auth.models import Permission
4+
from guardian.shortcuts import assign_perm
5+
from rest_framework.reverse import reverse
6+
from rest_framework.test import APITestCase
7+
8+
from authentik.core.models import Group
9+
from authentik.core.tests.utils import create_test_user
10+
from authentik.lib.generators import generate_id
11+
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode, Role
12+
from authentik.stages.dummy.models import DummyStage
13+
14+
15+
class TestInitialPermissions(APITestCase):
16+
"""Test InitialPermissions"""
17+
18+
def setUp(self) -> None:
19+
self.user = create_test_user()
20+
self.same_role_user = create_test_user()
21+
self.different_role_user = create_test_user()
22+
23+
self.role = Role.objects.create(name=generate_id())
24+
self.different_role = Role.objects.create(name=generate_id())
25+
26+
self.group = Group.objects.create(name=generate_id())
27+
self.different_group = Group.objects.create(name=generate_id())
28+
29+
self.group.roles.add(self.role)
30+
self.group.users.add(self.user, self.same_role_user)
31+
self.different_group.roles.add(self.different_role)
32+
self.different_group.users.add(self.different_role_user)
33+
34+
self.ip = InitialPermissions.objects.create(
35+
name=generate_id(), mode=InitialPermissionsMode.USER, role=self.role
36+
)
37+
self.view_role = Permission.objects.filter(codename="view_role").first()
38+
self.ip.permissions.add(self.view_role)
39+
40+
assign_perm("authentik_rbac.add_role", self.user)
41+
self.client.force_login(self.user)
42+
43+
def test_different_role(self):
44+
"""InitialPermissions for different role does nothing"""
45+
self.ip.role = self.different_role
46+
self.ip.save()
47+
48+
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
49+
50+
role = Role.objects.filter(name="test-role").first()
51+
self.assertFalse(self.user.has_perm("authentik_rbac.view_role", role))
52+
53+
def test_different_model(self):
54+
"""InitialPermissions for different model does nothing"""
55+
assign_perm("authentik_stages_dummy.add_dummystage", self.user)
56+
57+
self.client.post(
58+
reverse("authentik_api:stages-dummy-list"), {"name": "test-stage", "throw-error": False}
59+
)
60+
61+
role = Role.objects.filter(name="test-role").first()
62+
self.assertFalse(self.user.has_perm("authentik_rbac.view_role", role))
63+
stage = DummyStage.objects.filter(name="test-stage").first()
64+
self.assertFalse(self.user.has_perm("authentik_stages_dummy.view_dummystage", stage))
65+
66+
def test_mode_user(self):
67+
"""InitialPermissions adds user permission in user mode"""
68+
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
69+
70+
role = Role.objects.filter(name="test-role").first()
71+
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
72+
self.assertFalse(self.same_role_user.has_perm("authentik_rbac.view_role", role))
73+
74+
def test_mode_role(self):
75+
"""InitialPermissions adds role permission in role mode"""
76+
self.ip.mode = InitialPermissionsMode.ROLE
77+
self.ip.save()
78+
79+
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
80+
81+
role = Role.objects.filter(name="test-role").first()
82+
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
83+
self.assertTrue(self.same_role_user.has_perm("authentik_rbac.view_role", role))
84+
85+
def test_many_permissions(self):
86+
"""InitialPermissions can add multiple permissions"""
87+
change_role = Permission.objects.filter(codename="change_role").first()
88+
self.ip.permissions.add(change_role)
89+
90+
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
91+
92+
role = Role.objects.filter(name="test-role").first()
93+
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
94+
self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role))
95+
96+
def test_permissions_separated_by_role(self):
97+
"""When the triggering user is part of two different roles with InitialPermissions in role
98+
mode, it only adds permissions to the relevant role."""
99+
self.ip.mode = InitialPermissionsMode.ROLE
100+
self.ip.save()
101+
different_ip = InitialPermissions.objects.create(
102+
name=generate_id(), mode=InitialPermissionsMode.ROLE, role=self.different_role
103+
)
104+
change_role = Permission.objects.filter(codename="change_role").first()
105+
different_ip.permissions.add(change_role)
106+
self.different_group.users.add(self.user)
107+
108+
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
109+
110+
role = Role.objects.filter(name="test-role").first()
111+
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
112+
self.assertTrue(self.same_role_user.has_perm("authentik_rbac.view_role", role))
113+
self.assertFalse(self.different_role_user.has_perm("authentik_rbac.view_role", role))
114+
self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role))
115+
self.assertFalse(self.same_role_user.has_perm("authentik_rbac.change_role", role))
116+
self.assertTrue(self.different_role_user.has_perm("authentik_rbac.change_role", role))

0 commit comments

Comments
 (0)