From 03d5dad86748fee7e87f51482ac1352912a9c8ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simonyi=20Gerg=C5=91?= <28359278+gergosimonyi@users.noreply.github.com> Date: Mon, 14 Apr 2025 17:55:49 +0200 Subject: [PATCH] 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 https://github.com/goauthentik/authentik/pull/10139 * reorganize initial permission assignment * fixup! reorganize initial permission assignment --- authentik/blueprints/api.py | 4 +- authentik/core/api/utils.py | 10 + authentik/core/tests/test_api_utils.py | 19 + .../enterprise/providers/ssf/views/stream.py | 3 +- .../stages/authenticator_endpoint_gdtc/api.py | 2 +- authentik/rbac/api/initial_permissions.py | 41 ++ .../migrations/0005_initialpermissions.py | 39 ++ authentik/rbac/models.py | 30 ++ authentik/rbac/permissions.py | 21 + .../rbac/tests/test_initial_permissions.py | 116 ++++++ authentik/rbac/urls.py | 4 +- authentik/stages/dummy/urls.py | 2 +- authentik/stages/prompt/api.py | 3 +- authentik/tenants/api/tenants.py | 4 +- blueprints/schema.json | 107 +++++ schema.yml | 368 ++++++++++++++++++ web/src/admin/AdminInterface/AdminSidebar.ts | 1 + web/src/admin/Routes.ts | 4 + web/src/admin/rbac/InitialPermissionsForm.ts | 150 +++++++ .../admin/rbac/InitialPermissionsListPage.ts | 115 ++++++ web/src/admin/rbac/utils.ts | 14 + 21 files changed, 1047 insertions(+), 10 deletions(-) create mode 100644 authentik/rbac/api/initial_permissions.py create mode 100644 authentik/rbac/migrations/0005_initialpermissions.py create mode 100644 authentik/rbac/tests/test_initial_permissions.py create mode 100644 web/src/admin/rbac/InitialPermissionsForm.ts create mode 100644 web/src/admin/rbac/InitialPermissionsListPage.ts create mode 100644 web/src/admin/rbac/utils.ts diff --git a/authentik/blueprints/api.py b/authentik/blueprints/api.py index c4cc098df3..686f194061 100644 --- a/authentik/blueprints/api.py +++ b/authentik/blueprints/api.py @@ -7,7 +7,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.fields import CharField, DateTimeField from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import ListSerializer, ModelSerializer +from rest_framework.serializers import ListSerializer from rest_framework.viewsets import ModelViewSet from authentik.blueprints.models import BlueprintInstance @@ -15,7 +15,7 @@ from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.oci import OCI_PREFIX from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import JSONDictField, PassiveSerializer +from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer from authentik.rbac.decorators import permission_required diff --git a/authentik/core/api/utils.py b/authentik/core/api/utils.py index e4c335f126..b2afcb5383 100644 --- a/authentik/core/api/utils.py +++ b/authentik/core/api/utils.py @@ -20,6 +20,8 @@ from rest_framework.serializers import ( raise_errors_on_nested_writes, ) +from authentik.rbac.permissions import assign_initial_permissions + def is_dict(value: Any): """Ensure a value is a dictionary, useful for JSONFields""" @@ -29,6 +31,14 @@ def is_dict(value: Any): class ModelSerializer(BaseModelSerializer): + def create(self, validated_data): + instance = super().create(validated_data) + + request = self.context.get("request") + if request and hasattr(request, "user") and not request.user.is_anonymous: + assign_initial_permissions(request.user, instance) + + return instance def update(self, instance: Model, validated_data): raise_errors_on_nested_writes("update", self, validated_data) diff --git a/authentik/core/tests/test_api_utils.py b/authentik/core/tests/test_api_utils.py index 25a317ebd1..34e0398542 100644 --- a/authentik/core/tests/test_api_utils.py +++ b/authentik/core/tests/test_api_utils.py @@ -1,9 +1,17 @@ """Test API Utils""" from rest_framework.exceptions import ValidationError +from rest_framework.serializers import ( + HyperlinkedModelSerializer, +) +from rest_framework.serializers import ( + ModelSerializer as BaseModelSerializer, +) from rest_framework.test import APITestCase +from authentik.core.api.utils import ModelSerializer as CustomModelSerializer from authentik.core.api.utils import is_dict +from authentik.lib.utils.reflection import all_subclasses class TestAPIUtils(APITestCase): @@ -14,3 +22,14 @@ class TestAPIUtils(APITestCase): self.assertIsNone(is_dict({})) with self.assertRaises(ValidationError): is_dict("foo") + + def test_all_serializers_descend_from_custom(self): + """Test that every serializer we define descends from our own ModelSerializer""" + # Weirdly, there's only one serializer in `rest_framework` which descends from + # ModelSerializer: HyperlinkedModelSerializer + expected = {CustomModelSerializer, HyperlinkedModelSerializer} + actual = set(all_subclasses(BaseModelSerializer)) - set( + all_subclasses(CustomModelSerializer) + ) + + self.assertEqual(expected, actual) diff --git a/authentik/enterprise/providers/ssf/views/stream.py b/authentik/enterprise/providers/ssf/views/stream.py index 96bdcac2c7..0b06ec0efb 100644 --- a/authentik/enterprise/providers/ssf/views/stream.py +++ b/authentik/enterprise/providers/ssf/views/stream.py @@ -4,10 +4,9 @@ from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer from structlog.stdlib import get_logger -from authentik.core.api.utils import PassiveSerializer +from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.enterprise.providers.ssf.models import ( DeliveryMethods, EventTypes, diff --git a/authentik/enterprise/stages/authenticator_endpoint_gdtc/api.py b/authentik/enterprise/stages/authenticator_endpoint_gdtc/api.py index cd11160d20..bb0ea08c48 100644 --- a/authentik/enterprise/stages/authenticator_endpoint_gdtc/api.py +++ b/authentik/enterprise/stages/authenticator_endpoint_gdtc/api.py @@ -2,11 +2,11 @@ from rest_framework import mixins from rest_framework.permissions import IsAdminUser -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import GenericViewSet, ModelViewSet from structlog.stdlib import get_logger from authentik.core.api.used_by import UsedByMixin +from authentik.core.api.utils import ModelSerializer from authentik.enterprise.api import EnterpriseRequiredMixin from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( AuthenticatorEndpointGDTCStage, diff --git a/authentik/rbac/api/initial_permissions.py b/authentik/rbac/api/initial_permissions.py new file mode 100644 index 0000000000..91ae9df585 --- /dev/null +++ b/authentik/rbac/api/initial_permissions.py @@ -0,0 +1,41 @@ +"""RBAC Initial Permissions""" + +from rest_framework.serializers import ListSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.used_by import UsedByMixin +from authentik.core.api.utils import ModelSerializer +from authentik.rbac.api.rbac import PermissionSerializer +from authentik.rbac.models import InitialPermissions + + +class InitialPermissionsSerializer(ModelSerializer): + """InitialPermissions serializer""" + + permissions_obj = ListSerializer( + child=PermissionSerializer(), + read_only=True, + source="permissions", + required=False, + ) + + class Meta: + model = InitialPermissions + fields = [ + "pk", + "name", + "mode", + "role", + "permissions", + "permissions_obj", + ] + + +class InitialPermissionsViewSet(UsedByMixin, ModelViewSet): + """InitialPermissions viewset""" + + queryset = InitialPermissions.objects.all() + serializer_class = InitialPermissionsSerializer + search_fields = ["name"] + ordering = ["name"] + filterset_fields = ["name"] diff --git a/authentik/rbac/migrations/0005_initialpermissions.py b/authentik/rbac/migrations/0005_initialpermissions.py new file mode 100644 index 0000000000..1a93da396b --- /dev/null +++ b/authentik/rbac/migrations/0005_initialpermissions.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.13 on 2025-04-07 13:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("authentik_rbac", "0004_alter_systempermission_options"), + ] + + operations = [ + migrations.CreateModel( + name="InitialPermissions", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.TextField(max_length=150, unique=True)), + ("mode", models.CharField(choices=[("user", "User"), ("role", "Role")])), + ("permissions", models.ManyToManyField(blank=True, to="auth.permission")), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="authentik_rbac.role" + ), + ), + ], + options={ + "verbose_name": "Initial Permissions", + "verbose_name_plural": "Initial Permissions", + }, + ), + ] diff --git a/authentik/rbac/models.py b/authentik/rbac/models.py index 62324d984e..b8577ce530 100644 --- a/authentik/rbac/models.py +++ b/authentik/rbac/models.py @@ -3,6 +3,7 @@ from uuid import uuid4 from django.contrib.auth.management import _get_all_permissions +from django.contrib.auth.models import Permission from django.db import models from django.db.transaction import atomic from django.utils.translation import gettext_lazy as _ @@ -75,6 +76,35 @@ class Role(SerializerModel): ] +class InitialPermissionsMode(models.TextChoices): + """Determines which entity the initial permissions are assigned to.""" + + USER = "user", _("User") + ROLE = "role", _("Role") + + +class InitialPermissions(SerializerModel): + """Assigns permissions for newly created objects.""" + + name = models.TextField(max_length=150, unique=True) + mode = models.CharField(choices=InitialPermissionsMode.choices) + role = models.ForeignKey(Role, on_delete=models.CASCADE) + permissions = models.ManyToManyField(Permission, blank=True) + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.rbac.api.initial_permissions import InitialPermissionsSerializer + + return InitialPermissionsSerializer + + def __str__(self) -> str: + return f"Initial Permissions for Role #{self.role_id}, applying to #{self.mode}" + + class Meta: + verbose_name = _("Initial Permissions") + verbose_name_plural = _("Initial Permissions") + + class SystemPermission(models.Model): """System-wide permissions that are not related to any direct database model""" diff --git a/authentik/rbac/permissions.py b/authentik/rbac/permissions.py index 4850ff8c70..cb1c4ada7f 100644 --- a/authentik/rbac/permissions.py +++ b/authentik/rbac/permissions.py @@ -1,9 +1,13 @@ """RBAC Permissions""" +from django.contrib.contenttypes.models import ContentType from django.db.models import Model +from guardian.shortcuts import assign_perm from rest_framework.permissions import BasePermission, DjangoObjectPermissions from rest_framework.request import Request +from authentik.rbac.models import InitialPermissions, InitialPermissionsMode + class ObjectPermissions(DjangoObjectPermissions): """RBAC Permissions""" @@ -51,3 +55,20 @@ def HasPermission(*perm: str) -> type[BasePermission]: return bool(request.user and request.user.has_perms(perm)) return checker + + +# TODO: add `user: User` type annotation without circular dependencies. +# The author of this function isn't proficient/patient enough to do it. +def assign_initial_permissions(user, instance: Model): + # Performance here should not be an issue, but if needed, there are many optimization routes + initial_permissions_list = InitialPermissions.objects.filter(role__group__in=user.groups.all()) + for initial_permissions in initial_permissions_list: + for permission in initial_permissions.permissions.all(): + if permission.content_type != ContentType.objects.get_for_model(instance): + continue + assign_to = ( + user + if initial_permissions.mode == InitialPermissionsMode.USER + else initial_permissions.role.group + ) + assign_perm(permission, assign_to, instance) diff --git a/authentik/rbac/tests/test_initial_permissions.py b/authentik/rbac/tests/test_initial_permissions.py new file mode 100644 index 0000000000..03945b67bc --- /dev/null +++ b/authentik/rbac/tests/test_initial_permissions.py @@ -0,0 +1,116 @@ +"""Test InitialPermissions""" + +from django.contrib.auth.models import Permission +from guardian.shortcuts import assign_perm +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Group +from authentik.core.tests.utils import create_test_user +from authentik.lib.generators import generate_id +from authentik.rbac.models import InitialPermissions, InitialPermissionsMode, Role +from authentik.stages.dummy.models import DummyStage + + +class TestInitialPermissions(APITestCase): + """Test InitialPermissions""" + + def setUp(self) -> None: + self.user = create_test_user() + self.same_role_user = create_test_user() + self.different_role_user = create_test_user() + + self.role = Role.objects.create(name=generate_id()) + self.different_role = Role.objects.create(name=generate_id()) + + self.group = Group.objects.create(name=generate_id()) + self.different_group = Group.objects.create(name=generate_id()) + + self.group.roles.add(self.role) + self.group.users.add(self.user, self.same_role_user) + self.different_group.roles.add(self.different_role) + self.different_group.users.add(self.different_role_user) + + self.ip = InitialPermissions.objects.create( + name=generate_id(), mode=InitialPermissionsMode.USER, role=self.role + ) + self.view_role = Permission.objects.filter(codename="view_role").first() + self.ip.permissions.add(self.view_role) + + assign_perm("authentik_rbac.add_role", self.user) + self.client.force_login(self.user) + + def test_different_role(self): + """InitialPermissions for different role does nothing""" + self.ip.role = self.different_role + self.ip.save() + + self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"}) + + role = Role.objects.filter(name="test-role").first() + self.assertFalse(self.user.has_perm("authentik_rbac.view_role", role)) + + def test_different_model(self): + """InitialPermissions for different model does nothing""" + assign_perm("authentik_stages_dummy.add_dummystage", self.user) + + self.client.post( + reverse("authentik_api:stages-dummy-list"), {"name": "test-stage", "throw-error": False} + ) + + role = Role.objects.filter(name="test-role").first() + self.assertFalse(self.user.has_perm("authentik_rbac.view_role", role)) + stage = DummyStage.objects.filter(name="test-stage").first() + self.assertFalse(self.user.has_perm("authentik_stages_dummy.view_dummystage", stage)) + + def test_mode_user(self): + """InitialPermissions adds user permission in user mode""" + self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"}) + + role = Role.objects.filter(name="test-role").first() + self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role)) + self.assertFalse(self.same_role_user.has_perm("authentik_rbac.view_role", role)) + + def test_mode_role(self): + """InitialPermissions adds role permission in role mode""" + self.ip.mode = InitialPermissionsMode.ROLE + self.ip.save() + + self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"}) + + role = Role.objects.filter(name="test-role").first() + self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role)) + self.assertTrue(self.same_role_user.has_perm("authentik_rbac.view_role", role)) + + def test_many_permissions(self): + """InitialPermissions can add multiple permissions""" + change_role = Permission.objects.filter(codename="change_role").first() + self.ip.permissions.add(change_role) + + self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"}) + + role = Role.objects.filter(name="test-role").first() + self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role)) + self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role)) + + def test_permissions_separated_by_role(self): + """When the triggering user is part of two different roles with InitialPermissions in role + mode, it only adds permissions to the relevant role.""" + self.ip.mode = InitialPermissionsMode.ROLE + self.ip.save() + different_ip = InitialPermissions.objects.create( + name=generate_id(), mode=InitialPermissionsMode.ROLE, role=self.different_role + ) + change_role = Permission.objects.filter(codename="change_role").first() + different_ip.permissions.add(change_role) + self.different_group.users.add(self.user) + + self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"}) + + role = Role.objects.filter(name="test-role").first() + self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role)) + self.assertTrue(self.same_role_user.has_perm("authentik_rbac.view_role", role)) + self.assertFalse(self.different_role_user.has_perm("authentik_rbac.view_role", role)) + self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role)) + self.assertFalse(self.same_role_user.has_perm("authentik_rbac.change_role", role)) + self.assertTrue(self.different_role_user.has_perm("authentik_rbac.change_role", role)) diff --git a/authentik/rbac/urls.py b/authentik/rbac/urls.py index e39ef8f1bb..48e261180e 100644 --- a/authentik/rbac/urls.py +++ b/authentik/rbac/urls.py @@ -1,5 +1,6 @@ """RBAC API urls""" +from authentik.rbac.api.initial_permissions import InitialPermissionsViewSet from authentik.rbac.api.rbac import RBACPermissionViewSet from authentik.rbac.api.rbac_assigned_by_roles import RoleAssignedPermissionViewSet from authentik.rbac.api.rbac_assigned_by_users import UserAssignedPermissionViewSet @@ -21,5 +22,6 @@ api_urlpatterns = [ ("rbac/permissions/users", UserPermissionViewSet, "permissions-users"), ("rbac/permissions/roles", RolePermissionViewSet, "permissions-roles"), ("rbac/permissions", RBACPermissionViewSet), - ("rbac/roles", RoleViewSet), + ("rbac/roles", RoleViewSet, "roles"), + ("rbac/initial_permissions", InitialPermissionsViewSet, "initial-permissions"), ] diff --git a/authentik/stages/dummy/urls.py b/authentik/stages/dummy/urls.py index f57b2437aa..df38cca8f2 100644 --- a/authentik/stages/dummy/urls.py +++ b/authentik/stages/dummy/urls.py @@ -2,4 +2,4 @@ from authentik.stages.dummy.api import DummyStageViewSet -api_urlpatterns = [("stages/dummy", DummyStageViewSet)] +api_urlpatterns = [("stages/dummy", DummyStageViewSet, "stages-dummy")] diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py index ad15820478..f4f87c2551 100644 --- a/authentik/stages/prompt/api.py +++ b/authentik/stages/prompt/api.py @@ -4,11 +4,12 @@ from drf_spectacular.utils import extend_schema from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import CharField, ModelSerializer +from rest_framework.serializers import CharField from rest_framework.validators import UniqueValidator from rest_framework.viewsets import ModelViewSet from authentik.core.api.used_by import UsedByMixin +from authentik.core.api.utils import ModelSerializer from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.flows.api.stages import StageSerializer from authentik.flows.challenge import HttpChallengeResponse diff --git a/authentik/tenants/api/tenants.py b/authentik/tenants/api/tenants.py index c0b1cc4c3d..f4e97f9a50 100644 --- a/authentik/tenants/api/tenants.py +++ b/authentik/tenants/api/tenants.py @@ -15,12 +15,12 @@ from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import DateTimeField, ModelSerializer +from rest_framework.serializers import DateTimeField from rest_framework.views import View from rest_framework.viewsets import ModelViewSet from authentik.api.authentication import validate_auth -from authentik.core.api.utils import PassiveSerializer +from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.models import User from authentik.lib.config import CONFIG from authentik.recovery.lib import create_admin_group, create_recovery_token diff --git a/blueprints/schema.json b/blueprints/schema.json index 23f0a2458f..60e8bba570 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -1201,6 +1201,46 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_rbac.initialpermissions" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_rbac.initialpermissions_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_rbac.initialpermissions" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_rbac.initialpermissions" + } + } + }, { "type": "object", "required": [ @@ -4828,6 +4868,7 @@ "authentik_providers_scim.scimprovider", "authentik_providers_scim.scimmapping", "authentik_rbac.role", + "authentik_rbac.initialpermissions", "authentik_sources_kerberos.kerberossource", "authentik_sources_kerberos.kerberossourcepropertymapping", "authentik_sources_kerberos.userkerberossourceconnection", @@ -7169,12 +7210,16 @@ "authentik_providers_ssf.view_stream", "authentik_providers_ssf.view_streamevent", "authentik_rbac.access_admin_interface", + "authentik_rbac.add_initialpermissions", "authentik_rbac.add_role", "authentik_rbac.assign_role_permissions", + "authentik_rbac.change_initialpermissions", "authentik_rbac.change_role", + "authentik_rbac.delete_initialpermissions", "authentik_rbac.delete_role", "authentik_rbac.edit_system_settings", "authentik_rbac.unassign_role_permissions", + "authentik_rbac.view_initialpermissions", "authentik_rbac.view_role", "authentik_rbac.view_system_info", "authentik_rbac.view_system_settings", @@ -7461,6 +7506,64 @@ } } }, + "model_authentik_rbac.initialpermissions": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 150, + "minLength": 1, + "title": "Name" + }, + "mode": { + "type": "string", + "enum": [ + "user", + "role" + ], + "title": "Mode" + }, + "role": { + "type": "string", + "format": "uuid", + "title": "Role" + }, + "permissions": { + "type": "array", + "items": { + "type": "integer" + }, + "title": "Permissions" + } + }, + "required": [] + }, + "model_authentik_rbac.initialpermissions_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_initialpermissions", + "change_initialpermissions", + "delete_initialpermissions", + "view_initialpermissions" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_sources_kerberos.kerberossource": { "type": "object", "properties": { @@ -13793,12 +13896,16 @@ "authentik_providers_ssf.view_stream", "authentik_providers_ssf.view_streamevent", "authentik_rbac.access_admin_interface", + "authentik_rbac.add_initialpermissions", "authentik_rbac.add_role", "authentik_rbac.assign_role_permissions", + "authentik_rbac.change_initialpermissions", "authentik_rbac.change_role", + "authentik_rbac.delete_initialpermissions", "authentik_rbac.delete_role", "authentik_rbac.edit_system_settings", "authentik_rbac.unassign_role_permissions", + "authentik_rbac.view_initialpermissions", "authentik_rbac.view_role", "authentik_rbac.view_system_info", "authentik_rbac.view_system_settings", diff --git a/schema.yml b/schema.yml index d98d1a6dec..dda1c3e2da 100644 --- a/schema.yml +++ b/schema.yml @@ -24209,6 +24209,270 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /rbac/initial_permissions/: + get: + operationId: rbac_initial_permissions_list + description: InitialPermissions viewset + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - rbac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedInitialPermissionsList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: rbac_initial_permissions_create + description: InitialPermissions viewset + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InitialPermissionsRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/InitialPermissions' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/initial_permissions/{id}/: + get: + operationId: rbac_initial_permissions_retrieve + description: InitialPermissions viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Initial Permissions. + required: true + tags: + - rbac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/InitialPermissions' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: rbac_initial_permissions_update + description: InitialPermissions viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Initial Permissions. + required: true + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InitialPermissionsRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/InitialPermissions' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: rbac_initial_permissions_partial_update + description: InitialPermissions viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Initial Permissions. + required: true + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedInitialPermissionsRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/InitialPermissions' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: rbac_initial_permissions_destroy + description: InitialPermissions viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Initial Permissions. + required: true + tags: + - rbac + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/initial_permissions/{id}/used_by/: + get: + operationId: rbac_initial_permissions_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Initial Permissions. + required: true + tags: + - rbac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /rbac/permissions/: get: operationId: rbac_permissions_list @@ -24370,6 +24634,7 @@ paths: - authentik_providers_scim.scimmapping - authentik_providers_scim.scimprovider - authentik_providers_ssf.ssfprovider + - authentik_rbac.initialpermissions - authentik_rbac.role - authentik_sources_kerberos.groupkerberossourceconnection - authentik_sources_kerberos.kerberossource @@ -24616,6 +24881,7 @@ paths: - authentik_providers_scim.scimmapping - authentik_providers_scim.scimprovider - authentik_providers_ssf.ssfprovider + - authentik_rbac.initialpermissions - authentik_rbac.role - authentik_sources_kerberos.groupkerberossourceconnection - authentik_sources_kerberos.kerberossource @@ -45974,6 +46240,63 @@ components: minLength: 1 required: - reason + InitialPermissions: + type: object + description: InitialPermissions serializer + properties: + pk: + type: integer + readOnly: true + title: ID + name: + type: string + maxLength: 150 + mode: + $ref: '#/components/schemas/InitialPermissionsModeEnum' + role: + type: string + format: uuid + permissions: + type: array + items: + type: integer + permissions_obj: + type: array + items: + $ref: '#/components/schemas/Permission' + readOnly: true + required: + - mode + - name + - permissions_obj + - pk + - role + InitialPermissionsModeEnum: + enum: + - user + - role + type: string + InitialPermissionsRequest: + type: object + description: InitialPermissions serializer + properties: + name: + type: string + minLength: 1 + maxLength: 150 + mode: + $ref: '#/components/schemas/InitialPermissionsModeEnum' + role: + type: string + format: uuid + permissions: + type: array + items: + type: integer + required: + - mode + - name + - role InstallID: type: object properties: @@ -47662,6 +47985,7 @@ components: - authentik_providers_scim.scimprovider - authentik_providers_scim.scimmapping - authentik_rbac.role + - authentik_rbac.initialpermissions - authentik_sources_kerberos.kerberossource - authentik_sources_kerberos.kerberossourcepropertymapping - authentik_sources_kerberos.userkerberossourceconnection @@ -49390,6 +49714,18 @@ components: required: - pagination - results + PaginatedInitialPermissionsList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/InitialPermissions' + required: + - pagination + - results PaginatedInvitationList: type: object properties: @@ -51939,6 +52275,23 @@ components: type: boolean description: When enabled, the stage will succeed and continue even when incorrect user info is entered. + PatchedInitialPermissionsRequest: + type: object + description: InitialPermissions serializer + properties: + name: + type: string + minLength: 1 + maxLength: 150 + mode: + $ref: '#/components/schemas/InitialPermissionsModeEnum' + role: + type: string + format: uuid + permissions: + type: array + items: + type: integer PatchedInvitationRequest: type: object description: Invitation Serializer @@ -54104,6 +54457,21 @@ components: type: string required: - id + PermissionRequest: + type: object + description: Global permission + properties: + name: + type: string + minLength: 1 + maxLength: 255 + codename: + type: string + minLength: 1 + maxLength: 100 + required: + - codename + - name PlexAuthenticationChallenge: type: object description: Challenge shown to the user in identification stage diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index b3738ae68d..8ca4944259 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -131,6 +131,7 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement ["/identity/users", msg("Users"), [`^/identity/users/(?${ID_REGEX})$`]], ["/identity/groups", msg("Groups"), [`^/identity/groups/(?${UUID_REGEX})$`]], ["/identity/roles", msg("Roles"), [`^/identity/roles/(?${UUID_REGEX})$`]], + ["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?${ID_REGEX})$`]], ["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?${SLUG_REGEX})$`]], ["/core/tokens", msg("Tokens and App passwords")], ["/flow/stages/invitations", msg("Invitations")]]], diff --git a/web/src/admin/Routes.ts b/web/src/admin/Routes.ts index f6df48cbbf..88fb143ca3 100644 --- a/web/src/admin/Routes.ts +++ b/web/src/admin/Routes.ts @@ -84,6 +84,10 @@ export const ROUTES: Route[] = [ await import("@goauthentik/admin/roles/RoleListPage"); return html``; }), + new Route(new RegExp("^/identity/initial-permissions$"), async () => { + await import("@goauthentik/admin/rbac/InitialPermissionsListPage"); + return html``; + }), new Route(new RegExp(`^/identity/roles/(?${UUID_REGEX})$`), async (args) => { await import("@goauthentik/admin/roles/RoleViewPage"); return html``; diff --git a/web/src/admin/rbac/InitialPermissionsForm.ts b/web/src/admin/rbac/InitialPermissionsForm.ts new file mode 100644 index 0000000000..4cf02ce7de --- /dev/null +++ b/web/src/admin/rbac/InitialPermissionsForm.ts @@ -0,0 +1,150 @@ +import { InitialPermissionsModeToLabel } from "@goauthentik/admin/rbac/utils"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider"; +import { DataProvision, DualSelectPair } from "@goauthentik/elements/ak-dual-select/types"; +import "@goauthentik/elements/chips/Chip"; +import "@goauthentik/elements/chips/ChipGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + InitialPermissions, + InitialPermissionsModeEnum, + Permission, + RbacApi, + RbacRolesListRequest, + Role, +} from "@goauthentik/api"; + +export function rbacPermissionPair(item: Permission): DualSelectPair { + return [item.id.toString(), html`
${item.name}
`, item.name]; +} + +@customElement("ak-initial-permissions-form") +export class InitialPermissionsForm extends ModelForm { + loadInstance(pk: string): Promise { + return new RbacApi(DEFAULT_CONFIG).rbacInitialPermissionsRetrieve({ + id: Number(pk), + }); + } + + getSuccessMessage(): string { + return this.instance + ? msg("Successfully updated initial permissions.") + : msg("Successfully created initial permissions."); + } + + async send(data: InitialPermissions): Promise { + if (this.instance?.pk) { + return new RbacApi(DEFAULT_CONFIG).rbacInitialPermissionsPartialUpdate({ + id: this.instance.pk, + patchedInitialPermissionsRequest: data, + }); + } else { + return new RbacApi(DEFAULT_CONFIG).rbacInitialPermissionsCreate({ + initialPermissionsRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html`
+ + + + + => { + const args: RbacRolesListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const users = await new RbacApi(DEFAULT_CONFIG).rbacRolesList(args); + return users.results; + }} + .renderElement=${(role: Role): string => { + return role.name; + }} + .renderDescription=${(role: Role): TemplateResult => { + return html`${role.name}`; + }} + .value=${(role: Role | undefined): string | undefined => { + return role?.pk; + }} + .selected=${(role: Role): boolean => { + return this.instance?.role === role.pk; + }} + > + +

+ ${msg( + "When a user with the selected Role creates an object, the Initial Permissions will be applied to that object.", + )} +

+
+ + +

+ ${msg( + "The Initial Permissions can either be placed on the User creating the object, or the Role selected in the previous field.", + )} +

+
+ + => { + return new RbacApi(DEFAULT_CONFIG) + .rbacPermissionsList({ + page: page, + search: search, + }) + .then((results) => { + return { + pagination: results.pagination, + options: results.results.map(rbacPermissionPair), + }; + }); + }} + .selected=${(this.instance?.permissionsObj ?? []).map(rbacPermissionPair)} + available-label="${msg("Available Permissions")}" + selected-label="${msg("Selected Permissions")}" + > +

+ ${msg("Permissions to grant when a new object is created.")} +

+
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-initial-permissions-form": InitialPermissionsForm; + } +} diff --git a/web/src/admin/rbac/InitialPermissionsListPage.ts b/web/src/admin/rbac/InitialPermissionsListPage.ts new file mode 100644 index 0000000000..15b8206444 --- /dev/null +++ b/web/src/admin/rbac/InitialPermissionsListPage.ts @@ -0,0 +1,115 @@ +import "@goauthentik/admin/rbac/InitialPermissionsForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/buttons/SpinnerButton"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +import "@goauthentik/elements/forms/ModalForm"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; +import { TableColumn } from "@goauthentik/elements/table/Table"; +import { TablePage } from "@goauthentik/elements/table/TablePage"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { InitialPermissions, RbacApi } from "@goauthentik/api"; + +@customElement("ak-initial-permissions-list") +export class InitialPermissionsListPage extends TablePage { + checkbox = true; + clearOnRefresh = true; + searchEnabled(): boolean { + return true; + } + pageTitle(): string { + return msg("Initial Permissions"); + } + pageDescription(): string { + return msg("Set initial permissions for newly created objects."); + } + pageIcon(): string { + return "fa fa-lock"; + } + + @property() + order = "name"; + + async apiEndpoint(): Promise> { + return new RbacApi(DEFAULT_CONFIG).rbacInitialPermissionsList( + await this.defaultEndpointConfig(), + ); + } + + columns(): TableColumn[] { + return [new TableColumn(msg("Name"), "name"), new TableColumn(msg("Actions"))]; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return new RbacApi(DEFAULT_CONFIG).rbacInitialPermissionsUsedByList({ + id: item.pk, + }); + }} + .delete=${(item: InitialPermissions) => { + return new RbacApi(DEFAULT_CONFIG).rbacInitialPermissionsDestroy({ + id: item.pk, + }); + }} + > + + `; + } + + render(): TemplateResult { + return html` + +
+
${this.renderTable()}
+
`; + } + + row(item: InitialPermissions): TemplateResult[] { + return [ + html`${item.name}`, + html` + ${msg("Update")} + ${msg("Update Initial Permissions")} + + + + `, + ]; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${msg("Create")} + ${msg("Create Initial Permissions")} + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "initial-permissions-list": InitialPermissionsListPage; + } +} diff --git a/web/src/admin/rbac/utils.ts b/web/src/admin/rbac/utils.ts new file mode 100644 index 0000000000..7ad08b2dbf --- /dev/null +++ b/web/src/admin/rbac/utils.ts @@ -0,0 +1,14 @@ +import { msg } from "@lit/localize"; + +import { InitialPermissionsModeEnum } from "@goauthentik/api"; + +export function InitialPermissionsModeToLabel(mode: InitialPermissionsModeEnum): string { + switch (mode) { + case InitialPermissionsModeEnum.User: + return msg("User"); + case InitialPermissionsModeEnum.Role: + return msg("Role"); + case InitialPermissionsModeEnum.UnknownDefaultOpenApi: + return msg("Unknown Initial Permissions mode"); + } +}