diff --git a/.github/codecov.yml b/.github/codecov.yml index 1042659ed..8db67faf6 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -6,5 +6,5 @@ coverage: # adjust accordingly based on how flaky your tests are # this allows a 1% drop from the previous base commit coverage threshold: 1% - notify: - after_n_builds: 3 +comment: + after_n_builds: 3 diff --git a/Makefile b/Makefile index bb7f70a43..9eb357164 100644 --- a/Makefile +++ b/Makefile @@ -62,8 +62,9 @@ lint-fix: ## Lint and automatically fix errors in the python source code. Repor codespell -w $(CODESPELL_ARGS) lint: ## Lint the python and golang sources - pylint $(PY_SOURCES) bandit -r $(PY_SOURCES) -x node_modules + ./web/node_modules/.bin/pyright $(PY_SOURCES) + pylint $(PY_SOURCES) golangci-lint run -v migrate: ## Run the Authentik Django server's migrations diff --git a/authentik/admin/api/meta.py b/authentik/admin/api/meta.py index 25d944411..52640b8c5 100644 --- a/authentik/admin/api/meta.py +++ b/authentik/admin/api/meta.py @@ -1,7 +1,7 @@ """Meta API""" from drf_spectacular.utils import extend_schema from rest_framework.fields import CharField -from rest_framework.permissions import IsAdminUser +from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import ViewSet @@ -21,7 +21,7 @@ class AppSerializer(PassiveSerializer): class AppsViewSet(ViewSet): """Read-only view list all installed apps""" - permission_classes = [IsAdminUser] + permission_classes = [IsAuthenticated] @extend_schema(responses={200: AppSerializer(many=True)}) def list(self, request: Request) -> Response: @@ -35,7 +35,7 @@ class AppsViewSet(ViewSet): class ModelViewSet(ViewSet): """Read-only view list all installed models""" - permission_classes = [IsAdminUser] + permission_classes = [IsAuthenticated] @extend_schema(responses={200: AppSerializer(many=True)}) def list(self, request: Request) -> Response: diff --git a/authentik/admin/api/metrics.py b/authentik/admin/api/metrics.py index 08aea59d2..af32662b1 100644 --- a/authentik/admin/api/metrics.py +++ b/authentik/admin/api/metrics.py @@ -5,7 +5,7 @@ from django.db.models.functions import ExtractHour from drf_spectacular.utils import extend_schema, extend_schema_field from guardian.shortcuts import get_objects_for_user from rest_framework.fields import IntegerField, SerializerMethodField -from rest_framework.permissions import IsAdminUser +from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -68,7 +68,7 @@ class LoginMetricsSerializer(PassiveSerializer): class AdministrationMetricsViewSet(APIView): """Login Metrics per 1h""" - permission_classes = [IsAdminUser] + permission_classes = [IsAuthenticated] @extend_schema(responses={200: LoginMetricsSerializer(many=False)}) def get(self, request: Request) -> Response: diff --git a/authentik/admin/api/system.py b/authentik/admin/api/system.py index 11dc5dfec..7e7d2d920 100644 --- a/authentik/admin/api/system.py +++ b/authentik/admin/api/system.py @@ -8,7 +8,6 @@ from django.utils.timezone import now from drf_spectacular.utils import extend_schema from gunicorn import version_info as gunicorn_version from rest_framework.fields import SerializerMethodField -from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -17,6 +16,7 @@ from authentik.core.api.utils import PassiveSerializer from authentik.lib.utils.reflection import get_env from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.models import Outpost +from authentik.rbac.permissions import HasPermission class RuntimeDict(TypedDict): @@ -88,7 +88,7 @@ class SystemSerializer(PassiveSerializer): class SystemView(APIView): """Get system information.""" - permission_classes = [IsAdminUser] + permission_classes = [HasPermission("authentik_rbac.view_system_info")] pagination_class = None filter_backends = [] serializer_class = SystemSerializer diff --git a/authentik/admin/api/tasks.py b/authentik/admin/api/tasks.py index 00fbe4e08..72714dad5 100644 --- a/authentik/admin/api/tasks.py +++ b/authentik/admin/api/tasks.py @@ -14,14 +14,15 @@ from rest_framework.fields import ( ListField, SerializerMethodField, ) -from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import ViewSet from structlog.stdlib import get_logger +from authentik.api.decorators import permission_required from authentik.core.api.utils import PassiveSerializer from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus +from authentik.rbac.permissions import HasPermission LOGGER = get_logger() @@ -63,7 +64,7 @@ class TaskSerializer(PassiveSerializer): class TaskViewSet(ViewSet): """Read-only view set that returns all background tasks""" - permission_classes = [IsAdminUser] + permission_classes = [HasPermission("authentik_rbac.view_system_tasks")] serializer_class = TaskSerializer @extend_schema( @@ -93,6 +94,7 @@ class TaskViewSet(ViewSet): tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) return Response(TaskSerializer(tasks, many=True).data) + @permission_required(None, ["authentik_rbac.run_system_tasks"]) @extend_schema( request=OpenApiTypes.NONE, responses={ diff --git a/authentik/admin/api/workers.py b/authentik/admin/api/workers.py index ab6d03873..3b5da0594 100644 --- a/authentik/admin/api/workers.py +++ b/authentik/admin/api/workers.py @@ -2,18 +2,18 @@ from django.conf import settings from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework.fields import IntegerField -from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from authentik.rbac.permissions import HasPermission from authentik.root.celery import CELERY_APP class WorkerView(APIView): """Get currently connected worker count.""" - permission_classes = [IsAdminUser] + permission_classes = [HasPermission("authentik_rbac.view_system_info")] @extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()})) def get(self, request: Request) -> Response: diff --git a/authentik/api/authorization.py b/authentik/api/authorization.py index 05cd45819..e3ae48e5c 100644 --- a/authentik/api/authorization.py +++ b/authentik/api/authorization.py @@ -7,9 +7,9 @@ from rest_framework.authentication import get_authorization_header from rest_framework.filters import BaseFilterBackend from rest_framework.permissions import BasePermission from rest_framework.request import Request -from rest_framework_guardian.filters import ObjectPermissionsFilter from authentik.api.authentication import validate_auth +from authentik.rbac.filters import ObjectFilter class OwnerFilter(BaseFilterBackend): @@ -26,14 +26,14 @@ class OwnerFilter(BaseFilterBackend): class SecretKeyFilter(DjangoFilterBackend): """Allow access to all objects when authenticated with secret key as token. - Replaces both DjangoFilterBackend and ObjectPermissionsFilter""" + Replaces both DjangoFilterBackend and ObjectFilter""" def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: auth_header = get_authorization_header(request) token = validate_auth(auth_header) if token and token == settings.SECRET_KEY: return queryset - queryset = ObjectPermissionsFilter().filter_queryset(request, queryset, view) + queryset = ObjectFilter().filter_queryset(request, queryset, view) return super().filter_queryset(request, queryset, view) diff --git a/authentik/api/decorators.py b/authentik/api/decorators.py index a79cebc92..0cd737c76 100644 --- a/authentik/api/decorators.py +++ b/authentik/api/decorators.py @@ -10,7 +10,7 @@ from structlog.stdlib import get_logger LOGGER = get_logger() -def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None): +def permission_required(obj_perm: Optional[str] = None, global_perms: Optional[list[str]] = None): """Check permissions for a single custom action""" def wrapper_outter(func: Callable): @@ -18,15 +18,17 @@ def permission_required(perm: Optional[str] = None, other_perms: Optional[list[s @wraps(func) def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response: - if perm: + if obj_perm: obj = self.get_object() - if not request.user.has_perm(perm, obj): - LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj) + if not request.user.has_perm(obj_perm, obj): + LOGGER.debug( + "denying access for object", user=request.user, perm=obj_perm, obj=obj + ) return self.permission_denied(request) - if other_perms: - for other_perm in other_perms: + if global_perms: + for other_perm in global_perms: if not request.user.has_perm(other_perm): - LOGGER.debug("denying access for other", user=request.user, perm=perm) + LOGGER.debug("denying access for other", user=request.user, perm=other_perm) return self.permission_denied(request) return func(self, request, *args, **kwargs) diff --git a/authentik/api/pagination.py b/authentik/api/pagination.py index 7125c8968..402dbac9b 100644 --- a/authentik/api/pagination.py +++ b/authentik/api/pagination.py @@ -77,3 +77,10 @@ class Pagination(pagination.PageNumberPagination): }, "required": ["pagination", "results"], } + + +class SmallerPagination(Pagination): + """Smaller pagination for objects which might require a lot of queries + to retrieve all data for.""" + + max_page_size = 10 diff --git a/authentik/api/tests/test_viewsets.py b/authentik/api/tests/test_viewsets.py index dee956461..ac3d7da62 100644 --- a/authentik/api/tests/test_viewsets.py +++ b/authentik/api/tests/test_viewsets.py @@ -16,6 +16,7 @@ def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable: def tester(self: TestModelViewSets): self.assertIsNotNone(getattr(test_viewset, "search_fields", None)) + self.assertIsNotNone(getattr(test_viewset, "ordering", None)) filterset_class = getattr(test_viewset, "filterset_class", None) if not filterset_class: self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None)) diff --git a/authentik/blueprints/api.py b/authentik/blueprints/api.py index 9fac62c72..721eb5dcb 100644 --- a/authentik/blueprints/api.py +++ b/authentik/blueprints/api.py @@ -4,7 +4,6 @@ from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.fields import CharField, DateTimeField, JSONField -from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ListSerializer, ModelSerializer @@ -87,11 +86,11 @@ class BlueprintInstanceSerializer(ModelSerializer): class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet): """Blueprint instances""" - permission_classes = [IsAdminUser] serializer_class = BlueprintInstanceSerializer queryset = BlueprintInstance.objects.all() search_fields = ["name", "path"] filterset_fields = ["name", "path"] + ordering = ["name"] @extend_schema( responses={ diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 76c667c25..f2191548e 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -35,25 +35,28 @@ from authentik.core.models import ( Source, UserSourceConnection, ) +from authentik.enterprise.models import LicenseUsage from authentik.events.utils import cleanse_dict from authentik.flows.models import FlowToken, Stage from authentik.lib.models import SerializerModel from authentik.lib.sentry import SentryIgnoredException from authentik.outposts.models import OutpostServiceConnection from authentik.policies.models import Policy, PolicyBindingModel +from authentik.providers.scim.models import SCIMGroup, SCIMUser # Context set when the serializer is created in a blueprint context # Update website/developer-docs/blueprints/v1/models.md when used SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry" -def is_model_allowed(model: type[Model]) -> bool: - """Check if model is allowed""" +def excluded_models() -> list[type[Model]]: + """Return a list of all excluded models that shouldn't be exposed via API + or other means (internal only, base classes, non-used objects, etc)""" # pylint: disable=imported-auth-user from django.contrib.auth.models import Group as DjangoGroup from django.contrib.auth.models import User as DjangoUser - excluded_models = ( + return ( DjangoUser, DjangoGroup, # Base classes @@ -69,8 +72,15 @@ def is_model_allowed(model: type[Model]) -> bool: AuthenticatedSession, # Classes which are only internally managed FlowToken, + LicenseUsage, + SCIMGroup, + SCIMUser, ) - return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel)) + + +def is_model_allowed(model: type[Model]) -> bool: + """Check if model is allowed""" + return model not in excluded_models() and issubclass(model, (SerializerModel, BaseMetaModel)) class DoRollback(SentryIgnoredException): diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index f40aa3165..478181c28 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -17,7 +17,6 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet -from rest_framework_guardian.filters import ObjectPermissionsFilter from structlog.stdlib import get_logger from structlog.testing import capture_logs @@ -38,6 +37,7 @@ from authentik.lib.utils.file import ( from authentik.policies.api.exec import PolicyTestResultSerializer from authentik.policies.engine import PolicyEngine from authentik.policies.types import PolicyResult +from authentik.rbac.filters import ObjectFilter LOGGER = get_logger() @@ -122,7 +122,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: """Custom filter_queryset method which ignores guardian, but still supports sorting""" for backend in list(self.filter_backends): - if backend == ObjectPermissionsFilter: + if backend == ObjectFilter: continue queryset = backend().filter_queryset(self.request, queryset, self) return queryset diff --git a/authentik/core/api/groups.py b/authentik/core/api/groups.py index 961633037..4c6a8b509 100644 --- a/authentik/core/api/groups.py +++ b/authentik/core/api/groups.py @@ -2,7 +2,6 @@ from json import loads from typing import Optional -from django.db.models.query import QuerySet from django.http import Http404 from django_filters.filters import CharFilter, ModelMultipleChoiceFilter from django_filters.filterset import FilterSet @@ -14,12 +13,12 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError from rest_framework.viewsets import ModelViewSet -from rest_framework_guardian.filters import ObjectPermissionsFilter from authentik.api.decorators import permission_required from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import PassiveSerializer, is_dict from authentik.core.models import Group, User +from authentik.rbac.api.roles import RoleSerializer class GroupMemberSerializer(ModelSerializer): @@ -49,6 +48,12 @@ class GroupSerializer(ModelSerializer): users_obj = ListSerializer( child=GroupMemberSerializer(), read_only=True, source="users", required=False ) + roles_obj = ListSerializer( + child=RoleSerializer(), + read_only=True, + source="roles", + required=False, + ) parent_name = CharField(source="parent.name", read_only=True, allow_null=True) num_pk = IntegerField(read_only=True) @@ -71,8 +76,10 @@ class GroupSerializer(ModelSerializer): "parent", "parent_name", "users", - "attributes", "users_obj", + "attributes", + "roles", + "roles_obj", ] extra_kwargs = { "users": { @@ -138,19 +145,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet): filterset_class = GroupFilter ordering = ["name"] - def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: - """Custom filter_queryset method which ignores guardian, but still supports sorting""" - for backend in list(self.filter_backends): - if backend == ObjectPermissionsFilter: - continue - queryset = backend().filter_queryset(self.request, queryset, self) - return queryset - - def filter_queryset(self, queryset): - if self.request.user.has_perm("authentik_core.view_group"): - return self._filter_queryset_for_list(queryset) - return super().filter_queryset(queryset) - @permission_required(None, ["authentik_core.add_user"]) @extend_schema( request=UserAccountSerializer, diff --git a/authentik/core/api/transactional_applications.py b/authentik/core/api/transactional_applications.py index 9cc0ab0e5..19b6ea465 100644 --- a/authentik/core/api/transactional_applications.py +++ b/authentik/core/api/transactional_applications.py @@ -119,6 +119,7 @@ class TransactionApplicationResponseSerializer(PassiveSerializer): class TransactionalApplicationView(APIView): """Create provider and application and attach them in a single transaction""" + # TODO: Migrate to a more specific permission permission_classes = [IsAdminUser] @extend_schema( diff --git a/authentik/core/api/used_by.py b/authentik/core/api/used_by.py index 66f0a70dd..25e7d8295 100644 --- a/authentik/core/api/used_by.py +++ b/authentik/core/api/used_by.py @@ -73,6 +73,11 @@ class UsedByMixin: # but so we only apply them once, have a simple flag for the first object first_object = True + # TODO: This will only return the used-by references that the user can see + # Either we have to leak model information here to not make the list + # useless if the user doesn't have all permissions, or we need to double + # query and check if there is a difference between modes the user can see + # and can't see and add a warning for obj in get_objects_for_user( request.user, f"{app}.view_{model_name}", manager ).all(): diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index be59dc1c1..d4adacc97 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -7,7 +7,6 @@ from django.contrib.auth import update_session_auth_hash from django.contrib.sessions.backends.cache import KEY_PREFIX from django.core.cache import cache from django.db.models.functions import ExtractHour -from django.db.models.query import QuerySet from django.db.transaction import atomic from django.db.utils import IntegrityError from django.urls import reverse_lazy @@ -52,7 +51,6 @@ from rest_framework.serializers import ( ) from rest_framework.validators import UniqueValidator from rest_framework.viewsets import ModelViewSet -from rest_framework_guardian.filters import ObjectPermissionsFilter from structlog.stdlib import get_logger from authentik.admin.api.metrics import CoordinateSerializer @@ -205,6 +203,7 @@ class UserSelfSerializer(ModelSerializer): groups = SerializerMethodField() uid = CharField(read_only=True) settings = SerializerMethodField() + system_permissions = SerializerMethodField() @extend_schema_field( ListSerializer( @@ -226,6 +225,14 @@ class UserSelfSerializer(ModelSerializer): """Get user settings with tenant and group settings applied""" return user.group_attributes(self._context["request"]).get("settings", {}) + def get_system_permissions(self, user: User) -> list[str]: + """Get all system permissions assigned to the user""" + return list( + user.user_permissions.filter( + content_type__app_label="authentik_rbac", content_type__model="systempermission" + ).values_list("codename", flat=True) + ) + class Meta: model = User fields = [ @@ -240,6 +247,7 @@ class UserSelfSerializer(ModelSerializer): "uid", "settings", "type", + "system_permissions", ] extra_kwargs = { "is_active": {"read_only": True}, @@ -654,19 +662,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): return Response(status=204) - def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: - """Custom filter_queryset method which ignores guardian, but still supports sorting""" - for backend in list(self.filter_backends): - if backend == ObjectPermissionsFilter: - continue - queryset = backend().filter_queryset(self.request, queryset, self) - return queryset - - def filter_queryset(self, queryset): - if self.request.user.has_perm("authentik_core.view_user"): - return self._filter_queryset_for_list(queryset) - return super().filter_queryset(queryset) - @extend_schema( responses={ 200: inline_serializer( diff --git a/authentik/core/migrations/0032_group_roles.py b/authentik/core/migrations/0032_group_roles.py new file mode 100644 index 000000000..754b1bfba --- /dev/null +++ b/authentik/core/migrations/0032_group_roles.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.6 on 2023-10-11 13:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_core", "0031_alter_user_type"), + ("authentik_rbac", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="group", + options={"verbose_name": "Group", "verbose_name_plural": "Groups"}, + ), + migrations.AlterModelOptions( + name="token", + options={ + "permissions": [("view_token_key", "View token's key")], + "verbose_name": "Token", + "verbose_name_plural": "Tokens", + }, + ), + migrations.AlterModelOptions( + name="user", + options={ + "permissions": [ + ("reset_user_password", "Reset Password"), + ("impersonate", "Can impersonate other users"), + ("assign_user_permissions", "Can assign permissions to users"), + ("unassign_user_permissions", "Can unassign permissions from users"), + ], + "verbose_name": "User", + "verbose_name_plural": "Users", + }, + ), + migrations.AddField( + model_name="group", + name="roles", + field=models.ManyToManyField( + blank=True, related_name="ak_groups", to="authentik_rbac.role" + ), + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 7f4c25ca7..5365ef693 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -1,7 +1,7 @@ """authentik core models""" from datetime import timedelta from hashlib import sha256 -from typing import Any, Optional +from typing import Any, Optional, Self from uuid import uuid4 from deepmerge import always_merger @@ -88,6 +88,8 @@ class Group(SerializerModel): default=False, help_text=_("Users added to this group will be superusers.") ) + roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True) + parent = models.ForeignKey( "Group", blank=True, @@ -115,6 +117,38 @@ class Group(SerializerModel): """Recursively check if `user` is member of us, or any parent.""" return user.all_groups().filter(group_uuid=self.group_uuid).exists() + def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]: + """Recursively get all groups that have this as parent or are indirectly related""" + direct_groups = [] + if isinstance(self, QuerySet): + direct_groups = list(x for x in self.all().values_list("pk", flat=True).iterator()) + else: + direct_groups = [self.pk] + if len(direct_groups) < 1: + return Group.objects.none() + query = """ + WITH RECURSIVE parents AS ( + SELECT authentik_core_group.*, 0 AS relative_depth + FROM authentik_core_group + WHERE authentik_core_group.group_uuid = ANY(%s) + + UNION ALL + + SELECT authentik_core_group.*, parents.relative_depth + 1 + FROM authentik_core_group, parents + WHERE ( + authentik_core_group.group_uuid = parents.parent_id and + parents.relative_depth < 20 + ) + ) + SELECT group_uuid + FROM parents + GROUP BY group_uuid, name + ORDER BY name; + """ + group_pks = [group.pk for group in Group.objects.raw(query, [direct_groups]).iterator()] + return Group.objects.filter(pk__in=group_pks) + def __str__(self): return f"Group {self.name}" @@ -125,6 +159,8 @@ class Group(SerializerModel): "parent", ), ) + verbose_name = _("Group") + verbose_name_plural = _("Groups") class UserManager(DjangoUserManager): @@ -160,33 +196,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): """Recursively get all groups this user is a member of. At least one query is done to get the direct groups of the user, with groups there are at most 3 queries done""" - direct_groups = list( - x for x in self.ak_groups.all().values_list("pk", flat=True).iterator() - ) - if len(direct_groups) < 1: - return Group.objects.none() - query = """ - WITH RECURSIVE parents AS ( - SELECT authentik_core_group.*, 0 AS relative_depth - FROM authentik_core_group - WHERE authentik_core_group.group_uuid = ANY(%s) - - UNION ALL - - SELECT authentik_core_group.*, parents.relative_depth + 1 - FROM authentik_core_group, parents - WHERE ( - authentik_core_group.group_uuid = parents.parent_id and - parents.relative_depth < 20 - ) - ) - SELECT group_uuid - FROM parents - GROUP BY group_uuid, name - ORDER BY name; - """ - group_pks = [group.pk for group in Group.objects.raw(query, [direct_groups]).iterator()] - return Group.objects.filter(pk__in=group_pks) + return Group.children_recursive(self.ak_groups.all()) def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]: """Get a dictionary containing the attributes from all groups the user belongs to, @@ -261,12 +271,14 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): return get_avatar(self) class Meta: - permissions = ( - ("reset_user_password", "Reset Password"), - ("impersonate", "Can impersonate other users"), - ) verbose_name = _("User") verbose_name_plural = _("Users") + permissions = [ + ("reset_user_password", _("Reset Password")), + ("impersonate", _("Can impersonate other users")), + ("assign_user_permissions", _("Can assign permissions to users")), + ("unassign_user_permissions", _("Can unassign permissions from users")), + ] class Provider(SerializerModel): @@ -675,7 +687,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel): models.Index(fields=["identifier"]), models.Index(fields=["key"]), ] - permissions = (("view_token_key", "View token's key"),) + permissions = [("view_token_key", _("View token's key"))] class PropertyMapping(SerializerModel, ManagedModel): diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 76cbd38ff..31340fce9 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -7,6 +7,7 @@ from django.db.models import Model from django.db.models.signals import post_save, pre_delete, pre_save from django.dispatch import receiver from django.http.request import HttpRequest +from structlog.stdlib import get_logger from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider, User @@ -15,6 +16,8 @@ password_changed = Signal() # Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage login_failed = Signal() +LOGGER = get_logger() + @receiver(post_save, sender=Application) def post_save_application(sender: type[Model], instance, created: bool, **_): diff --git a/authentik/core/tests/utils.py b/authentik/core/tests/utils.py index 59294e6fd..da4294f42 100644 --- a/authentik/core/tests/utils.py +++ b/authentik/core/tests/utils.py @@ -21,10 +21,9 @@ def create_test_flow( ) -def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User: - """Generate a test-admin user""" +def create_test_user(name: Optional[str] = None, **kwargs) -> User: + """Generate a test user""" uid = generate_id(20) if not name else name - group = Group.objects.create(name=uid, is_superuser=True) kwargs.setdefault("email", f"{uid}@goauthentik.io") kwargs.setdefault("username", uid) user: User = User.objects.create( @@ -33,6 +32,13 @@ def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User: ) user.set_password(uid) user.save() + return user + + +def create_test_admin_user(name: Optional[str] = None, **kwargs) -> User: + """Generate a test-admin user""" + user = create_test_user(name, **kwargs) + group = Group.objects.create(name=user.name or name, is_superuser=True) group.users.add(user) return user diff --git a/authentik/enterprise/api.py b/authentik/enterprise/api.py index 6a215b1fe..fdf0a11fc 100644 --- a/authentik/enterprise/api.py +++ b/authentik/enterprise/api.py @@ -6,7 +6,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework.decorators import action from rest_framework.fields import BooleanField, CharField, DateTimeField, IntegerField -from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ModelSerializer @@ -84,7 +84,7 @@ class LicenseViewSet(UsedByMixin, ModelViewSet): 200: inline_serializer("InstallIDSerializer", {"install_id": CharField(required=True)}), }, ) - @action(detail=False, methods=["GET"], permission_classes=[IsAdminUser]) + @action(detail=False, methods=["GET"]) def get_install_id(self, request: Request) -> Response: """Get install_id""" return Response( diff --git a/authentik/enterprise/migrations/0002_rename_users_license_internal_users_and_more.py b/authentik/enterprise/migrations/0002_rename_users_license_internal_users_and_more.py index b3c199743..da574762e 100644 --- a/authentik/enterprise/migrations/0002_rename_users_license_internal_users_and_more.py +++ b/authentik/enterprise/migrations/0002_rename_users_license_internal_users_and_more.py @@ -33,4 +33,8 @@ class Migration(migrations.Migration): "verbose_name_plural": "License Usage Records", }, ), + migrations.AlterModelOptions( + name="license", + options={"verbose_name": "License", "verbose_name_plural": "Licenses"}, + ), ] diff --git a/authentik/enterprise/models.py b/authentik/enterprise/models.py index d10acd3ef..aca8f0b02 100644 --- a/authentik/enterprise/models.py +++ b/authentik/enterprise/models.py @@ -19,8 +19,10 @@ from django.utils.translation import gettext as _ from guardian.shortcuts import get_anonymous_user from jwt import PyJWTError, decode, get_unverified_header from rest_framework.exceptions import ValidationError +from rest_framework.serializers import BaseSerializer from authentik.core.models import ExpiringModel, User, UserTypes +from authentik.lib.models import SerializerModel from authentik.root.install_id import get_install_id @@ -151,7 +153,7 @@ class LicenseKey: return usage.record_date -class License(models.Model): +class License(SerializerModel): """An authentik enterprise license""" license_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) @@ -162,6 +164,12 @@ class License(models.Model): internal_users = models.BigIntegerField() external_users = models.BigIntegerField() + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.enterprise.api import LicenseSerializer + + return LicenseSerializer + @property def status(self) -> LicenseKey: """Get parsed license status""" @@ -169,6 +177,8 @@ class License(models.Model): class Meta: indexes = (HashIndex(fields=("key",)),) + verbose_name = _("License") + verbose_name_plural = _("Licenses") def usage_expiry(): diff --git a/authentik/flows/api/bindings.py b/authentik/flows/api/bindings.py index bda3ab323..96e2e4662 100644 --- a/authentik/flows/api/bindings.py +++ b/authentik/flows/api/bindings.py @@ -45,3 +45,4 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet): serializer_class = FlowStageBindingSerializer filterset_fields = "__all__" search_fields = ["stage__name"] + ordering = ["order"] diff --git a/authentik/flows/challenge.py b/authentik/flows/challenge.py index e1eeb56fd..eb968ce79 100644 --- a/authentik/flows/challenge.py +++ b/authentik/flows/challenge.py @@ -132,13 +132,6 @@ class PermissionDict(TypedDict): name: str -class PermissionSerializer(PassiveSerializer): - """Permission used for consent""" - - name = CharField(allow_blank=True) - id = CharField() - - class ChallengeResponse(PassiveSerializer): """Base class for all challenge responses""" diff --git a/authentik/flows/migrations/0026_alter_flow_options.py b/authentik/flows/migrations/0026_alter_flow_options.py new file mode 100644 index 000000000..c53d3774e --- /dev/null +++ b/authentik/flows/migrations/0026_alter_flow_options.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.6 on 2023-10-10 17:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="flow", + options={ + "permissions": [ + ("export_flow", "Can export a Flow"), + ("inspect_flow", "Can inspect a Flow's execution"), + ("view_flow_cache", "View Flow's cache metrics"), + ("clear_flow_cache", "Clear Flow's cache metrics"), + ], + "verbose_name": "Flow", + "verbose_name_plural": "Flows", + }, + ), + ] diff --git a/authentik/flows/models.py b/authentik/flows/models.py index f638ef04d..67f7f0a9c 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -194,9 +194,10 @@ class Flow(SerializerModel, PolicyBindingModel): verbose_name_plural = _("Flows") permissions = [ - ("export_flow", "Can export a Flow"), - ("view_flow_cache", "View Flow's cache metrics"), - ("clear_flow_cache", "Clear Flow's cache metrics"), + ("export_flow", _("Can export a Flow")), + ("inspect_flow", _("Can inspect a Flow's execution")), + ("view_flow_cache", _("View Flow's cache metrics")), + ("clear_flow_cache", _("Clear Flow's cache metrics")), ] diff --git a/authentik/flows/views/inspector.py b/authentik/flows/views/inspector.py index 3593c65da..4800ead6d 100644 --- a/authentik/flows/views/inspector.py +++ b/authentik/flows/views/inspector.py @@ -3,6 +3,7 @@ from hashlib import sha256 from typing import Any from django.conf import settings +from django.http import Http404 from django.http.request import HttpRequest from django.http.response import HttpResponse from django.shortcuts import get_object_or_404 @@ -11,7 +12,6 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.fields import BooleanField, ListField, SerializerMethodField -from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -68,21 +68,19 @@ class FlowInspectionSerializer(PassiveSerializer): class FlowInspectorView(APIView): """Flow inspector API""" - permission_classes = [IsAdminUser] - flow: Flow _logger: BoundLogger - - def check_permissions(self, request): - """Always allow access when in debug mode""" - if settings.DEBUG: - return None - return super().check_permissions(request) + permission_classes = [] def setup(self, request: HttpRequest, flow_slug: str): super().setup(request, flow_slug=flow_slug) - self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) self._logger = get_logger().bind(flow_slug=flow_slug) + self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) + if settings.DEBUG: + return + if request.user.has_perm("authentik_flow.inspect_flow", self.flow): + return + raise Http404 @extend_schema( responses={ diff --git a/authentik/lib/validators.py b/authentik/lib/validators.py new file mode 100644 index 000000000..7c67da8c1 --- /dev/null +++ b/authentik/lib/validators.py @@ -0,0 +1,32 @@ +"""Serializer validators""" +from typing import Optional + +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import ValidationError +from rest_framework.serializers import Serializer +from rest_framework.utils.representation import smart_repr + + +class RequiredTogetherValidator: + """Serializer-level validator that ensures all fields in `fields` are only + used together""" + + fields: list[str] + requires_context = True + message = _("The fields {field_names} must be used together.") + + def __init__(self, fields: list[str], message: Optional[str] = None) -> None: + self.fields = fields + self.message = message or self.message + + def __call__(self, attrs: dict, serializer: Serializer): + """Check that if any of the fields in `self.fields` are set, all of them must be set""" + if any(field in attrs for field in self.fields) and not all( + field in attrs for field in self.fields + ): + field_names = ", ".join(self.fields) + message = self.message.format(field_names=field_names) + raise ValidationError(message, code="required") + + def __repr__(self): + return "<%s(fields=%s)>" % (self.__class__.__name__, smart_repr(self.fields)) diff --git a/authentik/outposts/migrations/0020_alter_outpost_type.py b/authentik/outposts/migrations/0020_alter_outpost_type.py index 5036137c0..b643f8d47 100644 --- a/authentik/outposts/migrations/0020_alter_outpost_type.py +++ b/authentik/outposts/migrations/0020_alter_outpost_type.py @@ -28,4 +28,8 @@ class Migration(migrations.Migration): verbose_name="Managed by authentik", ), ), + migrations.AlterModelOptions( + name="outpost", + options={"verbose_name": "Outpost", "verbose_name_plural": "Outposts"}, + ), ] diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index 878a3e9e6..f876a0cf3 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -405,6 +405,10 @@ class Outpost(SerializerModel, ManagedModel): def __str__(self) -> str: return f"Outpost {self.name}" + class Meta: + verbose_name = _("Outpost") + verbose_name_plural = _("Outposts") + @dataclass class OutpostState: diff --git a/authentik/policies/models.py b/authentik/policies/models.py index 2e7d69c61..b99aeb549 100644 --- a/authentik/policies/models.py +++ b/authentik/policies/models.py @@ -190,8 +190,8 @@ class Policy(SerializerModel, CreatedUpdatedModel): verbose_name_plural = _("Policies") permissions = [ - ("view_policy_cache", "View Policy's cache metrics"), - ("clear_policy_cache", "Clear Policy's cache metrics"), + ("view_policy_cache", _("View Policy's cache metrics")), + ("clear_policy_cache", _("Clear Policy's cache metrics")), ] class PolicyMeta: diff --git a/authentik/rbac/__init__.py b/authentik/rbac/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/rbac/api/__init__.py b/authentik/rbac/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/rbac/api/rbac.py b/authentik/rbac/api/rbac.py new file mode 100644 index 000000000..3f468f483 --- /dev/null +++ b/authentik/rbac/api/rbac.py @@ -0,0 +1,130 @@ +"""common RBAC serializers""" +from django.apps import apps +from django.contrib.auth.models import Permission +from django.db.models import QuerySet +from django_filters.filters import ModelChoiceFilter +from django_filters.filterset import FilterSet +from rest_framework.exceptions import ValidationError +from rest_framework.fields import ( + CharField, + ChoiceField, + ListField, + ReadOnlyField, + SerializerMethodField, +) +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ReadOnlyModelViewSet + +from authentik.core.api.utils import PassiveSerializer +from authentik.core.models import User +from authentik.lib.validators import RequiredTogetherValidator +from authentik.policies.event_matcher.models import model_choices +from authentik.rbac.models import Role + + +class PermissionSerializer(ModelSerializer): + """Global permission""" + + app_label = ReadOnlyField(source="content_type.app_label") + app_label_verbose = SerializerMethodField() + model = ReadOnlyField(source="content_type.model") + model_verbose = SerializerMethodField() + + def get_app_label_verbose(self, instance: Permission) -> str: + """Human-readable app label""" + return apps.get_app_config(instance.content_type.app_label).verbose_name + + def get_model_verbose(self, instance: Permission) -> str: + """Human-readable model name""" + return apps.get_model( + instance.content_type.app_label, instance.content_type.model + )._meta.verbose_name + + class Meta: + model = Permission + fields = [ + "id", + "name", + "codename", + "model", + "app_label", + "app_label_verbose", + "model_verbose", + ] + + +class PermissionFilter(FilterSet): + """Filter permissions""" + + role = ModelChoiceFilter(queryset=Role.objects.all(), method="filter_role") + user = ModelChoiceFilter(queryset=User.objects.all()) + + def filter_role(self, queryset: QuerySet, name, value: Role) -> QuerySet: + """Filter permissions based on role""" + return queryset.filter(group__role=value) + + class Meta: + model = Permission + fields = [ + "codename", + "content_type__model", + "content_type__app_label", + "role", + "user", + ] + + +class RBACPermissionViewSet(ReadOnlyModelViewSet): + """Read-only list of all permissions, filterable by model and app""" + + queryset = Permission.objects.none() + serializer_class = PermissionSerializer + ordering = ["name"] + filterset_class = PermissionFilter + search_fields = [ + "codename", + "content_type__model", + "content_type__app_label", + ] + + def get_queryset(self) -> QuerySet: + return ( + Permission.objects.all() + .select_related("content_type") + .filter( + content_type__app_label__startswith="authentik", + ) + ) + + +class PermissionAssignSerializer(PassiveSerializer): + """Request to assign a new permission""" + + permissions = ListField(child=CharField()) + model = ChoiceField(choices=model_choices(), required=False) + object_pk = CharField(required=False) + + validators = [RequiredTogetherValidator(fields=["model", "object_pk"])] + + def validate(self, attrs: dict) -> dict: + model_instance = None + # Check if we're setting an object-level perm or global + model = attrs.get("model") + object_pk = attrs.get("object_pk") + if model and object_pk: + model = apps.get_model(attrs["model"]) + model_instance = model.objects.filter(pk=attrs["object_pk"]).first() + attrs["model_instance"] = model_instance + if attrs.get("model"): + return attrs + permissions = attrs.get("permissions", []) + if not all("." in perm for perm in permissions): + raise ValidationError( + { + "permissions": ( + "When assigning global permissions, codename must be given as " + "app_label.codename" + ) + } + ) + return attrs diff --git a/authentik/rbac/api/rbac_assigned_by_roles.py b/authentik/rbac/api/rbac_assigned_by_roles.py new file mode 100644 index 000000000..5dcdcab12 --- /dev/null +++ b/authentik/rbac/api/rbac_assigned_by_roles.py @@ -0,0 +1,123 @@ +"""common RBAC serializers""" +from django.db.models import Q, QuerySet +from django.db.transaction import atomic +from django_filters.filters import CharFilter, ChoiceFilter +from django_filters.filterset import FilterSet +from drf_spectacular.utils import OpenApiResponse, extend_schema +from guardian.models import GroupObjectPermission +from guardian.shortcuts import assign_perm, remove_perm +from rest_framework.decorators import action +from rest_framework.fields import CharField, ReadOnlyField +from rest_framework.mixins import ListModelMixin +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import GenericViewSet + +from authentik.api.decorators import permission_required +from authentik.core.api.utils import PassiveSerializer +from authentik.policies.event_matcher.models import model_choices +from authentik.rbac.api.rbac import PermissionAssignSerializer +from authentik.rbac.models import Role + + +class RoleObjectPermissionSerializer(ModelSerializer): + """Role-bound object level permission""" + + app_label = ReadOnlyField(source="content_type.app_label") + model = ReadOnlyField(source="content_type.model") + codename = ReadOnlyField(source="permission.codename") + name = ReadOnlyField(source="permission.name") + object_pk = ReadOnlyField() + + class Meta: + model = GroupObjectPermission + fields = ["id", "codename", "model", "app_label", "object_pk", "name"] + + +class RoleAssignedObjectPermissionSerializer(PassiveSerializer): + """Roles assigned object permission serializer""" + + role_pk = CharField(source="group.role.pk", read_only=True) + name = CharField(source="group.name", read_only=True) + permissions = RoleObjectPermissionSerializer( + many=True, source="group.groupobjectpermission_set" + ) + + class Meta: + model = Role + fields = ["role_pk", "name", "permissions"] + + +class RoleAssignedPermissionFilter(FilterSet): + """Role Assigned permission filter""" + + model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True) + object_pk = CharFilter(method="filter_object_pk") + + def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet: + """Filter by object type""" + app, _, model = value.partition(".") + return queryset.filter( + Q( + group__permissions__content_type__app_label=app, + group__permissions__content_type__model=model, + ) + | Q( + group__groupobjectpermission__permission__content_type__app_label=app, + group__groupobjectpermission__permission__content_type__model=model, + ) + ).distinct() + + def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet: + """Filter by object primary key""" + return queryset.filter(Q(group__groupobjectpermission__object_pk=value)).distinct() + + +class RoleAssignedPermissionViewSet(ListModelMixin, GenericViewSet): + """Get assigned object permissions for a single object""" + + serializer_class = RoleAssignedObjectPermissionSerializer + ordering = ["name"] + # The filtering is done in the filterset, + # which has a required filter that does the heavy lifting + queryset = Role.objects.all() + filterset_class = RoleAssignedPermissionFilter + + @permission_required("authentik_rbac.assign_role_permissions") + @extend_schema( + request=PermissionAssignSerializer(), + responses={ + 204: OpenApiResponse(description="Successfully assigned"), + }, + ) + @action(methods=["POST"], detail=True, pagination_class=None, filter_backends=[]) + def assign(self, request: Request, *args, **kwargs) -> Response: + """Assign permission(s) to role. When `object_pk` is set, the permissions + are only assigned to the specific object, otherwise they are assigned globally.""" + role: Role = self.get_object() + data = PermissionAssignSerializer(data=request.data) + data.is_valid(raise_exception=True) + with atomic(): + for perm in data.validated_data["permissions"]: + assign_perm(perm, role.group, data.validated_data["model_instance"]) + return Response(status=204) + + @permission_required("authentik_rbac.unassign_role_permissions") + @extend_schema( + request=PermissionAssignSerializer(), + responses={ + 204: OpenApiResponse(description="Successfully unassigned"), + }, + ) + @action(methods=["PATCH"], detail=True, pagination_class=None, filter_backends=[]) + def unassign(self, request: Request, *args, **kwargs) -> Response: + """Unassign permission(s) to role. When `object_pk` is set, the permissions + are only assigned to the specific object, otherwise they are assigned globally.""" + role: Role = self.get_object() + data = PermissionAssignSerializer(data=request.data) + data.is_valid(raise_exception=True) + with atomic(): + for perm in data.validated_data["permissions"]: + remove_perm(perm, role.group, data.validated_data["model_instance"]) + return Response(status=204) diff --git a/authentik/rbac/api/rbac_assigned_by_users.py b/authentik/rbac/api/rbac_assigned_by_users.py new file mode 100644 index 000000000..d69b30a52 --- /dev/null +++ b/authentik/rbac/api/rbac_assigned_by_users.py @@ -0,0 +1,129 @@ +"""common RBAC serializers""" +from django.db.models import Q, QuerySet +from django.db.transaction import atomic +from django_filters.filters import CharFilter, ChoiceFilter +from django_filters.filterset import FilterSet +from drf_spectacular.utils import OpenApiResponse, extend_schema +from guardian.models import UserObjectPermission +from guardian.shortcuts import assign_perm, remove_perm +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.fields import BooleanField, ReadOnlyField +from rest_framework.mixins import ListModelMixin +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import GenericViewSet + +from authentik.api.decorators import permission_required +from authentik.core.api.groups import GroupMemberSerializer +from authentik.core.models import User, UserTypes +from authentik.policies.event_matcher.models import model_choices +from authentik.rbac.api.rbac import PermissionAssignSerializer + + +class UserObjectPermissionSerializer(ModelSerializer): + """User-bound object level permission""" + + app_label = ReadOnlyField(source="content_type.app_label") + model = ReadOnlyField(source="content_type.model") + codename = ReadOnlyField(source="permission.codename") + name = ReadOnlyField(source="permission.name") + object_pk = ReadOnlyField() + + class Meta: + model = UserObjectPermission + fields = ["id", "codename", "model", "app_label", "object_pk", "name"] + + +class UserAssignedObjectPermissionSerializer(GroupMemberSerializer): + """Users assigned object permission serializer""" + + permissions = UserObjectPermissionSerializer(many=True, source="userobjectpermission_set") + is_superuser = BooleanField() + + class Meta: + model = GroupMemberSerializer.Meta.model + fields = GroupMemberSerializer.Meta.fields + ["permissions", "is_superuser"] + + +class UserAssignedPermissionFilter(FilterSet): + """Assigned permission filter""" + + model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True) + object_pk = CharFilter(method="filter_object_pk") + + def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet: + """Filter by object type""" + app, _, model = value.partition(".") + return queryset.filter( + Q( + user_permissions__content_type__app_label=app, + user_permissions__content_type__model=model, + ) + | Q( + userobjectpermission__permission__content_type__app_label=app, + userobjectpermission__permission__content_type__model=model, + ) + | Q(ak_groups__is_superuser=True) + ).distinct() + + def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet: + """Filter by object primary key""" + return queryset.filter( + Q(userobjectpermission__object_pk=value) | Q(ak_groups__is_superuser=True), + ).distinct() + + +class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet): + """Get assigned object permissions for a single object""" + + serializer_class = UserAssignedObjectPermissionSerializer + ordering = ["username"] + # The filtering is done in the filterset, + # which has a required filter that does the heavy lifting + queryset = User.objects.all() + filterset_class = UserAssignedPermissionFilter + + @permission_required("authentik_core.assign_user_permissions") + @extend_schema( + request=PermissionAssignSerializer(), + responses={ + 204: OpenApiResponse(description="Successfully assigned"), + }, + ) + @action(methods=["POST"], detail=True, pagination_class=None, filter_backends=[]) + def assign(self, request: Request, *args, **kwargs) -> Response: + """Assign permission(s) to user""" + user: User = self.get_object() + if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT: + raise ValidationError("Permissions cannot be assigned to an internal service account.") + data = PermissionAssignSerializer(data=request.data) + data.is_valid(raise_exception=True) + with atomic(): + for perm in data.validated_data["permissions"]: + assign_perm(perm, user, data.validated_data["model_instance"]) + return Response(status=204) + + @permission_required("authentik_core.unassign_user_permissions") + @extend_schema( + request=PermissionAssignSerializer(), + responses={ + 204: OpenApiResponse(description="Successfully unassigned"), + }, + ) + @action(methods=["PATCH"], detail=True, pagination_class=None, filter_backends=[]) + def unassign(self, request: Request, *args, **kwargs) -> Response: + """Unassign permission(s) to user. When `object_pk` is set, the permissions + are only assigned to the specific object, otherwise they are assigned globally.""" + user: User = self.get_object() + if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT: + raise ValidationError( + "Permissions cannot be unassigned from an internal service account." + ) + data = PermissionAssignSerializer(data=request.data) + data.is_valid(raise_exception=True) + with atomic(): + for perm in data.validated_data["permissions"]: + remove_perm(perm, user, data.validated_data["model_instance"]) + return Response(status=204) diff --git a/authentik/rbac/api/rbac_roles.py b/authentik/rbac/api/rbac_roles.py new file mode 100644 index 000000000..162a3225b --- /dev/null +++ b/authentik/rbac/api/rbac_roles.py @@ -0,0 +1,71 @@ +"""common RBAC serializers""" +from typing import Optional + +from django.apps import apps +from django_filters.filters import UUIDFilter +from django_filters.filterset import FilterSet +from guardian.models import GroupObjectPermission +from guardian.shortcuts import get_objects_for_group +from rest_framework.fields import SerializerMethodField +from rest_framework.mixins import ListModelMixin +from rest_framework.viewsets import GenericViewSet + +from authentik.api.pagination import SmallerPagination +from authentik.rbac.api.rbac_assigned_by_roles import RoleObjectPermissionSerializer + + +class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer): + """User permission with additional object-related data""" + + app_label_verbose = SerializerMethodField() + model_verbose = SerializerMethodField() + + object_description = SerializerMethodField() + + def get_app_label_verbose(self, instance: GroupObjectPermission) -> str: + """Get app label from permission's model""" + return apps.get_app_config(instance.content_type.app_label).verbose_name + + def get_model_verbose(self, instance: GroupObjectPermission) -> str: + """Get model label from permission's model""" + return apps.get_model( + instance.content_type.app_label, instance.content_type.model + )._meta.verbose_name + + def get_object_description(self, instance: GroupObjectPermission) -> Optional[str]: + """Get model description from attached model. This operation takes at least + one additional query, and the description is only shown if the user/role has the + view_ permission on the object""" + app_label = instance.content_type.app_label + model = instance.content_type.model + model_class = apps.get_model(app_label, model) + objects = get_objects_for_group(instance.group, f"{app_label}.view_{model}", model_class) + obj = objects.first() + if not obj: + return None + return str(obj) + + class Meta(RoleObjectPermissionSerializer.Meta): + fields = RoleObjectPermissionSerializer.Meta.fields + [ + "app_label_verbose", + "model_verbose", + "object_description", + ] + + +class RolePermissionFilter(FilterSet): + """Role permission filter""" + + uuid = UUIDFilter("group__role__uuid", required=True) + + +class RolePermissionViewSet(ListModelMixin, GenericViewSet): + """Get a role's assigned object permissions""" + + serializer_class = ExtraRoleObjectPermissionSerializer + ordering = ["group__role__name"] + pagination_class = SmallerPagination + # The filtering is done in the filterset, + # which has a required filter that does the heavy lifting + queryset = GroupObjectPermission.objects.select_related("content_type", "group__role").all() + filterset_class = RolePermissionFilter diff --git a/authentik/rbac/api/rbac_users.py b/authentik/rbac/api/rbac_users.py new file mode 100644 index 000000000..04f3fcabd --- /dev/null +++ b/authentik/rbac/api/rbac_users.py @@ -0,0 +1,71 @@ +"""common RBAC serializers""" +from typing import Optional + +from django.apps import apps +from django_filters.filters import NumberFilter +from django_filters.filterset import FilterSet +from guardian.models import UserObjectPermission +from guardian.shortcuts import get_objects_for_user +from rest_framework.fields import SerializerMethodField +from rest_framework.mixins import ListModelMixin +from rest_framework.viewsets import GenericViewSet + +from authentik.api.pagination import SmallerPagination +from authentik.rbac.api.rbac_assigned_by_users import UserObjectPermissionSerializer + + +class ExtraUserObjectPermissionSerializer(UserObjectPermissionSerializer): + """User permission with additional object-related data""" + + app_label_verbose = SerializerMethodField() + model_verbose = SerializerMethodField() + + object_description = SerializerMethodField() + + def get_app_label_verbose(self, instance: UserObjectPermission) -> str: + """Get app label from permission's model""" + return apps.get_app_config(instance.content_type.app_label).verbose_name + + def get_model_verbose(self, instance: UserObjectPermission) -> str: + """Get model label from permission's model""" + return apps.get_model( + instance.content_type.app_label, instance.content_type.model + )._meta.verbose_name + + def get_object_description(self, instance: UserObjectPermission) -> Optional[str]: + """Get model description from attached model. This operation takes at least + one additional query, and the description is only shown if the user/role has the + view_ permission on the object""" + app_label = instance.content_type.app_label + model = instance.content_type.model + model_class = apps.get_model(app_label, model) + objects = get_objects_for_user(instance.user, f"{app_label}.view_{model}", model_class) + obj = objects.first() + if not obj: + return None + return str(obj) + + class Meta(UserObjectPermissionSerializer.Meta): + fields = UserObjectPermissionSerializer.Meta.fields + [ + "app_label_verbose", + "model_verbose", + "object_description", + ] + + +class UserPermissionFilter(FilterSet): + """User-assigned permission filter""" + + user_id = NumberFilter("user__id", required=True) + + +class UserPermissionViewSet(ListModelMixin, GenericViewSet): + """Get a users's assigned object permissions""" + + serializer_class = ExtraUserObjectPermissionSerializer + ordering = ["user__username"] + pagination_class = SmallerPagination + # The filtering is done in the filterset, + # which has a required filter that does the heavy lifting + queryset = UserObjectPermission.objects.select_related("content_type", "user").all() + filterset_class = UserPermissionFilter diff --git a/authentik/rbac/api/roles.py b/authentik/rbac/api/roles.py new file mode 100644 index 000000000..36eef8a19 --- /dev/null +++ b/authentik/rbac/api/roles.py @@ -0,0 +1,24 @@ +"""RBAC Roles""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.used_by import UsedByMixin +from authentik.rbac.models import Role + + +class RoleSerializer(ModelSerializer): + """Role serializer""" + + class Meta: + model = Role + fields = ["pk", "name"] + + +class RoleViewSet(UsedByMixin, ModelViewSet): + """Role viewset""" + + serializer_class = RoleSerializer + queryset = Role.objects.all() + search_fields = ["group__name"] + ordering = ["group__name"] + filterset_fields = ["group__name"] diff --git a/authentik/rbac/apps.py b/authentik/rbac/apps.py new file mode 100644 index 000000000..f6b878c01 --- /dev/null +++ b/authentik/rbac/apps.py @@ -0,0 +1,15 @@ +"""authentik rbac app config""" +from authentik.blueprints.apps import ManagedAppConfig + + +class AuthentikRBACConfig(ManagedAppConfig): + """authentik rbac app config""" + + name = "authentik.rbac" + label = "authentik_rbac" + verbose_name = "authentik RBAC" + default = True + + def reconcile_load_rbac_signals(self): + """Load rbac signals""" + self.import_module("authentik.rbac.signals") diff --git a/authentik/rbac/filters.py b/authentik/rbac/filters.py new file mode 100644 index 000000000..395efd673 --- /dev/null +++ b/authentik/rbac/filters.py @@ -0,0 +1,26 @@ +"""RBAC API Filter""" +from django.db.models import QuerySet +from rest_framework.exceptions import PermissionDenied +from rest_framework.request import Request +from rest_framework_guardian.filters import ObjectPermissionsFilter + + +class ObjectFilter(ObjectPermissionsFilter): + """Object permission filter that grants global permission higher priority than + per-object permissions""" + + def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: + permission = self.perm_format % { + "app_label": queryset.model._meta.app_label, + "model_name": queryset.model._meta.model_name, + } + # having the global permission set on a user has higher priority than + # per-object permissions + if request.user.has_perm(permission): + return queryset + queryset = super().filter_queryset(request, queryset, view) + if not queryset.exists(): + # User doesn't have direct permission to all objects + # and also no object permissions assigned (directly or via role) + raise PermissionDenied() + return queryset diff --git a/authentik/rbac/migrations/0001_initial.py b/authentik/rbac/migrations/0001_initial.py new file mode 100644 index 000000000..85cabbf11 --- /dev/null +++ b/authentik/rbac/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.6 on 2023-10-11 13:37 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="Role", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.TextField(max_length=150, unique=True)), + ( + "group", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to="auth.group" + ), + ), + ], + options={ + "verbose_name": "Role", + "verbose_name_plural": "Roles", + "permissions": [ + ("assign_role_permissions", "Can assign permissions to users"), + ("unassign_role_permissions", "Can unassign permissions from users"), + ], + }, + ), + ] diff --git a/authentik/rbac/migrations/0002_systempermission.py b/authentik/rbac/migrations/0002_systempermission.py new file mode 100644 index 000000000..eb4a03bb4 --- /dev/null +++ b/authentik/rbac/migrations/0002_systempermission.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.6 on 2023-10-12 15:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_rbac", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="SystemPermission", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ], + options={ + "permissions": [ + ("view_system_info", "Can view system info"), + ("view_system_tasks", "Can view system tasks"), + ("run_system_tasks", "Can run system tasks"), + ("access_admin_interface", "Can access admin interface"), + ], + "managed": False, + "default_permissions": (), + }, + ), + ] diff --git a/authentik/rbac/migrations/__init__.py b/authentik/rbac/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/rbac/models.py b/authentik/rbac/models.py new file mode 100644 index 000000000..fe6096f7d --- /dev/null +++ b/authentik/rbac/models.py @@ -0,0 +1,73 @@ +"""RBAC models""" +from typing import Optional +from uuid import uuid4 + +from django.db import models +from django.db.transaction import atomic +from django.utils.translation import gettext_lazy as _ +from guardian.shortcuts import assign_perm +from rest_framework.serializers import BaseSerializer + +from authentik.lib.models import SerializerModel + + +class Role(SerializerModel): + """RBAC role, which can have different permissions (both global and per-object) attached + to it.""" + + uuid = models.UUIDField(default=uuid4, editable=False, unique=True, primary_key=True) + # Due to the way django and django-guardian work, this is somewhat of a hack. + # Django and django-guardian allow for setting permissions on users and groups, but they + # only allow for a custom user object, not a custom group object, which is why + # we have both authentik and django groups. With this model, we use the inbuilt group system + # for RBAC. This means that every Role needs a single django group that its assigned to + # which will hold all of the actual permissions + # The main advantage of that is that all the permission checking just works out of the box, + # as these permissions are checked by default by django and most other libraries that build + # on top of django + group = models.OneToOneField("auth.Group", on_delete=models.CASCADE) + + # name field has the same constraints as the group model + name = models.TextField(max_length=150, unique=True) + + def assign_permission(self, *perms: str, obj: Optional[models.Model] = None): + """Assign permission to role, can handle multiple permissions, + but when assigning multiple permissions to an object the permissions + must all belong to the object given""" + with atomic(): + for perm in perms: + assign_perm(perm, self.group, obj) + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.rbac.api.roles import RoleSerializer + + return RoleSerializer + + def __str__(self) -> str: + return f"Role {self.name}" + + class Meta: + verbose_name = _("Role") + verbose_name_plural = _("Roles") + permissions = [ + ("assign_role_permissions", _("Can assign permissions to users")), + ("unassign_role_permissions", _("Can unassign permissions from users")), + ] + + +class SystemPermission(models.Model): + """System-wide permissions that are not related to any direct + database model""" + + class Meta: + managed = False + default_permissions = () + verbose_name = _("System permission") + verbose_name_plural = _("System permissions") + permissions = [ + ("view_system_info", _("Can view system info")), + ("view_system_tasks", _("Can view system tasks")), + ("run_system_tasks", _("Can run system tasks")), + ("access_admin_interface", _("Can access admin interface")), + ] diff --git a/authentik/rbac/permissions.py b/authentik/rbac/permissions.py new file mode 100644 index 000000000..011a02715 --- /dev/null +++ b/authentik/rbac/permissions.py @@ -0,0 +1,30 @@ +"""RBAC Permissions""" +from django.db.models import Model +from rest_framework.permissions import BasePermission, DjangoObjectPermissions +from rest_framework.request import Request + + +class ObjectPermissions(DjangoObjectPermissions): + """RBAC Permissions""" + + def has_object_permission(self, request: Request, view, obj: Model): + queryset = self._queryset(view) + model_cls = queryset.model + perms = self.get_required_object_permissions(request.method, model_cls) + # Rank global permissions higher than per-object permissions + if request.user.has_perms(perms): + return True + return super().has_object_permission(request, view, obj) + + +# pylint: disable=invalid-name +def HasPermission(*perm: str) -> type[BasePermission]: + """Permission checker for any non-object permissions, returns + a BasePermission class that can be used with rest_framework""" + + # pylint: disable=missing-class-docstring, invalid-name + class checker(BasePermission): + def has_permission(self, request: Request, view): + return bool(request.user and request.user.has_perms(perm)) + + return checker diff --git a/authentik/rbac/signals.py b/authentik/rbac/signals.py new file mode 100644 index 000000000..f3bbbc036 --- /dev/null +++ b/authentik/rbac/signals.py @@ -0,0 +1,67 @@ +"""rbac signals""" +from django.contrib.auth.models import Group as DjangoGroup +from django.db.models.signals import m2m_changed, pre_save +from django.db.transaction import atomic +from django.dispatch import receiver +from structlog.stdlib import get_logger + +from authentik.core.models import Group +from authentik.rbac.models import Role + +LOGGER = get_logger() + + +@receiver(pre_save, sender=Role) +def rbac_role_pre_save(sender: type[Role], instance: Role, **_): + """Ensure role has a group object created for it""" + if hasattr(instance, "group"): + return + group, _ = DjangoGroup.objects.get_or_create(name=instance.name) + instance.group = group + + +@receiver(m2m_changed, sender=Group.roles.through) +def rbac_group_role_m2m(sender: type[Group], action: str, instance: Group, reverse: bool, **_): + """RBAC: Sync group members into roles when roles are assigned""" + if action not in ["post_add", "post_remove", "post_clear"]: + return + with atomic(): + group_users = list( + instance.children_recursive() + .exclude(users__isnull=True) + .values_list("users", flat=True) + ) + if not group_users: + return + for role in instance.roles.all(): + role: Role + role.group.user_set.set(group_users) + LOGGER.debug("Updated users in group", group=instance) + + +# pylint: disable=no-member +@receiver(m2m_changed, sender=Group.users.through) +def rbac_group_users_m2m( + sender: type[Group], action: str, instance: Group, pk_set: set, reverse: bool, **_ +): + """Handle Group/User m2m and mirror it to roles""" + if action not in ["post_add", "post_remove"]: + return + # reverse: instance is a Group, pk_set is a list of user pks + # non-reverse: instance is a User, pk_set is a list of groups + with atomic(): + if reverse: + for role in instance.roles.all(): + role: Role + if action == "post_add": + role.group.user_set.add(*pk_set) + elif action == "post_remove": + role.group.user_set.remove(*pk_set) + else: + for group in Group.objects.filter(pk__in=pk_set): + for role in group.roles.all(): + role: Role + if action == "post_add": + role.group.user_set.add(instance) + elif action == "post_remove": + role.group.user_set.remove(instance) diff --git a/authentik/rbac/tests/__init__.py b/authentik/rbac/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/rbac/tests/test_api_assigned_by_roles.py b/authentik/rbac/tests/test_api_assigned_by_roles.py new file mode 100644 index 000000000..07032e805 --- /dev/null +++ b/authentik/rbac/tests/test_api_assigned_by_roles.py @@ -0,0 +1,151 @@ +"""Test RoleAssignedPermissionViewSet api""" +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Group +from authentik.core.tests.utils import create_test_admin_user, create_test_user +from authentik.lib.generators import generate_id +from authentik.rbac.api.rbac_assigned_by_roles import RoleAssignedObjectPermissionSerializer +from authentik.rbac.models import Role +from authentik.stages.invitation.models import Invitation + + +class TestRBACRoleAPI(APITestCase): + """Test RoleAssignedPermissionViewSet api""" + + def setUp(self) -> None: + self.superuser = create_test_admin_user() + + self.user = create_test_user() + self.role = Role.objects.create(name=generate_id()) + self.group = Group.objects.create(name=generate_id()) + self.group.roles.add(self.role) + self.group.users.add(self.user) + + def test_filter_assigned(self): + """Test RoleAssignedPermissionViewSet's filters""" + inv = Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv) + # self.user doesn't have permissions to see their (object) permissions + self.client.force_login(self.superuser) + res = self.client.get( + reverse("authentik_api:permissions-assigned-by-roles-list"), + { + "model": "authentik_stages_invitation.invitation", + "object_pk": str(inv.pk), + "ordering": "pk", + }, + ) + self.assertEqual(res.status_code, 200) + self.assertJSONEqual( + res.content.decode(), + { + "pagination": { + "next": 0, + "previous": 0, + "count": 1, + "current": 1, + "total_pages": 1, + "start_index": 1, + "end_index": 1, + }, + "results": [ + RoleAssignedObjectPermissionSerializer(instance=self.role).data, + ], + }, + ) + + def test_assign_global(self): + """Test permission assign""" + self.client.force_login(self.superuser) + res = self.client.post( + reverse( + "authentik_api:permissions-assigned-by-roles-assign", + kwargs={ + "pk": self.role.pk, + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + }, + ) + self.assertEqual(res.status_code, 204) + self.assertTrue(self.user.has_perm("authentik_stages_invitation.view_invitation")) + + def test_assign_object(self): + """Test permission assign (object)""" + inv = Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + self.client.force_login(self.superuser) + res = self.client.post( + reverse( + "authentik_api:permissions-assigned-by-roles-assign", + kwargs={ + "pk": self.role.pk, + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + "model": "authentik_stages_invitation.invitation", + "object_pk": str(inv.pk), + }, + ) + self.assertEqual(res.status_code, 204) + self.assertTrue( + self.user.has_perm( + "authentik_stages_invitation.view_invitation", + inv, + ) + ) + + def test_unassign_global(self): + """Test permission unassign""" + self.role.assign_permission("authentik_stages_invitation.view_invitation") + self.client.force_login(self.superuser) + res = self.client.patch( + reverse( + "authentik_api:permissions-assigned-by-roles-unassign", + kwargs={ + "pk": str(self.role.pk), + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + }, + ) + self.assertEqual(res.status_code, 204) + self.assertFalse(self.user.has_perm("authentik_stages_invitation.view_invitation")) + + def test_unassign_object(self): + """Test permission unassign (object)""" + inv = Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv) + self.client.force_login(self.superuser) + res = self.client.patch( + reverse( + "authentik_api:permissions-assigned-by-roles-unassign", + kwargs={ + "pk": str(self.role.pk), + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + "model": "authentik_stages_invitation.invitation", + "object_pk": str(inv.pk), + }, + ) + self.assertEqual(res.status_code, 204) + self.assertFalse( + self.user.has_perm( + "authentik_stages_invitation.view_invitation", + inv, + ) + ) diff --git a/authentik/rbac/tests/test_api_assigned_by_users.py b/authentik/rbac/tests/test_api_assigned_by_users.py new file mode 100644 index 000000000..fa1238495 --- /dev/null +++ b/authentik/rbac/tests/test_api_assigned_by_users.py @@ -0,0 +1,196 @@ +"""Test UserAssignedPermissionViewSet api""" +from django.urls import reverse +from guardian.shortcuts import assign_perm +from rest_framework.test import APITestCase + +from authentik.core.models import Group, UserTypes +from authentik.core.tests.utils import create_test_admin_user, create_test_user +from authentik.lib.generators import generate_id +from authentik.rbac.api.rbac_assigned_by_users import UserAssignedObjectPermissionSerializer +from authentik.rbac.models import Role +from authentik.stages.invitation.models import Invitation + + +class TestRBACUserAPI(APITestCase): + """Test UserAssignedPermissionViewSet api""" + + def setUp(self) -> None: + self.superuser = create_test_admin_user() + + self.user = create_test_user() + self.role = Role.objects.create(name=generate_id()) + self.group = Group.objects.create(name=generate_id()) + self.group.roles.add(self.role) + self.group.users.add(self.user) + + def test_filter_assigned(self): + """Test UserAssignedPermissionViewSet's filters""" + inv = Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + assign_perm("authentik_stages_invitation.view_invitation", self.user, inv) + # self.user doesn't have permissions to see their (object) permissions + self.client.force_login(self.superuser) + res = self.client.get( + reverse("authentik_api:permissions-assigned-by-users-list"), + { + "model": "authentik_stages_invitation.invitation", + "object_pk": str(inv.pk), + "ordering": "pk", + }, + ) + self.assertEqual(res.status_code, 200) + self.assertJSONEqual( + res.content.decode(), + { + "pagination": { + "next": 0, + "previous": 0, + "count": 2, + "current": 1, + "total_pages": 1, + "start_index": 1, + "end_index": 2, + }, + "results": sorted( + [ + UserAssignedObjectPermissionSerializer(instance=self.user).data, + UserAssignedObjectPermissionSerializer(instance=self.superuser).data, + ], + key=lambda u: u["pk"], + ), + }, + ) + + def test_assign_global(self): + """Test permission assign""" + self.client.force_login(self.superuser) + res = self.client.post( + reverse( + "authentik_api:permissions-assigned-by-users-assign", + kwargs={ + "pk": self.user.pk, + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + }, + ) + self.assertEqual(res.status_code, 204) + self.assertTrue(self.user.has_perm("authentik_stages_invitation.view_invitation")) + + def test_assign_global_internal_sa(self): + """Test permission assign (to internal service account)""" + self.client.force_login(self.superuser) + self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT + self.user.save() + res = self.client.post( + reverse( + "authentik_api:permissions-assigned-by-users-assign", + kwargs={ + "pk": self.user.pk, + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + }, + ) + self.assertEqual(res.status_code, 400) + self.assertFalse(self.user.has_perm("authentik_stages_invitation.view_invitation")) + + def test_assign_object(self): + """Test permission assign (object)""" + inv = Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + self.client.force_login(self.superuser) + res = self.client.post( + reverse( + "authentik_api:permissions-assigned-by-users-assign", + kwargs={ + "pk": self.user.pk, + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + "model": "authentik_stages_invitation.invitation", + "object_pk": str(inv.pk), + }, + ) + self.assertEqual(res.status_code, 204) + self.assertTrue( + self.user.has_perm( + "authentik_stages_invitation.view_invitation", + inv, + ) + ) + + def test_unassign_global(self): + """Test permission unassign""" + assign_perm("authentik_stages_invitation.view_invitation", self.user) + self.client.force_login(self.superuser) + res = self.client.patch( + reverse( + "authentik_api:permissions-assigned-by-users-unassign", + kwargs={ + "pk": self.user.pk, + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + }, + ) + self.assertEqual(res.status_code, 204) + self.assertFalse(self.user.has_perm("authentik_stages_invitation.view_invitation")) + + def test_unassign_global_internal_sa(self): + """Test permission unassign (from internal service account)""" + self.client.force_login(self.superuser) + self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT + self.user.save() + assign_perm("authentik_stages_invitation.view_invitation", self.user) + self.client.force_login(self.superuser) + res = self.client.patch( + reverse( + "authentik_api:permissions-assigned-by-users-unassign", + kwargs={ + "pk": self.user.pk, + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + }, + ) + self.assertEqual(res.status_code, 400) + self.assertTrue(self.user.has_perm("authentik_stages_invitation.view_invitation")) + + def test_unassign_object(self): + """Test permission unassign (object)""" + inv = Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + assign_perm("authentik_stages_invitation.view_invitation", self.user, inv) + self.client.force_login(self.superuser) + res = self.client.patch( + reverse( + "authentik_api:permissions-assigned-by-users-unassign", + kwargs={ + "pk": self.user.pk, + }, + ), + { + "permissions": ["authentik_stages_invitation.view_invitation"], + "model": "authentik_stages_invitation.invitation", + "object_pk": str(inv.pk), + }, + ) + self.assertEqual(res.status_code, 204) + self.assertFalse( + self.user.has_perm( + "authentik_stages_invitation.view_invitation", + inv, + ) + ) diff --git a/authentik/rbac/tests/test_api_filters.py b/authentik/rbac/tests/test_api_filters.py new file mode 100644 index 000000000..91bd707d7 --- /dev/null +++ b/authentik/rbac/tests/test_api_filters.py @@ -0,0 +1,122 @@ +"""RBAC role tests""" +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Group +from authentik.core.tests.utils import create_test_admin_user, create_test_user +from authentik.lib.generators import generate_id +from authentik.rbac.models import Role +from authentik.stages.invitation.api import InvitationSerializer +from authentik.stages.invitation.models import Invitation + + +class TestAPIPerms(APITestCase): + """Test API Permission and filtering""" + + def setUp(self) -> None: + self.superuser = create_test_admin_user() + + self.user = create_test_user() + self.role = Role.objects.create(name=generate_id()) + self.group = Group.objects.create(name=generate_id()) + self.group.roles.add(self.role) + self.group.users.add(self.user) + + def test_list_simple(self): + """Test list (single object, role has global permission)""" + self.client.force_login(self.user) + self.role.assign_permission("authentik_stages_invitation.view_invitation") + + Invitation.objects.all().delete() + inv = Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + res = self.client.get(reverse("authentik_api:invitation-list")) + self.assertEqual(res.status_code, 200) + self.assertJSONEqual( + res.content.decode(), + { + "pagination": { + "next": 0, + "previous": 0, + "count": 1, + "current": 1, + "total_pages": 1, + "start_index": 1, + "end_index": 1, + }, + "results": [ + InvitationSerializer(instance=inv).data, + ], + }, + ) + + def test_list_object_perm(self): + """Test list""" + self.client.force_login(self.user) + + Invitation.objects.all().delete() + Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + inv2 = Invitation.objects.create( + name=generate_id(), + created_by=self.superuser, + ) + self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv2) + + res = self.client.get(reverse("authentik_api:invitation-list")) + self.assertEqual(res.status_code, 200) + self.assertJSONEqual( + res.content.decode(), + { + "pagination": { + "next": 0, + "previous": 0, + "count": 1, + "current": 1, + "total_pages": 1, + "start_index": 1, + "end_index": 1, + }, + "results": [ + InvitationSerializer(instance=inv2).data, + ], + }, + ) + + def test_list_denied(self): + """Test list without adding permission""" + self.client.force_login(self.user) + + res = self.client.get(reverse("authentik_api:invitation-list")) + self.assertEqual(res.status_code, 403) + self.assertJSONEqual( + res.content.decode(), + {"detail": "You do not have permission to perform this action."}, + ) + + def test_create_simple(self): + """Test create with permission""" + self.client.force_login(self.user) + self.role.assign_permission("authentik_stages_invitation.add_invitation") + res = self.client.post( + reverse("authentik_api:invitation-list"), + data={ + "name": generate_id(), + }, + ) + self.assertEqual(res.status_code, 201) + + def test_create_simple_denied(self): + """Test create without assigning permission""" + self.client.force_login(self.user) + res = self.client.post( + reverse("authentik_api:invitation-list"), + data={ + "name": generate_id(), + }, + ) + self.assertEqual(res.status_code, 403) diff --git a/authentik/rbac/tests/test_roles.py b/authentik/rbac/tests/test_roles.py new file mode 100644 index 000000000..f9cbfdabb --- /dev/null +++ b/authentik/rbac/tests/test_roles.py @@ -0,0 +1,35 @@ +"""RBAC role tests""" +from rest_framework.test import APITestCase + +from authentik.core.models import Group +from authentik.core.tests.utils import create_test_admin_user +from authentik.lib.generators import generate_id +from authentik.rbac.models import Role + + +class TestRoles(APITestCase): + """Test roles""" + + def test_role_create(self): + """Test creation""" + user = create_test_admin_user() + group = Group.objects.create(name=generate_id()) + role = Role.objects.create(name=generate_id()) + role.assign_permission("authentik_core.view_application") + group.roles.add(role) + group.users.add(user) + self.assertEqual(list(role.group.user_set.all()), [user]) + self.assertTrue(user.has_perm("authentik_core.view_application")) + + def test_role_create_remove(self): + """Test creation and remove""" + user = create_test_admin_user() + group = Group.objects.create(name=generate_id()) + role = Role.objects.create(name=generate_id()) + role.assign_permission("authentik_core.view_application") + group.roles.add(role) + group.users.add(user) + self.assertEqual(list(role.group.user_set.all()), [user]) + self.assertTrue(user.has_perm("authentik_core.view_application")) + user.delete() + self.assertEqual(list(role.group.user_set.all()), []) diff --git a/authentik/rbac/urls.py b/authentik/rbac/urls.py new file mode 100644 index 000000000..586264a50 --- /dev/null +++ b/authentik/rbac/urls.py @@ -0,0 +1,24 @@ +"""RBAC API urls""" +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 +from authentik.rbac.api.rbac_roles import RolePermissionViewSet +from authentik.rbac.api.rbac_users import UserPermissionViewSet +from authentik.rbac.api.roles import RoleViewSet + +api_urlpatterns = [ + ( + "rbac/permissions/assigned_by_users", + UserAssignedPermissionViewSet, + "permissions-assigned-by-users", + ), + ( + "rbac/permissions/assigned_by_roles", + RoleAssignedPermissionViewSet, + "permissions-assigned-by-roles", + ), + ("rbac/permissions/users", UserPermissionViewSet, "permissions-users"), + ("rbac/permissions/roles", RolePermissionViewSet, "permissions-roles"), + ("rbac/permissions", RBACPermissionViewSet), + ("rbac/roles", RoleViewSet), +] diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 3cb081862..ee31f2cc6 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -77,6 +77,7 @@ INSTALLED_APPS = [ "authentik.providers.radius", "authentik.providers.saml", "authentik.providers.scim", + "authentik.rbac", "authentik.recovery", "authentik.sources.ldap", "authentik.sources.oauth", @@ -156,7 +157,7 @@ REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "authentik.api.pagination.Pagination", "PAGE_SIZE": 100, "DEFAULT_FILTER_BACKENDS": [ - "rest_framework_guardian.filters.ObjectPermissionsFilter", + "authentik.rbac.filters.ObjectFilter", "django_filters.rest_framework.DjangoFilterBackend", "rest_framework.filters.OrderingFilter", "rest_framework.filters.SearchFilter", @@ -164,7 +165,7 @@ REST_FRAMEWORK = { "DEFAULT_PARSER_CLASSES": [ "rest_framework.parsers.JSONParser", ], - "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.DjangoObjectPermissions",), + "DEFAULT_PERMISSION_CLASSES": ("authentik.rbac.permissions.ObjectPermissions",), "DEFAULT_AUTHENTICATION_CLASSES": ( "authentik.api.authentication.TokenAuthentication", "rest_framework.authentication.SessionAuthentication", @@ -410,6 +411,9 @@ if DEBUG: INSTALLED_APPS.append("silk") SILKY_PYTHON_PROFILER = True MIDDLEWARE = ["silk.middleware.SilkyMiddleware"] + MIDDLEWARE + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"].append( + "rest_framework.renderers.BrowsableAPIRenderer" + ) INSTALLED_APPS.append("authentik.core") diff --git a/authentik/stages/authenticator_static/migrations/0009_throttling.py b/authentik/stages/authenticator_static/migrations/0009_throttling.py index 17690de2e..1883f8836 100644 --- a/authentik/stages/authenticator_static/migrations/0009_throttling.py +++ b/authentik/stages/authenticator_static/migrations/0009_throttling.py @@ -30,4 +30,12 @@ class Migration(migrations.Migration): name="staticdevice", options={"verbose_name": "Static device", "verbose_name_plural": "Static devices"}, ), + migrations.AlterModelOptions( + name="staticdevice", + options={"verbose_name": "Static Device", "verbose_name_plural": "Static Devices"}, + ), + migrations.AlterModelOptions( + name="statictoken", + options={"verbose_name": "Static Token", "verbose_name_plural": "Static Tokens"}, + ), ] diff --git a/authentik/stages/authenticator_static/models.py b/authentik/stages/authenticator_static/models.py index ac8b55b08..7ce345159 100644 --- a/authentik/stages/authenticator_static/models.py +++ b/authentik/stages/authenticator_static/models.py @@ -95,8 +95,8 @@ class StaticDevice(SerializerModel, ThrottlingMixin, Device): return match is not None class Meta(Device.Meta): - verbose_name = _("Static device") - verbose_name_plural = _("Static devices") + verbose_name = _("Static Device") + verbose_name_plural = _("Static Devices") class StaticToken(models.Model): @@ -124,3 +124,7 @@ class StaticToken(models.Model): """ return b32encode(urandom(5)).decode("utf-8").lower() + + class Meta: + verbose_name = _("Static Token") + verbose_name_plural = _("Static Tokens") diff --git a/authentik/stages/authenticator_totp/migrations/0010_alter_totpdevice_key.py b/authentik/stages/authenticator_totp/migrations/0010_alter_totpdevice_key.py index 436eaa38a..af007e4df 100644 --- a/authentik/stages/authenticator_totp/migrations/0010_alter_totpdevice_key.py +++ b/authentik/stages/authenticator_totp/migrations/0010_alter_totpdevice_key.py @@ -25,4 +25,8 @@ class Migration(migrations.Migration): name="totpdevice", options={"verbose_name": "TOTP device", "verbose_name_plural": "TOTP devices"}, ), + migrations.AlterModelOptions( + name="totpdevice", + options={"verbose_name": "TOTP Device", "verbose_name_plural": "TOTP Devices"}, + ), ] diff --git a/authentik/stages/authenticator_totp/models.py b/authentik/stages/authenticator_totp/models.py index 6828a8e2e..41bf2d2c8 100644 --- a/authentik/stages/authenticator_totp/models.py +++ b/authentik/stages/authenticator_totp/models.py @@ -241,5 +241,5 @@ class TOTPDevice(SerializerModel, ThrottlingMixin, Device): return None class Meta(Device.Meta): - verbose_name = _("TOTP device") - verbose_name_plural = _("TOTP devices") + verbose_name = _("TOTP Device") + verbose_name_plural = _("TOTP Devices") diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py index d8c42724f..61677559c 100644 --- a/authentik/stages/consent/stage.py +++ b/authentik/stages/consent/stage.py @@ -6,11 +6,11 @@ from django.http import HttpRequest, HttpResponse from django.utils.timezone import now from rest_framework.fields import CharField +from authentik.core.api.utils import PassiveSerializer from authentik.flows.challenge import ( Challenge, ChallengeResponse, ChallengeTypes, - PermissionSerializer, WithUserInfoChallenge, ) from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER @@ -25,12 +25,19 @@ PLAN_CONTEXT_CONSENT_EXTRA_PERMISSIONS = "consent_additional_permissions" SESSION_KEY_CONSENT_TOKEN = "authentik/stages/consent/token" # nosec +class ConsentPermissionSerializer(PassiveSerializer): + """Permission used for consent""" + + name = CharField(allow_blank=True) + id = CharField() + + class ConsentChallenge(WithUserInfoChallenge): """Challenge info for consent screens""" header_text = CharField(required=False) - permissions = PermissionSerializer(many=True) - additional_permissions = PermissionSerializer(many=True) + permissions = ConsentPermissionSerializer(many=True) + additional_permissions = ConsentPermissionSerializer(many=True) component = CharField(default="ak-stage-consent") token = CharField(required=True) diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py index 255c97b4b..7d484f598 100644 --- a/authentik/stages/prompt/api.py +++ b/authentik/stages/prompt/api.py @@ -71,6 +71,7 @@ class PromptViewSet(UsedByMixin, ModelViewSet): queryset = Prompt.objects.all().prefetch_related("promptstage_set") serializer_class = PromptSerializer + ordering = ["field_key"] filterset_fields = ["field_key", "name", "label", "type", "placeholder"] search_fields = ["field_key", "name", "label", "type", "placeholder"] diff --git a/blueprints/schema.json b/blueprints/schema.json index 9dc77b796..57f624087 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -1188,6 +1188,43 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_rbac.role" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "attrs": { + "$ref": "#/$defs/model_authentik_rbac.role" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_rbac.role" + } + } + }, { "type": "object", "required": [ @@ -2705,6 +2742,43 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_enterprise.license" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "attrs": { + "$ref": "#/$defs/model_authentik_enterprise.license" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_enterprise.license" + } + } + }, { "type": "object", "required": [ @@ -3372,6 +3446,7 @@ "authentik.providers.radius", "authentik.providers.saml", "authentik.providers.scim", + "authentik.rbac", "authentik.recovery", "authentik.sources.ldap", "authentik.sources.oauth", @@ -3443,6 +3518,7 @@ "authentik_providers_saml.samlpropertymapping", "authentik_providers_scim.scimprovider", "authentik_providers_scim.scimmapping", + "authentik_rbac.role", "authentik_sources_ldap.ldapsource", "authentik_sources_ldap.ldappropertymapping", "authentik_sources_oauth.oauthsource", @@ -3483,7 +3559,8 @@ "authentik_core.group", "authentik_core.user", "authentik_core.application", - "authentik_core.token" + "authentik_core.token", + "authentik_enterprise.license" ], "title": "Model", "description": "Match events created by selected model. When left empty, all models are matched. When an app is selected, all the application's models are matched." @@ -4944,6 +5021,18 @@ }, "required": [] }, + "model_authentik_rbac.role": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 150, + "minLength": 1, + "title": "Name" + } + }, + "required": [] + }, "model_authentik_sources_ldap.ldapsource": { "type": "object", "properties": { @@ -8405,6 +8494,13 @@ "type": "object", "additionalProperties": true, "title": "Attributes" + }, + "roles": { + "type": "array", + "items": { + "type": "integer" + }, + "title": "Roles" } }, "required": [] @@ -8599,6 +8695,17 @@ }, "required": [] }, + "model_authentik_enterprise.license": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "title": "Key" + } + }, + "required": [] + }, "model_authentik_blueprints.metaapplyblueprint": { "type": "object", "properties": { diff --git a/internal/outpost/ldap/search/memory/memory.go b/internal/outpost/ldap/search/memory/memory.go index d877b76e5..177099f7e 100644 --- a/internal/outpost/ldap/search/memory/memory.go +++ b/internal/outpost/ldap/search/memory/memory.go @@ -162,7 +162,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, for _, u := range g.UsersObj { if flag.UserPk == u.Pk { //TODO: Is there a better way to clone this object? - fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.GroupMember{u}) + fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.GroupMember{u}, []api.Role{}) fg.SetUsers([]int32{flag.UserPk}) if g.Parent.IsSet() { fg.SetParent(*g.Parent.Get()) diff --git a/schema.yml b/schema.yml index 03eed6d01..219633aa2 100644 --- a/schema.yml +++ b/schema.yml @@ -885,7 +885,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -918,7 +918,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -957,7 +957,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -995,7 +995,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -1113,7 +1113,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -1146,7 +1146,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -1185,7 +1185,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -1223,7 +1223,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -2030,7 +2030,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -2063,7 +2063,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -2102,7 +2102,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -2140,7 +2140,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -2170,7 +2170,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this Static device. + description: A unique integer value identifying this Static Device. required: true tags: - authenticators @@ -2262,7 +2262,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -2295,7 +2295,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -2334,7 +2334,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -2372,7 +2372,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -2402,7 +2402,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this TOTP device. + description: A unique integer value identifying this TOTP Device. required: true tags: - authenticators @@ -3379,7 +3379,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this group. + description: A UUID string identifying this Group. required: true tags: - core @@ -3413,7 +3413,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this group. + description: A UUID string identifying this Group. required: true tags: - core @@ -3453,7 +3453,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this group. + description: A UUID string identifying this Group. required: true tags: - core @@ -3492,7 +3492,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this group. + description: A UUID string identifying this Group. required: true tags: - core @@ -3523,7 +3523,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this group. + description: A UUID string identifying this Group. required: true tags: - core @@ -3562,7 +3562,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this group. + description: A UUID string identifying this Group. required: true tags: - core @@ -3601,7 +3601,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this group. + description: A UUID string identifying this Group. required: true tags: - core @@ -5653,7 +5653,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this license. + description: A UUID string identifying this License. required: true tags: - enterprise @@ -5687,7 +5687,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this license. + description: A UUID string identifying this License. required: true tags: - enterprise @@ -5727,7 +5727,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this license. + description: A UUID string identifying this License. required: true tags: - enterprise @@ -5766,7 +5766,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this license. + description: A UUID string identifying this License. required: true tags: - enterprise @@ -5797,7 +5797,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this license. + description: A UUID string identifying this License. required: true tags: - enterprise @@ -9120,7 +9120,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this outpost. + description: A UUID string identifying this Outpost. required: true tags: - outposts @@ -9154,7 +9154,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this outpost. + description: A UUID string identifying this Outpost. required: true tags: - outposts @@ -9194,7 +9194,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this outpost. + description: A UUID string identifying this Outpost. required: true tags: - outposts @@ -9233,7 +9233,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this outpost. + description: A UUID string identifying this Outpost. required: true tags: - outposts @@ -9312,7 +9312,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this outpost. + description: A UUID string identifying this Outpost. required: true tags: - outposts @@ -9349,7 +9349,7 @@ paths: schema: type: string format: uuid - description: A UUID string identifying this outpost. + description: A UUID string identifying this Outpost. required: true tags: - outposts @@ -17051,6 +17051,1068 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /rbac/permissions/: + get: + operationId: rbac_permissions_list + description: Read-only list of all permissions, filterable by model and app + parameters: + - in: query + name: codename + schema: + type: string + - in: query + name: content_type__app_label + schema: + type: string + - in: query + name: content_type__model + 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 + - in: query + name: role + schema: + type: string + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: user + schema: + type: integer + tags: + - rbac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedPermissionList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/{id}/: + get: + operationId: rbac_permissions_retrieve + description: Read-only list of all permissions, filterable by model and app + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this permission. + required: true + tags: + - rbac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Permission' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/assigned_by_roles/: + get: + operationId: rbac_permissions_assigned_by_roles_list + description: Get assigned object permissions for a single object + parameters: + - in: query + name: model + schema: + type: string + enum: + - authentik_blueprints.blueprintinstance + - authentik_core.application + - authentik_core.group + - authentik_core.token + - authentik_core.user + - authentik_crypto.certificatekeypair + - authentik_enterprise.license + - authentik_events.event + - authentik_events.notification + - authentik_events.notificationrule + - authentik_events.notificationtransport + - authentik_events.notificationwebhookmapping + - authentik_flows.flow + - authentik_flows.flowstagebinding + - authentik_outposts.dockerserviceconnection + - authentik_outposts.kubernetesserviceconnection + - authentik_outposts.outpost + - authentik_policies.policybinding + - authentik_policies_dummy.dummypolicy + - authentik_policies_event_matcher.eventmatcherpolicy + - authentik_policies_expiry.passwordexpirypolicy + - authentik_policies_expression.expressionpolicy + - authentik_policies_password.passwordpolicy + - authentik_policies_reputation.reputation + - authentik_policies_reputation.reputationpolicy + - authentik_providers_ldap.ldapprovider + - authentik_providers_oauth2.accesstoken + - authentik_providers_oauth2.authorizationcode + - authentik_providers_oauth2.oauth2provider + - authentik_providers_oauth2.refreshtoken + - authentik_providers_oauth2.scopemapping + - authentik_providers_proxy.proxyprovider + - authentik_providers_radius.radiusprovider + - authentik_providers_saml.samlpropertymapping + - authentik_providers_saml.samlprovider + - authentik_providers_scim.scimmapping + - authentik_providers_scim.scimprovider + - authentik_rbac.role + - authentik_sources_ldap.ldappropertymapping + - authentik_sources_ldap.ldapsource + - authentik_sources_oauth.oauthsource + - authentik_sources_oauth.useroauthsourceconnection + - authentik_sources_plex.plexsource + - authentik_sources_plex.plexsourceconnection + - authentik_sources_saml.samlsource + - authentik_sources_saml.usersamlsourceconnection + - authentik_stages_authenticator_duo.authenticatorduostage + - authentik_stages_authenticator_duo.duodevice + - authentik_stages_authenticator_sms.authenticatorsmsstage + - authentik_stages_authenticator_sms.smsdevice + - authentik_stages_authenticator_static.authenticatorstaticstage + - authentik_stages_authenticator_static.staticdevice + - authentik_stages_authenticator_totp.authenticatortotpstage + - authentik_stages_authenticator_totp.totpdevice + - authentik_stages_authenticator_validate.authenticatorvalidatestage + - authentik_stages_authenticator_webauthn.authenticatewebauthnstage + - authentik_stages_authenticator_webauthn.webauthndevice + - authentik_stages_captcha.captchastage + - authentik_stages_consent.consentstage + - authentik_stages_consent.userconsent + - authentik_stages_deny.denystage + - authentik_stages_dummy.dummystage + - authentik_stages_email.emailstage + - authentik_stages_identification.identificationstage + - authentik_stages_invitation.invitation + - authentik_stages_invitation.invitationstage + - authentik_stages_password.passwordstage + - authentik_stages_prompt.prompt + - authentik_stages_prompt.promptstage + - authentik_stages_user_delete.userdeletestage + - authentik_stages_user_login.userloginstage + - authentik_stages_user_logout.userlogoutstage + - authentik_stages_user_write.userwritestage + - authentik_tenants.tenant + description: |- + * `authentik_crypto.certificatekeypair` - Certificate-Key Pair + * `authentik_events.event` - Event + * `authentik_events.notificationtransport` - Notification Transport + * `authentik_events.notification` - Notification + * `authentik_events.notificationrule` - Notification Rule + * `authentik_events.notificationwebhookmapping` - Webhook Mapping + * `authentik_flows.flow` - Flow + * `authentik_flows.flowstagebinding` - Flow Stage Binding + * `authentik_outposts.dockerserviceconnection` - Docker Service-Connection + * `authentik_outposts.kubernetesserviceconnection` - Kubernetes Service-Connection + * `authentik_outposts.outpost` - Outpost + * `authentik_policies_dummy.dummypolicy` - Dummy Policy + * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy + * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy + * `authentik_policies_expression.expressionpolicy` - Expression Policy + * `authentik_policies_password.passwordpolicy` - Password Policy + * `authentik_policies_reputation.reputationpolicy` - Reputation Policy + * `authentik_policies_reputation.reputation` - Reputation Score + * `authentik_policies.policybinding` - Policy Binding + * `authentik_providers_ldap.ldapprovider` - LDAP Provider + * `authentik_providers_oauth2.scopemapping` - Scope Mapping + * `authentik_providers_oauth2.oauth2provider` - OAuth2/OpenID Provider + * `authentik_providers_oauth2.authorizationcode` - Authorization Code + * `authentik_providers_oauth2.accesstoken` - OAuth2 Access Token + * `authentik_providers_oauth2.refreshtoken` - OAuth2 Refresh Token + * `authentik_providers_proxy.proxyprovider` - Proxy Provider + * `authentik_providers_radius.radiusprovider` - Radius Provider + * `authentik_providers_saml.samlprovider` - SAML Provider + * `authentik_providers_saml.samlpropertymapping` - SAML Property Mapping + * `authentik_providers_scim.scimprovider` - SCIM Provider + * `authentik_providers_scim.scimmapping` - SCIM Mapping + * `authentik_rbac.role` - Role + * `authentik_sources_ldap.ldapsource` - LDAP Source + * `authentik_sources_ldap.ldappropertymapping` - LDAP Property Mapping + * `authentik_sources_oauth.oauthsource` - OAuth Source + * `authentik_sources_oauth.useroauthsourceconnection` - User OAuth Source Connection + * `authentik_sources_plex.plexsource` - Plex Source + * `authentik_sources_plex.plexsourceconnection` - User Plex Source Connection + * `authentik_sources_saml.samlsource` - SAML Source + * `authentik_sources_saml.usersamlsourceconnection` - User SAML Source Connection + * `authentik_stages_authenticator_duo.authenticatorduostage` - Duo Authenticator Setup Stage + * `authentik_stages_authenticator_duo.duodevice` - Duo Device + * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage + * `authentik_stages_authenticator_sms.smsdevice` - SMS Device + * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage + * `authentik_stages_authenticator_static.staticdevice` - Static Device + * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage + * `authentik_stages_authenticator_totp.totpdevice` - TOTP Device + * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage + * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage + * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device + * `authentik_stages_captcha.captchastage` - Captcha Stage + * `authentik_stages_consent.consentstage` - Consent Stage + * `authentik_stages_consent.userconsent` - User Consent + * `authentik_stages_deny.denystage` - Deny Stage + * `authentik_stages_dummy.dummystage` - Dummy Stage + * `authentik_stages_email.emailstage` - Email Stage + * `authentik_stages_identification.identificationstage` - Identification Stage + * `authentik_stages_invitation.invitationstage` - Invitation Stage + * `authentik_stages_invitation.invitation` - Invitation + * `authentik_stages_password.passwordstage` - Password Stage + * `authentik_stages_prompt.prompt` - Prompt + * `authentik_stages_prompt.promptstage` - Prompt Stage + * `authentik_stages_user_delete.userdeletestage` - User Delete Stage + * `authentik_stages_user_login.userloginstage` - User Login Stage + * `authentik_stages_user_logout.userlogoutstage` - User Logout Stage + * `authentik_stages_user_write.userwritestage` - User Write Stage + * `authentik_tenants.tenant` - Tenant + * `authentik_blueprints.blueprintinstance` - Blueprint Instance + * `authentik_core.group` - Group + * `authentik_core.user` - User + * `authentik_core.application` - Application + * `authentik_core.token` - Token + * `authentik_enterprise.license` - License + required: true + - in: query + name: object_pk + 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/PaginatedRoleAssignedObjectPermissionList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/assigned_by_roles/{uuid}/assign/: + post: + operationId: rbac_permissions_assigned_by_roles_assign_create + description: |- + Assign permission(s) to role. When `object_pk` is set, the permissions + are only assigned to the specific object, otherwise they are assigned globally. + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Role. + required: true + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionAssignRequest' + required: true + security: + - authentik: [] + responses: + '204': + description: Successfully assigned + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/assigned_by_roles/{uuid}/unassign/: + patch: + operationId: rbac_permissions_assigned_by_roles_unassign_partial_update + description: |- + Unassign permission(s) to role. When `object_pk` is set, the permissions + are only assigned to the specific object, otherwise they are assigned globally. + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Role. + required: true + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedPermissionAssignRequest' + security: + - authentik: [] + responses: + '204': + description: Successfully unassigned + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/assigned_by_users/: + get: + operationId: rbac_permissions_assigned_by_users_list + description: Get assigned object permissions for a single object + parameters: + - in: query + name: model + schema: + type: string + enum: + - authentik_blueprints.blueprintinstance + - authentik_core.application + - authentik_core.group + - authentik_core.token + - authentik_core.user + - authentik_crypto.certificatekeypair + - authentik_enterprise.license + - authentik_events.event + - authentik_events.notification + - authentik_events.notificationrule + - authentik_events.notificationtransport + - authentik_events.notificationwebhookmapping + - authentik_flows.flow + - authentik_flows.flowstagebinding + - authentik_outposts.dockerserviceconnection + - authentik_outposts.kubernetesserviceconnection + - authentik_outposts.outpost + - authentik_policies.policybinding + - authentik_policies_dummy.dummypolicy + - authentik_policies_event_matcher.eventmatcherpolicy + - authentik_policies_expiry.passwordexpirypolicy + - authentik_policies_expression.expressionpolicy + - authentik_policies_password.passwordpolicy + - authentik_policies_reputation.reputation + - authentik_policies_reputation.reputationpolicy + - authentik_providers_ldap.ldapprovider + - authentik_providers_oauth2.accesstoken + - authentik_providers_oauth2.authorizationcode + - authentik_providers_oauth2.oauth2provider + - authentik_providers_oauth2.refreshtoken + - authentik_providers_oauth2.scopemapping + - authentik_providers_proxy.proxyprovider + - authentik_providers_radius.radiusprovider + - authentik_providers_saml.samlpropertymapping + - authentik_providers_saml.samlprovider + - authentik_providers_scim.scimmapping + - authentik_providers_scim.scimprovider + - authentik_rbac.role + - authentik_sources_ldap.ldappropertymapping + - authentik_sources_ldap.ldapsource + - authentik_sources_oauth.oauthsource + - authentik_sources_oauth.useroauthsourceconnection + - authentik_sources_plex.plexsource + - authentik_sources_plex.plexsourceconnection + - authentik_sources_saml.samlsource + - authentik_sources_saml.usersamlsourceconnection + - authentik_stages_authenticator_duo.authenticatorduostage + - authentik_stages_authenticator_duo.duodevice + - authentik_stages_authenticator_sms.authenticatorsmsstage + - authentik_stages_authenticator_sms.smsdevice + - authentik_stages_authenticator_static.authenticatorstaticstage + - authentik_stages_authenticator_static.staticdevice + - authentik_stages_authenticator_totp.authenticatortotpstage + - authentik_stages_authenticator_totp.totpdevice + - authentik_stages_authenticator_validate.authenticatorvalidatestage + - authentik_stages_authenticator_webauthn.authenticatewebauthnstage + - authentik_stages_authenticator_webauthn.webauthndevice + - authentik_stages_captcha.captchastage + - authentik_stages_consent.consentstage + - authentik_stages_consent.userconsent + - authentik_stages_deny.denystage + - authentik_stages_dummy.dummystage + - authentik_stages_email.emailstage + - authentik_stages_identification.identificationstage + - authentik_stages_invitation.invitation + - authentik_stages_invitation.invitationstage + - authentik_stages_password.passwordstage + - authentik_stages_prompt.prompt + - authentik_stages_prompt.promptstage + - authentik_stages_user_delete.userdeletestage + - authentik_stages_user_login.userloginstage + - authentik_stages_user_logout.userlogoutstage + - authentik_stages_user_write.userwritestage + - authentik_tenants.tenant + description: |- + * `authentik_crypto.certificatekeypair` - Certificate-Key Pair + * `authentik_events.event` - Event + * `authentik_events.notificationtransport` - Notification Transport + * `authentik_events.notification` - Notification + * `authentik_events.notificationrule` - Notification Rule + * `authentik_events.notificationwebhookmapping` - Webhook Mapping + * `authentik_flows.flow` - Flow + * `authentik_flows.flowstagebinding` - Flow Stage Binding + * `authentik_outposts.dockerserviceconnection` - Docker Service-Connection + * `authentik_outposts.kubernetesserviceconnection` - Kubernetes Service-Connection + * `authentik_outposts.outpost` - Outpost + * `authentik_policies_dummy.dummypolicy` - Dummy Policy + * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy + * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy + * `authentik_policies_expression.expressionpolicy` - Expression Policy + * `authentik_policies_password.passwordpolicy` - Password Policy + * `authentik_policies_reputation.reputationpolicy` - Reputation Policy + * `authentik_policies_reputation.reputation` - Reputation Score + * `authentik_policies.policybinding` - Policy Binding + * `authentik_providers_ldap.ldapprovider` - LDAP Provider + * `authentik_providers_oauth2.scopemapping` - Scope Mapping + * `authentik_providers_oauth2.oauth2provider` - OAuth2/OpenID Provider + * `authentik_providers_oauth2.authorizationcode` - Authorization Code + * `authentik_providers_oauth2.accesstoken` - OAuth2 Access Token + * `authentik_providers_oauth2.refreshtoken` - OAuth2 Refresh Token + * `authentik_providers_proxy.proxyprovider` - Proxy Provider + * `authentik_providers_radius.radiusprovider` - Radius Provider + * `authentik_providers_saml.samlprovider` - SAML Provider + * `authentik_providers_saml.samlpropertymapping` - SAML Property Mapping + * `authentik_providers_scim.scimprovider` - SCIM Provider + * `authentik_providers_scim.scimmapping` - SCIM Mapping + * `authentik_rbac.role` - Role + * `authentik_sources_ldap.ldapsource` - LDAP Source + * `authentik_sources_ldap.ldappropertymapping` - LDAP Property Mapping + * `authentik_sources_oauth.oauthsource` - OAuth Source + * `authentik_sources_oauth.useroauthsourceconnection` - User OAuth Source Connection + * `authentik_sources_plex.plexsource` - Plex Source + * `authentik_sources_plex.plexsourceconnection` - User Plex Source Connection + * `authentik_sources_saml.samlsource` - SAML Source + * `authentik_sources_saml.usersamlsourceconnection` - User SAML Source Connection + * `authentik_stages_authenticator_duo.authenticatorduostage` - Duo Authenticator Setup Stage + * `authentik_stages_authenticator_duo.duodevice` - Duo Device + * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage + * `authentik_stages_authenticator_sms.smsdevice` - SMS Device + * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage + * `authentik_stages_authenticator_static.staticdevice` - Static Device + * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage + * `authentik_stages_authenticator_totp.totpdevice` - TOTP Device + * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage + * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage + * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device + * `authentik_stages_captcha.captchastage` - Captcha Stage + * `authentik_stages_consent.consentstage` - Consent Stage + * `authentik_stages_consent.userconsent` - User Consent + * `authentik_stages_deny.denystage` - Deny Stage + * `authentik_stages_dummy.dummystage` - Dummy Stage + * `authentik_stages_email.emailstage` - Email Stage + * `authentik_stages_identification.identificationstage` - Identification Stage + * `authentik_stages_invitation.invitationstage` - Invitation Stage + * `authentik_stages_invitation.invitation` - Invitation + * `authentik_stages_password.passwordstage` - Password Stage + * `authentik_stages_prompt.prompt` - Prompt + * `authentik_stages_prompt.promptstage` - Prompt Stage + * `authentik_stages_user_delete.userdeletestage` - User Delete Stage + * `authentik_stages_user_login.userloginstage` - User Login Stage + * `authentik_stages_user_logout.userlogoutstage` - User Logout Stage + * `authentik_stages_user_write.userwritestage` - User Write Stage + * `authentik_tenants.tenant` - Tenant + * `authentik_blueprints.blueprintinstance` - Blueprint Instance + * `authentik_core.group` - Group + * `authentik_core.user` - User + * `authentik_core.application` - Application + * `authentik_core.token` - Token + * `authentik_enterprise.license` - License + required: true + - in: query + name: object_pk + 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/PaginatedUserAssignedObjectPermissionList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/assigned_by_users/{id}/assign/: + post: + operationId: rbac_permissions_assigned_by_users_assign_create + description: Assign permission(s) to user + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User. + required: true + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionAssignRequest' + required: true + security: + - authentik: [] + responses: + '204': + description: Successfully assigned + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/assigned_by_users/{id}/unassign/: + patch: + operationId: rbac_permissions_assigned_by_users_unassign_partial_update + description: |- + Unassign permission(s) to user. When `object_pk` is set, the permissions + are only assigned to the specific object, otherwise they are assigned globally. + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User. + required: true + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedPermissionAssignRequest' + security: + - authentik: [] + responses: + '204': + description: Successfully unassigned + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/roles/: + get: + operationId: rbac_permissions_roles_list + description: Get a role's assigned object permissions + parameters: + - 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 + - in: query + name: uuid + schema: + type: string + format: uuid + required: true + tags: + - rbac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedExtraRoleObjectPermissionList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/permissions/users/: + get: + operationId: rbac_permissions_users_list + description: Get a users's assigned object permissions + parameters: + - 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 + - in: query + name: user_id + schema: + type: integer + required: true + tags: + - rbac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedExtraUserObjectPermissionList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/roles/: + get: + operationId: rbac_roles_list + description: Role viewset + parameters: + - in: query + name: group__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/PaginatedRoleList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: rbac_roles_create + description: Role viewset + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RoleRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /rbac/roles/{uuid}/: + get: + operationId: rbac_roles_retrieve + description: Role viewset + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Role. + required: true + tags: + - rbac + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: rbac_roles_update + description: Role viewset + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Role. + required: true + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RoleRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: rbac_roles_partial_update + description: Role viewset + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Role. + required: true + tags: + - rbac + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedRoleRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: rbac_roles_destroy + description: Role viewset + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Role. + 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/roles/{uuid}/used_by/: + get: + operationId: rbac_roles_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Role. + 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: '' /root/config/: get: operationId: root_config_retrieve @@ -26725,6 +27787,7 @@ components: - authentik.providers.radius - authentik.providers.saml - authentik.providers.scim + - authentik.rbac - authentik.recovery - authentik.sources.ldap - authentik.sources.oauth @@ -26775,6 +27838,7 @@ components: * `authentik.providers.radius` - authentik Providers.Radius * `authentik.providers.saml` - authentik Providers.SAML * `authentik.providers.scim` - authentik Providers.SCIM + * `authentik.rbac` - authentik RBAC * `authentik.recovery` - authentik Recovery * `authentik.sources.ldap` - authentik Sources.LDAP * `authentik.sources.oauth` - authentik Sources.OAuth @@ -28495,11 +29559,11 @@ components: permissions: type: array items: - $ref: '#/components/schemas/Permission' + $ref: '#/components/schemas/ConsentPermission' additional_permissions: type: array items: - $ref: '#/components/schemas/Permission' + $ref: '#/components/schemas/ConsentPermission' token: type: string required: @@ -28522,6 +29586,17 @@ components: minLength: 1 required: - token + ConsentPermission: + type: object + description: Permission used for consent + properties: + name: + type: string + id: + type: string + required: + - id + - name ConsentStage: type: object description: ConsentStage Serializer @@ -29510,6 +30585,7 @@ components: * `authentik.providers.radius` - authentik Providers.Radius * `authentik.providers.saml` - authentik Providers.SAML * `authentik.providers.scim` - authentik Providers.SCIM + * `authentik.rbac` - authentik RBAC * `authentik.recovery` - authentik Recovery * `authentik.sources.ldap` - authentik Sources.LDAP * `authentik.sources.oauth` - authentik Sources.OAuth @@ -29556,7 +30632,7 @@ components: * `authentik_flows.flowstagebinding` - Flow Stage Binding * `authentik_outposts.dockerserviceconnection` - Docker Service-Connection * `authentik_outposts.kubernetesserviceconnection` - Kubernetes Service-Connection - * `authentik_outposts.outpost` - outpost + * `authentik_outposts.outpost` - Outpost * `authentik_policies_dummy.dummypolicy` - Dummy Policy * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy @@ -29577,6 +30653,7 @@ components: * `authentik_providers_saml.samlpropertymapping` - SAML Property Mapping * `authentik_providers_scim.scimprovider` - SCIM Provider * `authentik_providers_scim.scimmapping` - SCIM Mapping + * `authentik_rbac.role` - Role * `authentik_sources_ldap.ldapsource` - LDAP Source * `authentik_sources_ldap.ldappropertymapping` - LDAP Property Mapping * `authentik_sources_oauth.oauthsource` - OAuth Source @@ -29590,9 +30667,9 @@ components: * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage * `authentik_stages_authenticator_sms.smsdevice` - SMS Device * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage - * `authentik_stages_authenticator_static.staticdevice` - Static device + * `authentik_stages_authenticator_static.staticdevice` - Static Device * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage - * `authentik_stages_authenticator_totp.totpdevice` - TOTP device + * `authentik_stages_authenticator_totp.totpdevice` - TOTP Device * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device @@ -29614,10 +30691,11 @@ components: * `authentik_stages_user_write.userwritestage` - User Write Stage * `authentik_tenants.tenant` - Tenant * `authentik_blueprints.blueprintinstance` - Blueprint Instance - * `authentik_core.group` - group + * `authentik_core.group` - Group * `authentik_core.user` - User * `authentik_core.application` - Application * `authentik_core.token` - Token + * `authentik_enterprise.license` - License required: - bound_to - component @@ -29703,6 +30781,7 @@ components: * `authentik.providers.radius` - authentik Providers.Radius * `authentik.providers.saml` - authentik Providers.SAML * `authentik.providers.scim` - authentik Providers.SCIM + * `authentik.rbac` - authentik RBAC * `authentik.recovery` - authentik Recovery * `authentik.sources.ldap` - authentik Sources.LDAP * `authentik.sources.oauth` - authentik Sources.OAuth @@ -29749,7 +30828,7 @@ components: * `authentik_flows.flowstagebinding` - Flow Stage Binding * `authentik_outposts.dockerserviceconnection` - Docker Service-Connection * `authentik_outposts.kubernetesserviceconnection` - Kubernetes Service-Connection - * `authentik_outposts.outpost` - outpost + * `authentik_outposts.outpost` - Outpost * `authentik_policies_dummy.dummypolicy` - Dummy Policy * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy @@ -29770,6 +30849,7 @@ components: * `authentik_providers_saml.samlpropertymapping` - SAML Property Mapping * `authentik_providers_scim.scimprovider` - SCIM Provider * `authentik_providers_scim.scimmapping` - SCIM Mapping + * `authentik_rbac.role` - Role * `authentik_sources_ldap.ldapsource` - LDAP Source * `authentik_sources_ldap.ldappropertymapping` - LDAP Property Mapping * `authentik_sources_oauth.oauthsource` - OAuth Source @@ -29783,9 +30863,9 @@ components: * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage * `authentik_stages_authenticator_sms.smsdevice` - SMS Device * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage - * `authentik_stages_authenticator_static.staticdevice` - Static device + * `authentik_stages_authenticator_static.staticdevice` - Static Device * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage - * `authentik_stages_authenticator_totp.totpdevice` - TOTP device + * `authentik_stages_authenticator_totp.totpdevice` - TOTP Device * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device @@ -29807,10 +30887,11 @@ components: * `authentik_stages_user_write.userwritestage` - User Write Stage * `authentik_tenants.tenant` - Tenant * `authentik_blueprints.blueprintinstance` - Blueprint Instance - * `authentik_core.group` - group + * `authentik_core.group` - Group * `authentik_core.user` - User * `authentik_core.application` - Application * `authentik_core.token` - Token + * `authentik_enterprise.license` - License required: - name EventRequest: @@ -29948,6 +31029,106 @@ components: required: - expression - name + ExtraRoleObjectPermission: + type: object + description: User permission with additional object-related data + properties: + id: + type: integer + readOnly: true + codename: + type: string + readOnly: true + model: + type: string + title: Python model class name + readOnly: true + app_label: + type: string + readOnly: true + object_pk: + type: string + title: Object ID + readOnly: true + name: + type: string + readOnly: true + app_label_verbose: + type: string + description: Get app label from permission's model + readOnly: true + model_verbose: + type: string + description: Get model label from permission's model + readOnly: true + object_description: + type: string + nullable: true + description: |- + Get model description from attached model. This operation takes at least + one additional query, and the description is only shown if the user/role has the + view_ permission on the object + readOnly: true + required: + - app_label + - app_label_verbose + - codename + - id + - model + - model_verbose + - name + - object_description + - object_pk + ExtraUserObjectPermission: + type: object + description: User permission with additional object-related data + properties: + id: + type: integer + readOnly: true + codename: + type: string + readOnly: true + model: + type: string + title: Python model class name + readOnly: true + app_label: + type: string + readOnly: true + object_pk: + type: string + title: Object ID + readOnly: true + name: + type: string + readOnly: true + app_label_verbose: + type: string + description: Get app label from permission's model + readOnly: true + model_verbose: + type: string + description: Get model label from permission's model + readOnly: true + object_description: + type: string + nullable: true + description: |- + Get model description from attached model. This operation takes at least + one additional query, and the description is only shown if the user/role has the + view_ permission on the object + readOnly: true + required: + - app_label + - app_label_verbose + - codename + - id + - model + - model_verbose + - name + - object_description + - object_pk FilePathRequest: type: object description: Serializer to upload file @@ -30548,19 +31729,30 @@ components: type: array items: type: integer - attributes: - type: object - additionalProperties: {} users_obj: type: array items: $ref: '#/components/schemas/GroupMember' readOnly: true + attributes: + type: object + additionalProperties: {} + roles: + type: array + items: + type: string + format: uuid + roles_obj: + type: array + items: + $ref: '#/components/schemas/Role' + readOnly: true required: - name - num_pk - parent_name - pk + - roles_obj - users_obj GroupMember: type: object @@ -30661,6 +31853,11 @@ components: attributes: type: object additionalProperties: {} + roles: + type: array + items: + type: string + format: uuid required: - name IdentificationChallenge: @@ -31930,6 +33127,7 @@ components: - authentik_providers_saml.samlpropertymapping - authentik_providers_scim.scimprovider - authentik_providers_scim.scimmapping + - authentik_rbac.role - authentik_sources_ldap.ldapsource - authentik_sources_ldap.ldappropertymapping - authentik_sources_oauth.oauthsource @@ -31971,6 +33169,7 @@ components: - authentik_core.user - authentik_core.application - authentik_core.token + - authentik_enterprise.license type: string description: |- * `authentik_crypto.certificatekeypair` - Certificate-Key Pair @@ -31983,7 +33182,7 @@ components: * `authentik_flows.flowstagebinding` - Flow Stage Binding * `authentik_outposts.dockerserviceconnection` - Docker Service-Connection * `authentik_outposts.kubernetesserviceconnection` - Kubernetes Service-Connection - * `authentik_outposts.outpost` - outpost + * `authentik_outposts.outpost` - Outpost * `authentik_policies_dummy.dummypolicy` - Dummy Policy * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy @@ -32004,6 +33203,7 @@ components: * `authentik_providers_saml.samlpropertymapping` - SAML Property Mapping * `authentik_providers_scim.scimprovider` - SCIM Provider * `authentik_providers_scim.scimmapping` - SCIM Mapping + * `authentik_rbac.role` - Role * `authentik_sources_ldap.ldapsource` - LDAP Source * `authentik_sources_ldap.ldappropertymapping` - LDAP Property Mapping * `authentik_sources_oauth.oauthsource` - OAuth Source @@ -32017,9 +33217,9 @@ components: * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage * `authentik_stages_authenticator_sms.smsdevice` - SMS Device * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage - * `authentik_stages_authenticator_static.staticdevice` - Static device + * `authentik_stages_authenticator_static.staticdevice` - Static Device * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage - * `authentik_stages_authenticator_totp.totpdevice` - TOTP device + * `authentik_stages_authenticator_totp.totpdevice` - TOTP Device * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device @@ -32041,10 +33241,11 @@ components: * `authentik_stages_user_write.userwritestage` - User Write Stage * `authentik_tenants.tenant` - Tenant * `authentik_blueprints.blueprintinstance` - Blueprint Instance - * `authentik_core.group` - group + * `authentik_core.group` - Group * `authentik_core.user` - User * `authentik_core.application` - Application * `authentik_core.token` - Token + * `authentik_enterprise.license` - License NameIdPolicyEnum: enum: - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress @@ -33292,6 +34493,30 @@ components: required: - pagination - results + PaginatedExtraRoleObjectPermissionList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/ExtraRoleObjectPermission' + required: + - pagination + - results + PaginatedExtraUserObjectPermissionList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/ExtraUserObjectPermission' + required: + - pagination + - results PaginatedFlowList: type: object properties: @@ -33556,6 +34781,18 @@ components: required: - pagination - results + PaginatedPermissionList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/Permission' + required: + - pagination + - results PaginatedPlexSourceConnectionList: type: object properties: @@ -33724,6 +34961,30 @@ components: required: - pagination - results + PaginatedRoleAssignedObjectPermissionList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/RoleAssignedObjectPermission' + required: + - pagination + - results + PaginatedRoleList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/Role' + required: + - pagination + - results PaginatedSAMLPropertyMappingList: type: object properties: @@ -33904,6 +35165,18 @@ components: required: - pagination - results + PaginatedUserAssignedObjectPermissionList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/UserAssignedObjectPermission' + required: + - pagination + - results PaginatedUserConsentList: type: object properties: @@ -34927,6 +36200,7 @@ components: * `authentik.providers.radius` - authentik Providers.Radius * `authentik.providers.saml` - authentik Providers.SAML * `authentik.providers.scim` - authentik Providers.SCIM + * `authentik.rbac` - authentik RBAC * `authentik.recovery` - authentik Recovery * `authentik.sources.ldap` - authentik Sources.LDAP * `authentik.sources.oauth` - authentik Sources.OAuth @@ -34973,7 +36247,7 @@ components: * `authentik_flows.flowstagebinding` - Flow Stage Binding * `authentik_outposts.dockerserviceconnection` - Docker Service-Connection * `authentik_outposts.kubernetesserviceconnection` - Kubernetes Service-Connection - * `authentik_outposts.outpost` - outpost + * `authentik_outposts.outpost` - Outpost * `authentik_policies_dummy.dummypolicy` - Dummy Policy * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy @@ -34994,6 +36268,7 @@ components: * `authentik_providers_saml.samlpropertymapping` - SAML Property Mapping * `authentik_providers_scim.scimprovider` - SCIM Provider * `authentik_providers_scim.scimmapping` - SCIM Mapping + * `authentik_rbac.role` - Role * `authentik_sources_ldap.ldapsource` - LDAP Source * `authentik_sources_ldap.ldappropertymapping` - LDAP Property Mapping * `authentik_sources_oauth.oauthsource` - OAuth Source @@ -35007,9 +36282,9 @@ components: * `authentik_stages_authenticator_sms.authenticatorsmsstage` - SMS Authenticator Setup Stage * `authentik_stages_authenticator_sms.smsdevice` - SMS Device * `authentik_stages_authenticator_static.authenticatorstaticstage` - Static Authenticator Stage - * `authentik_stages_authenticator_static.staticdevice` - Static device + * `authentik_stages_authenticator_static.staticdevice` - Static Device * `authentik_stages_authenticator_totp.authenticatortotpstage` - TOTP Authenticator Setup Stage - * `authentik_stages_authenticator_totp.totpdevice` - TOTP device + * `authentik_stages_authenticator_totp.totpdevice` - TOTP Device * `authentik_stages_authenticator_validate.authenticatorvalidatestage` - Authenticator Validation Stage * `authentik_stages_authenticator_webauthn.authenticatewebauthnstage` - WebAuthn Authenticator Setup Stage * `authentik_stages_authenticator_webauthn.webauthndevice` - WebAuthn Device @@ -35031,10 +36306,11 @@ components: * `authentik_stages_user_write.userwritestage` - User Write Stage * `authentik_tenants.tenant` - Tenant * `authentik_blueprints.blueprintinstance` - Blueprint Instance - * `authentik_core.group` - group + * `authentik_core.group` - Group * `authentik_core.user` - User * `authentik_core.application` - Application * `authentik_core.token` - Token + * `authentik_enterprise.license` - License PatchedEventRequest: type: object description: Event Serializer @@ -35184,6 +36460,11 @@ components: attributes: type: object additionalProperties: {} + roles: + type: array + items: + type: string + format: uuid PatchedIdentificationStageRequest: type: object description: IdentificationStage Serializer @@ -35891,6 +37172,20 @@ components: minimum: -2147483648 description: How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage. + PatchedPermissionAssignRequest: + type: object + description: Request to assign a new permission + properties: + permissions: + type: array + items: + type: string + minLength: 1 + model: + $ref: '#/components/schemas/ModelEnum' + object_pk: + type: string + minLength: 1 PatchedPlexSourceConnectionRequest: type: object description: Plex Source connection Serializer @@ -36198,6 +37493,14 @@ components: type: integer maximum: 2147483647 minimum: -2147483648 + PatchedRoleRequest: + type: object + description: Role serializer + properties: + name: + type: string + minLength: 1 + maxLength: 150 PatchedSAMLPropertyMappingRequest: type: object description: SAMLPropertyMapping Serializer @@ -36744,15 +38047,56 @@ components: maxLength: 200 Permission: type: object - description: Permission used for consent + description: Global permission properties: + id: + type: integer + readOnly: true name: type: string - id: + maxLength: 255 + codename: type: string + maxLength: 100 + model: + type: string + title: Python model class name + readOnly: true + app_label: + type: string + readOnly: true + app_label_verbose: + type: string + description: Human-readable app label + readOnly: true + model_verbose: + type: string + description: Human-readable model name + readOnly: true required: + - app_label + - app_label_verbose + - codename - id + - model + - model_verbose - name + PermissionAssignRequest: + type: object + description: Request to assign a new permission + properties: + permissions: + type: array + items: + type: string + minLength: 1 + model: + $ref: '#/components/schemas/ModelEnum' + object_pk: + type: string + minLength: 1 + required: + - permissions PlexAuthenticationChallenge: type: object description: Challenge shown to the user in identification stage @@ -38280,6 +39624,80 @@ components: * `discouraged` - Discouraged * `preferred` - Preferred * `required` - Required + Role: + type: object + description: Role serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Uuid + name: + type: string + maxLength: 150 + required: + - name + - pk + RoleAssignedObjectPermission: + type: object + description: Roles assigned object permission serializer + properties: + role_pk: + type: string + readOnly: true + name: + type: string + readOnly: true + permissions: + type: array + items: + $ref: '#/components/schemas/RoleObjectPermission' + required: + - name + - permissions + - role_pk + RoleObjectPermission: + type: object + description: Role-bound object level permission + properties: + id: + type: integer + readOnly: true + codename: + type: string + readOnly: true + model: + type: string + title: Python model class name + readOnly: true + app_label: + type: string + readOnly: true + object_pk: + type: string + title: Object ID + readOnly: true + name: + type: string + readOnly: true + required: + - app_label + - codename + - id + - model + - name + - object_pk + RoleRequest: + type: object + description: Role serializer + properties: + name: + type: string + minLength: 1 + maxLength: 150 + required: + - name SAMLMetadata: type: object description: SAML Provider Metadata serializer @@ -40176,6 +41594,56 @@ components: type: integer required: - pk + UserAssignedObjectPermission: + type: object + description: Users assigned object permission serializer + properties: + pk: + type: integer + readOnly: true + title: ID + username: + type: string + description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ + only. + pattern: ^[\w.@+-]+$ + maxLength: 150 + name: + type: string + description: User's display name. + is_active: + type: boolean + title: Active + description: Designates whether this user should be treated as active. Unselect + this instead of deleting accounts. + last_login: + type: string + format: date-time + nullable: true + email: + type: string + format: email + title: Email address + maxLength: 254 + attributes: + type: object + additionalProperties: {} + uid: + type: string + readOnly: true + permissions: + type: array + items: + $ref: '#/components/schemas/UserObjectPermission' + is_superuser: + type: boolean + required: + - is_superuser + - name + - permissions + - pk + - uid + - username UserConsent: type: object description: UserConsent Serializer @@ -40564,6 +42032,37 @@ components: required: - identifier - user + UserObjectPermission: + type: object + description: User-bound object level permission + properties: + id: + type: integer + readOnly: true + codename: + type: string + readOnly: true + model: + type: string + title: Python model class name + readOnly: true + app_label: + type: string + readOnly: true + object_pk: + type: string + title: Object ID + readOnly: true + name: + type: string + readOnly: true + required: + - app_label + - codename + - id + - model + - name + - object_pk UserPasswordSetRequest: type: object properties: @@ -40705,6 +42204,12 @@ components: readOnly: true type: $ref: '#/components/schemas/UserTypeEnum' + system_permissions: + type: array + items: + type: string + description: Get all system permissions assigned to the user + readOnly: true required: - avatar - groups @@ -40713,6 +42218,7 @@ components: - name - pk - settings + - system_permissions - uid - username UserSelfGroups: diff --git a/web/src/admin/AdminInterface.ts b/web/src/admin/AdminInterface.ts index fa6d4efa5..44d927ae2 100644 --- a/web/src/admin/AdminInterface.ts +++ b/web/src/admin/AdminInterface.ts @@ -116,7 +116,11 @@ export class AdminInterface extends Interface { configureSentry(true); this.version = await new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve(); this.user = await me(); - if (!this.user.user.isSuperuser && this.user.user.pk > 0) { + const canAccessAdmin = + this.user.user.isSuperuser || + // TODO: somehow add `access_admin_interface` to the API schema + this.user.user.systemPermissions.includes("access_admin_interface"); + if (!canAccessAdmin && this.user.user.pk > 0) { window.location.assign("/if/user"); } } @@ -211,6 +215,7 @@ export class AdminInterface extends Interface { [null, msg("Directory"), null, [ ["/identity/users", msg("Users"), [`^/identity/users/(?${ID_REGEX})$`]], ["/identity/groups", msg("Groups"), [`^/identity/groups/(?${UUID_REGEX})$`]], + ["/identity/roles", msg("Roles"), [`^/identity/roles/(?${UUID_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 55a830835..1c7a9739e 100644 --- a/web/src/admin/Routes.ts +++ b/web/src/admin/Routes.ts @@ -80,6 +80,14 @@ export const ROUTES: Route[] = [ await import("@goauthentik/admin/users/UserViewPage"); return html``; }), + new Route(new RegExp("^/identity/roles$"), async () => { + await import("@goauthentik/admin/roles/RoleListPage"); + return html``; + }), + new Route(new RegExp(`^/identity/roles/(?${UUID_REGEX})$`), async (args) => { + await import("@goauthentik/admin/roles/RoleViewPage"); + return html``; + }), new Route(new RegExp("^/flow/stages/invitations$"), async () => { await import("@goauthentik/admin/stages/invitation/InvitationListPage"); return html``; diff --git a/web/src/admin/admin-overview/cards/AdminStatusCard.ts b/web/src/admin/admin-overview/cards/AdminStatusCard.ts index b773a78bc..d20f5d570 100644 --- a/web/src/admin/admin-overview/cards/AdminStatusCard.ts +++ b/web/src/admin/admin-overview/cards/AdminStatusCard.ts @@ -2,9 +2,12 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { PFSize } from "@goauthentik/elements/Spinner"; import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard"; +import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { until } from "lit/directives/until.js"; +import { ResponseError } from "@goauthentik/api"; + export interface AdminStatus { icon: string; message?: TemplateResult; @@ -41,6 +44,12 @@ export abstract class AdminStatusCard extends AggregateCard { ${status.message ? html`

${status.message}

` : html``}`; + }) + .catch((exc: ResponseError) => { + return html`

+  ${exc.response.statusText} +

+

${msg("Failed to fetch")}

`; }), html``, )} diff --git a/web/src/admin/applications/ApplicationViewPage.ts b/web/src/admin/applications/ApplicationViewPage.ts index f65182ca7..84ea2f207 100644 --- a/web/src/admin/applications/ApplicationViewPage.ts +++ b/web/src/admin/applications/ApplicationViewPage.ts @@ -3,6 +3,7 @@ import "@goauthentik/admin/applications/ApplicationCheckAccessForm"; import "@goauthentik/admin/applications/ApplicationForm"; import "@goauthentik/admin/policies/BoundPoliciesList"; import { PFSize } from "@goauthentik/app/elements/Spinner"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/components/ak-app-icon"; import "@goauthentik/components/events/ObjectChangelog"; @@ -27,7 +28,12 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { Application, CoreApi, OutpostsApi } from "@goauthentik/api"; +import { + Application, + CoreApi, + OutpostsApi, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; @customElement("ak-application-view") export class ApplicationViewPage extends AKElement { @@ -299,6 +305,12 @@ export class ApplicationViewPage extends AKElement { + `; } } diff --git a/web/src/admin/blueprints/BlueprintListPage.ts b/web/src/admin/blueprints/BlueprintListPage.ts index e2e06351d..f038cc957 100644 --- a/web/src/admin/blueprints/BlueprintListPage.ts +++ b/web/src/admin/blueprints/BlueprintListPage.ts @@ -7,6 +7,7 @@ import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -18,7 +19,12 @@ import { customElement, property } from "lit/decorators.js"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; -import { BlueprintInstance, BlueprintInstanceStatusEnum, ManagedApi } from "@goauthentik/api"; +import { + BlueprintInstance, + BlueprintInstanceStatusEnum, + ManagedApi, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; export function BlueprintStatus(blueprint?: BlueprintInstance): string { if (!blueprint) return ""; @@ -151,6 +157,11 @@ export class BlueprintListPage extends TablePage { + + { diff --git a/web/src/admin/crypto/CertificateKeyPairListPage.ts b/web/src/admin/crypto/CertificateKeyPairListPage.ts index c6959ea40..990f3446e 100644 --- a/web/src/admin/crypto/CertificateKeyPairListPage.ts +++ b/web/src/admin/crypto/CertificateKeyPairListPage.ts @@ -6,6 +6,7 @@ import { PFColor } from "@goauthentik/elements/Label"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -17,7 +18,11 @@ import { customElement, property } from "lit/decorators.js"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; -import { CertificateKeyPair, CryptoApi } from "@goauthentik/api"; +import { + CertificateKeyPair, + CryptoApi, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; @customElement("ak-crypto-certificate-list") export class CertificateKeyPairListPage extends TablePage { @@ -119,16 +124,21 @@ export class CertificateKeyPairListPage extends TablePage { `, html` ${item.certExpiry?.toLocaleString()} `, html` - ${msg("Update")} - ${msg("Update Certificate-Key Pair")} - - - - `, + ${msg("Update")} + ${msg("Update Certificate-Key Pair")} + + + + + + `, ]; } diff --git a/web/src/admin/enterprise/EnterpriseLicenseListPage.ts b/web/src/admin/enterprise/EnterpriseLicenseListPage.ts index 27a0c1b67..0d2f16cf3 100644 --- a/web/src/admin/enterprise/EnterpriseLicenseListPage.ts +++ b/web/src/admin/enterprise/EnterpriseLicenseListPage.ts @@ -7,6 +7,7 @@ import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/cards/AggregateCard"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -23,7 +24,13 @@ import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; -import { EnterpriseApi, License, LicenseForecast, LicenseSummary } from "@goauthentik/api"; +import { + EnterpriseApi, + License, + LicenseForecast, + LicenseSummary, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; @customElement("ak-enterprise-license-list") export class EnterpriseLicenseListPage extends TablePage { @@ -221,16 +228,21 @@ export class EnterpriseLicenseListPage extends TablePage {
${msg(str`External: ${item.externalUsers}`)}
`, html` ${item.expiry?.toLocaleString()} `, html` - ${msg("Update")} - ${msg("Update License")} - - - - `, + ${msg("Update")} + ${msg("Update License")} + + + + + + `, ]; } diff --git a/web/src/admin/events/RuleListPage.ts b/web/src/admin/events/RuleListPage.ts index 58d3e3c1b..e997903b1 100644 --- a/web/src/admin/events/RuleListPage.ts +++ b/web/src/admin/events/RuleListPage.ts @@ -6,6 +6,8 @@ import { uiConfig } from "@goauthentik/common/ui/config"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -15,7 +17,11 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { EventsApi, NotificationRule } from "@goauthentik/api"; +import { + EventsApi, + NotificationRule, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; @customElement("ak-event-rule-list") export class RuleListPage extends TablePage { @@ -88,15 +94,21 @@ export class RuleListPage extends TablePage { ? html`${item.groupObj.name}` : msg("None (rule disabled)")}`, html` - ${msg("Update")} - ${msg("Update Notification Rule")} - - - `, + ${msg("Update")} + ${msg("Update Notification Rule")} + + + + + + `, ]; } diff --git a/web/src/admin/events/TransportListPage.ts b/web/src/admin/events/TransportListPage.ts index 07f8bc57f..c36c21af9 100644 --- a/web/src/admin/events/TransportListPage.ts +++ b/web/src/admin/events/TransportListPage.ts @@ -5,6 +5,8 @@ import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -14,7 +16,11 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { EventsApi, NotificationTransport } from "@goauthentik/api"; +import { + EventsApi, + NotificationTransport, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; @customElement("ak-event-transport-list") export class TransportListPage extends TablePage { @@ -90,6 +96,12 @@ export class TransportListPage extends TablePage { + + + { diff --git a/web/src/admin/flows/FlowViewPage.ts b/web/src/admin/flows/FlowViewPage.ts index edd752c7f..c760821e5 100644 --- a/web/src/admin/flows/FlowViewPage.ts +++ b/web/src/admin/flows/FlowViewPage.ts @@ -3,6 +3,7 @@ import "@goauthentik/admin/flows/FlowDiagram"; import "@goauthentik/admin/flows/FlowForm"; import "@goauthentik/admin/policies/BoundPoliciesList"; import { DesignationToLabel } from "@goauthentik/app/admin/flows/utils"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/components/events/ObjectChangelog"; import { AKElement } from "@goauthentik/elements/Base"; @@ -22,7 +23,12 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { Flow, FlowsApi, ResponseError } from "@goauthentik/api"; +import { + Flow, + FlowsApi, + RbacPermissionsAssignedByUsersListModelEnum, + ResponseError, +} from "@goauthentik/api"; @customElement("ak-flow-view") export class FlowViewPage extends AKElement { @@ -267,6 +273,12 @@ export class FlowViewPage extends AKElement { + `; } } diff --git a/web/src/admin/groups/GroupForm.ts b/web/src/admin/groups/GroupForm.ts index 6525d1aa8..2a1cca350 100644 --- a/web/src/admin/groups/GroupForm.ts +++ b/web/src/admin/groups/GroupForm.ts @@ -11,13 +11,22 @@ import YAML from "yaml"; import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import { CoreApi, CoreGroupsListRequest, Group } from "@goauthentik/api"; +import { + CoreApi, + CoreGroupsListRequest, + Group, + PaginatedRoleList, + RbacApi, +} from "@goauthentik/api"; @customElement("ak-group-form") export class GroupForm extends ModelForm { + @state() + roles?: PaginatedRoleList; + static get styles(): CSSResult[] { return super.styles.concat(css` .pf-c-button.pf-m-control { @@ -43,6 +52,12 @@ export class GroupForm extends ModelForm { } } + async load(): Promise { + this.roles = await new RbacApi(DEFAULT_CONFIG).rbacRolesList({ + ordering: "name", + }); + } + async send(data: Group): Promise { if (this.instance?.pk) { return new CoreApi(DEFAULT_CONFIG).coreGroupsPartialUpdate({ @@ -112,6 +127,26 @@ export class GroupForm extends ModelForm { > + + +

+ ${msg( + "Select roles to grant this groups' users' permissions from the selected roles.", + )} +

+

+ ${msg("Hold control/command to select multiple items.")} +

+
${msg("Group Info")}
-
+
+
+
+ ${msg("Roles")} +
+
+
+
    + ${this.group.rolesObj.map((role) => { + return html`
  • + ${role.name} + +
  • `; + })} +
+
+
+
+ + `; } } diff --git a/web/src/admin/outposts/OutpostListPage.ts b/web/src/admin/outposts/OutpostListPage.ts index 575319f16..390134ad0 100644 --- a/web/src/admin/outposts/OutpostListPage.ts +++ b/web/src/admin/outposts/OutpostListPage.ts @@ -10,6 +10,7 @@ import { PFSize } from "@goauthentik/elements/Spinner"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -23,7 +24,13 @@ import { ifDefined } from "lit/directives/if-defined.js"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; -import { Outpost, OutpostHealth, OutpostTypeEnum, OutpostsApi } from "@goauthentik/api"; +import { + Outpost, + OutpostHealth, + OutpostTypeEnum, + OutpostsApi, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; export function TypeToLabel(type?: OutpostTypeEnum): string { if (!type) return ""; @@ -141,6 +148,11 @@ export class OutpostListPage extends TablePage { + + ${item.managed !== "goauthentik.io/outposts/embedded" ? html` - `, + html` + + ${msg("Update")} + ${msg(str`Update ${item.verboseName}`)} + + + + + + + `, ]; } diff --git a/web/src/admin/policies/PolicyListPage.ts b/web/src/admin/policies/PolicyListPage.ts index a467f94b4..9a1dda215 100644 --- a/web/src/admin/policies/PolicyListPage.ts +++ b/web/src/admin/policies/PolicyListPage.ts @@ -13,6 +13,7 @@ import "@goauthentik/elements/forms/ConfirmationForm"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ProxyForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -92,6 +93,9 @@ export class PolicyListPage extends TablePage { + + + ${msg("Test")} ${msg("Test Policy")} diff --git a/web/src/admin/policies/reputation/ReputationListPage.ts b/web/src/admin/policies/reputation/ReputationListPage.ts index c2144a436..8c25f1514 100644 --- a/web/src/admin/policies/reputation/ReputationListPage.ts +++ b/web/src/admin/policies/reputation/ReputationListPage.ts @@ -4,6 +4,7 @@ import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -13,7 +14,11 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { PoliciesApi, Reputation } from "@goauthentik/api"; +import { + PoliciesApi, + RbacPermissionsAssignedByUsersListModelEnum, + Reputation, +} from "@goauthentik/api"; @customElement("ak-policy-reputation-list") export class ReputationListPage extends TablePage { @@ -52,6 +57,7 @@ export class ReputationListPage extends TablePage { new TableColumn(msg("IP"), "ip"), new TableColumn(msg("Score"), "score"), new TableColumn(msg("Updated"), "updated"), + new TableColumn(msg("Actions")), ]; } @@ -86,6 +92,13 @@ export class ReputationListPage extends TablePage { ${item.ip}`, html`${item.score}`, html`${item.updated.toLocaleString()}`, + html` + + + `, ]; } } diff --git a/web/src/admin/property-mappings/PropertyMappingListPage.ts b/web/src/admin/property-mappings/PropertyMappingListPage.ts index 500525922..e961a744c 100644 --- a/web/src/admin/property-mappings/PropertyMappingListPage.ts +++ b/web/src/admin/property-mappings/PropertyMappingListPage.ts @@ -10,6 +10,7 @@ import { uiConfig } from "@goauthentik/common/ui/config"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ProxyForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; @@ -107,6 +108,8 @@ export class PropertyMappingListPage extends TablePage { + + ${msg("Test")} ${msg("Test Property Mapping")} diff --git a/web/src/admin/providers/ldap/LDAPProviderViewPage.ts b/web/src/admin/providers/ldap/LDAPProviderViewPage.ts index f644bb7c5..421b04334 100644 --- a/web/src/admin/providers/ldap/LDAPProviderViewPage.ts +++ b/web/src/admin/providers/ldap/LDAPProviderViewPage.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/providers/RelatedApplicationButton"; import "@goauthentik/admin/providers/ldap/LDAPProviderForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { me } from "@goauthentik/common/users"; @@ -27,7 +28,12 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { LDAPProvider, ProvidersApi, SessionUser } from "@goauthentik/api"; +import { + LDAPProvider, + ProvidersApi, + RbacPermissionsAssignedByUsersListModelEnum, + SessionUser, +} from "@goauthentik/api"; @customElement("ak-provider-ldap-view") export class LDAPProviderViewPage extends AKElement { @@ -101,6 +107,12 @@ export class LDAPProviderViewPage extends AKElement { + `; } diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts b/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts index 920e71ceb..afe5dfd58 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts @@ -33,6 +33,7 @@ import { OAuth2ProviderSetupURLs, PropertyMappingPreview, ProvidersApi, + RbacPermissionsAssignedByUsersListModelEnum, } from "@goauthentik/api"; @customElement("ak-provider-oauth2-view") @@ -128,6 +129,12 @@ export class OAuth2ProviderViewPage extends AKElement { + `; } diff --git a/web/src/admin/providers/proxy/ProxyProviderViewPage.ts b/web/src/admin/providers/proxy/ProxyProviderViewPage.ts index 2451cfa1e..a0ce2792d 100644 --- a/web/src/admin/providers/proxy/ProxyProviderViewPage.ts +++ b/web/src/admin/providers/proxy/ProxyProviderViewPage.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/providers/RelatedApplicationButton"; import "@goauthentik/admin/providers/proxy/ProxyProviderForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { convertToSlug } from "@goauthentik/common/utils"; @@ -39,7 +40,12 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { ProvidersApi, ProxyMode, ProxyProvider } from "@goauthentik/api"; +import { + ProvidersApi, + ProxyMode, + ProxyProvider, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; export function ModeToLabel(action?: ProxyMode): string { if (!action) return ""; @@ -208,6 +214,12 @@ export class ProxyProviderViewPage extends AKElement { + `; } diff --git a/web/src/admin/providers/radius/RadiusProviderViewPage.ts b/web/src/admin/providers/radius/RadiusProviderViewPage.ts index b62600a97..3963face9 100644 --- a/web/src/admin/providers/radius/RadiusProviderViewPage.ts +++ b/web/src/admin/providers/radius/RadiusProviderViewPage.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/providers/RelatedApplicationButton"; import "@goauthentik/admin/providers/radius/RadiusProviderForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import "@goauthentik/components/events/ObjectChangelog"; @@ -21,10 +22,13 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGallery from "@patternfly/patternfly/layouts/Gallery/gallery.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; -import PFFlex from "@patternfly/patternfly/utilities/Flex/flex.css"; import PFSizing from "@patternfly/patternfly/utilities/Sizing/sizing.css"; -import { ProvidersApi, RadiusProvider } from "@goauthentik/api"; +import { + ProvidersApi, + RadiusProvider, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; @customElement("ak-provider-radius-view") export class RadiusProviderViewPage extends AKElement { @@ -50,7 +54,6 @@ export class RadiusProviderViewPage extends AKElement { PFBase, PFButton, PFPage, - PFFlex, PFDisplay, PFGallery, PFContent, @@ -162,6 +165,12 @@ export class RadiusProviderViewPage extends AKElement { + `; } } diff --git a/web/src/admin/providers/saml/SAMLProviderViewPage.ts b/web/src/admin/providers/saml/SAMLProviderViewPage.ts index 3e8d773ef..806a51b6b 100644 --- a/web/src/admin/providers/saml/SAMLProviderViewPage.ts +++ b/web/src/admin/providers/saml/SAMLProviderViewPage.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/providers/RelatedApplicationButton"; import "@goauthentik/admin/providers/saml/SAMLProviderForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { MessageLevel } from "@goauthentik/common/messages"; @@ -34,6 +35,7 @@ import { CertificateKeyPair, CryptoApi, ProvidersApi, + RbacPermissionsAssignedByUsersListModelEnum, SAMLMetadata, SAMLProvider, } from "@goauthentik/api"; @@ -226,6 +228,12 @@ export class SAMLProviderViewPage extends AKElement { + `; } diff --git a/web/src/admin/providers/scim/SCIMProviderViewPage.ts b/web/src/admin/providers/scim/SCIMProviderViewPage.ts index e9d0afb79..3998c7c81 100644 --- a/web/src/admin/providers/scim/SCIMProviderViewPage.ts +++ b/web/src/admin/providers/scim/SCIMProviderViewPage.ts @@ -1,4 +1,5 @@ import "@goauthentik/admin/providers/scim/SCIMProviderForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import "@goauthentik/components/events/ObjectChangelog"; @@ -26,7 +27,12 @@ import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { ProvidersApi, SCIMProvider, Task } from "@goauthentik/api"; +import { + ProvidersApi, + RbacPermissionsAssignedByUsersListModelEnum, + SCIMProvider, + Task, +} from "@goauthentik/api"; @customElement("ak-provider-scim-view") export class SCIMProviderViewPage extends AKElement { @@ -113,6 +119,12 @@ export class SCIMProviderViewPage extends AKElement { + `; } diff --git a/web/src/admin/roles/RoleForm.ts b/web/src/admin/roles/RoleForm.ts new file mode 100644 index 000000000..48b886b82 --- /dev/null +++ b/web/src/admin/roles/RoleForm.ts @@ -0,0 +1,56 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +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 { RbacApi, Role } from "@goauthentik/api"; + +@customElement("ak-role-form") +export class RoleForm extends ModelForm { + loadInstance(pk: string): Promise { + return new RbacApi(DEFAULT_CONFIG).rbacRolesRetrieve({ + uuid: pk, + }); + } + + getSuccessMessage(): string { + if (this.instance) { + return msg("Successfully updated role."); + } else { + return msg("Successfully created role."); + } + } + + async send(data: Role): Promise { + if (this.instance?.pk) { + return new RbacApi(DEFAULT_CONFIG).rbacRolesPartialUpdate({ + uuid: this.instance.pk, + patchedRoleRequest: data, + }); + } else { + return new RbacApi(DEFAULT_CONFIG).rbacRolesCreate({ + roleRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html`
+ + + +
`; + } +} diff --git a/web/src/admin/roles/RoleListPage.ts b/web/src/admin/roles/RoleListPage.ts new file mode 100644 index 000000000..2bf8bf64e --- /dev/null +++ b/web/src/admin/roles/RoleListPage.ts @@ -0,0 +1,98 @@ +import "@goauthentik/admin/roles/RoleForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { uiConfig } from "@goauthentik/common/ui/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 { RbacApi, Role } from "@goauthentik/api"; + +@customElement("ak-role-list") +export class RoleListPage extends TablePage { + checkbox = true; + searchEnabled(): boolean { + return true; + } + pageTitle(): string { + return msg("Roles"); + } + pageDescription(): string { + return msg("Manage roles which grant permissions to objects within authentik."); + } + pageIcon(): string { + return "fa fa-lock"; + } + + @property() + order = "name"; + + async apiEndpoint(page: number): Promise> { + return new RbacApi(DEFAULT_CONFIG).rbacRolesList({ + ordering: this.order, + page: page, + pageSize: (await uiConfig()).pagination.perPage, + search: this.search || "", + }); + } + + 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).rbacRolesUsedByList({ + uuid: item.pk, + }); + }} + .delete=${(item: Role) => { + return new RbacApi(DEFAULT_CONFIG).rbacRolesDestroy({ + uuid: item.pk, + }); + }} + > + + `; + } + + row(item: Role): TemplateResult[] { + return [ + html`${item.name}`, + html` + ${msg("Update")} + ${msg("Update Role")} + + + `, + ]; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${msg("Create")} + ${msg("Create Role")} + + + + `; + } +} diff --git a/web/src/admin/roles/RolePermissionForm.ts b/web/src/admin/roles/RolePermissionForm.ts new file mode 100644 index 000000000..f0312706a --- /dev/null +++ b/web/src/admin/roles/RolePermissionForm.ts @@ -0,0 +1,88 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/components/ak-toggle-group"; +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/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; +import "@goauthentik/elements/rbac/PermissionSelectModal"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import { Permission, RbacApi } from "@goauthentik/api"; + +interface RolePermissionAssign { + permissions: string[]; +} + +@customElement("ak-role-permission-form") +export class RolePermissionForm extends ModelForm { + @state() + permissionsToAdd: Permission[] = []; + + @property() + roleUuid?: string; + + async load(): Promise {} + + loadInstance(): Promise { + throw new Error("Method not implemented."); + } + + getSuccessMessage(): string { + return msg("Successfully assigned permission."); + } + + async send(data: RolePermissionAssign): Promise { + await new RbacApi(DEFAULT_CONFIG).rbacPermissionsAssignedByRolesAssignCreate({ + uuid: this.roleUuid || "", + permissionAssignRequest: { + permissions: data.permissions, + }, + }); + this.permissionsToAdd = []; + return; + } + + renderForm(): TemplateResult { + return html`
+ +
+ { + this.permissionsToAdd = items; + this.requestUpdate(); + return Promise.resolve(); + }} + > + + +
+ + ${this.permissionsToAdd.map((permission) => { + return html` { + const idx = this.permissionsToAdd.indexOf(permission); + this.permissionsToAdd.splice(idx, 1); + this.requestUpdate(); + }} + > + ${permission.name} + `; + })} + +
+
+
+
`; + } +} diff --git a/web/src/admin/roles/RolePermissionGlobalTable.ts b/web/src/admin/roles/RolePermissionGlobalTable.ts new file mode 100644 index 000000000..9a302c19c --- /dev/null +++ b/web/src/admin/roles/RolePermissionGlobalTable.ts @@ -0,0 +1,89 @@ +import "@goauthentik/admin/roles/RolePermissionForm"; +import { DEFAULT_CONFIG } from "@goauthentik/app/common/api/config"; +import { groupBy } from "@goauthentik/app/common/utils"; +import { PaginatedResponse, Table, TableColumn } from "@goauthentik/app/elements/table/Table"; +import "@goauthentik/elements/forms/ModalForm"; + +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 { Permission, RbacApi } from "@goauthentik/api"; + +@customElement("ak-role-permissions-global-table") +export class RolePermissionGlobalTable extends Table { + @property() + roleUuid?: string; + + searchEnabled(): boolean { + return true; + } + + checkbox = true; + + order = "content_type__app_label,content_type__model"; + + apiEndpoint(page: number): Promise> { + return new RbacApi(DEFAULT_CONFIG).rbacPermissionsList({ + role: this.roleUuid, + page: page, + ordering: this.order, + search: this.search, + }); + } + + groupBy(items: Permission[]): [string, Permission[]][] { + return groupBy(items, (obj) => { + return obj.appLabelVerbose; + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn("Model", "model"), + new TableColumn("Permission", ""), + new TableColumn(""), + ]; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${msg("Assign")} + ${msg("Assign permission to role")} + + + + + `; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return new RbacApi( + DEFAULT_CONFIG, + ).rbacPermissionsAssignedByRolesUnassignPartialUpdate({ + uuid: this.roleUuid || "", + patchedPermissionAssignRequest: { + permissions: [`${item.appLabel}.${item.codename}`], + }, + }); + }} + > + + `; + } + + row(item: Permission): TemplateResult[] { + return [html`${item.modelVerbose}`, html`${item.name}`, html`✓`]; + } +} diff --git a/web/src/admin/roles/RolePermissionObjectTable.ts b/web/src/admin/roles/RolePermissionObjectTable.ts new file mode 100644 index 000000000..e8a71963a --- /dev/null +++ b/web/src/admin/roles/RolePermissionObjectTable.ts @@ -0,0 +1,94 @@ +import { DEFAULT_CONFIG } from "@goauthentik/app/common/api/config"; +import { groupBy } from "@goauthentik/app/common/utils"; +import { PaginatedResponse, Table, TableColumn } from "@goauthentik/app/elements/table/Table"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +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 { ExtraRoleObjectPermission, RbacApi } from "@goauthentik/api"; + +@customElement("ak-role-permissions-object-table") +export class RolePermissionObjectTable extends Table { + @property() + roleUuid?: string; + + searchEnabled(): boolean { + return true; + } + + checkbox = true; + + apiEndpoint(page: number): Promise> { + return new RbacApi(DEFAULT_CONFIG).rbacPermissionsRolesList({ + uuid: this.roleUuid || "", + page: page, + ordering: this.order, + search: this.search, + }); + } + + groupBy(items: ExtraRoleObjectPermission[]): [string, ExtraRoleObjectPermission[]][] { + return groupBy(items, (obj) => { + return obj.appLabelVerbose; + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn("Model", "model"), + new TableColumn("Permission", ""), + new TableColumn("Object", ""), + new TableColumn(""), + ]; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return [ + { key: msg("Permission"), value: item.name }, + { key: msg("Object"), value: item.objectDescription || item.objectPk }, + ]; + }} + .delete=${(item: ExtraRoleObjectPermission) => { + return new RbacApi( + DEFAULT_CONFIG, + ).rbacPermissionsAssignedByRolesUnassignPartialUpdate({ + uuid: this.roleUuid || "", + patchedPermissionAssignRequest: { + permissions: [`${item.appLabel}.${item.codename}`], + objectPk: item.objectPk, + }, + }); + }} + > + + `; + } + + row(item: ExtraRoleObjectPermission): TemplateResult[] { + return [ + html`${item.modelVerbose}`, + html`${item.name}`, + html`${item.objectDescription + ? html`${item.objectDescription}` + : html` +
${item.objectPk}
+
`}`, + html`✓`, + ]; + } +} diff --git a/web/src/admin/roles/RoleViewPage.ts b/web/src/admin/roles/RoleViewPage.ts new file mode 100644 index 000000000..b9a77fac4 --- /dev/null +++ b/web/src/admin/roles/RoleViewPage.ts @@ -0,0 +1,144 @@ +import "@goauthentik/admin/groups/RelatedGroupList"; +import "@goauthentik/app/admin/roles/RolePermissionGlobalTable"; +import "@goauthentik/app/admin/roles/RolePermissionObjectTable"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import "@goauthentik/components/events/ObjectChangelog"; +import "@goauthentik/components/events/UserEvents"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/PageHeader"; +import "@goauthentik/elements/Tabs"; + +import { msg, str } from "@lit/localize"; +import { CSSResult, TemplateResult, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; + +import { RbacApi, RbacPermissionsAssignedByUsersListModelEnum, Role } from "@goauthentik/api"; + +@customElement("ak-role-view") +export class RoleViewPage extends AKElement { + @property({ type: String }) + set roleId(id: string) { + new RbacApi(DEFAULT_CONFIG) + .rbacRolesRetrieve({ + uuid: id, + }) + .then((role) => { + this._role = role; + }); + } + + @state() + _role?: Role; + + static get styles(): CSSResult[] { + return [ + PFBase, + PFPage, + PFButton, + PFDisplay, + PFGrid, + PFContent, + PFCard, + PFDescriptionList, + css` + .pf-c-description-list__description ak-action-button { + margin-right: 6px; + margin-bottom: 6px; + } + .ak-button-collection { + max-width: 12em; + } + `, + ]; + } + + constructor() { + super(); + this.addEventListener(EVENT_REFRESH, () => { + if (!this._role?.pk) return; + this.roleId = this._role?.pk; + }); + } + + render(): TemplateResult { + return html` + + ${this.renderBody()}`; + } + + renderBody(): TemplateResult { + if (!this._role) { + return html``; + } + return html` +
+
+
+
${msg("Role Info")}
+
+
+
+
+ ${msg("Name")} +
+
+
+ ${this._role.name} +
+
+
+
+
+
+
+
${msg("Assigned global permissions")}
+
+ +
+
+
+
${msg("Assigned object permissions")}
+
+ +
+
+
+
+ +
`; + } +} diff --git a/web/src/admin/sources/ldap/LDAPSourceViewPage.ts b/web/src/admin/sources/ldap/LDAPSourceViewPage.ts index 2c74bc5f2..36129c3c4 100644 --- a/web/src/admin/sources/ldap/LDAPSourceViewPage.ts +++ b/web/src/admin/sources/ldap/LDAPSourceViewPage.ts @@ -1,4 +1,5 @@ import "@goauthentik/admin/sources/ldap/LDAPSourceForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import "@goauthentik/components/events/ObjectChangelog"; @@ -22,7 +23,13 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { LDAPSource, SourcesApi, Task, TaskStatusEnum } from "@goauthentik/api"; +import { + LDAPSource, + RbacPermissionsAssignedByUsersListModelEnum, + SourcesApi, + Task, + TaskStatusEnum, +} from "@goauthentik/api"; @customElement("ak-source-ldap-view") export class LDAPSourceViewPage extends AKElement { @@ -206,6 +213,12 @@ export class LDAPSourceViewPage extends AKElement { + `; } } diff --git a/web/src/admin/sources/oauth/OAuthSourceViewPage.ts b/web/src/admin/sources/oauth/OAuthSourceViewPage.ts index 3bfa5cdaf..f70c13038 100644 --- a/web/src/admin/sources/oauth/OAuthSourceViewPage.ts +++ b/web/src/admin/sources/oauth/OAuthSourceViewPage.ts @@ -1,6 +1,7 @@ import "@goauthentik/admin/policies/BoundPoliciesList"; import "@goauthentik/admin/sources/oauth/OAuthSourceDiagram"; import "@goauthentik/admin/sources/oauth/OAuthSourceForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import "@goauthentik/components/events/ObjectChangelog"; @@ -22,7 +23,12 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { OAuthSource, ProviderTypeEnum, SourcesApi } from "@goauthentik/api"; +import { + OAuthSource, + ProviderTypeEnum, + RbacPermissionsAssignedByUsersListModelEnum, + SourcesApi, +} from "@goauthentik/api"; export function ProviderToLabel(provider?: ProviderTypeEnum): string { switch (provider) { @@ -238,6 +244,12 @@ export class OAuthSourceViewPage extends AKElement { + `; } } diff --git a/web/src/admin/sources/plex/PlexSourceViewPage.ts b/web/src/admin/sources/plex/PlexSourceViewPage.ts index 51db79d27..88287a8b2 100644 --- a/web/src/admin/sources/plex/PlexSourceViewPage.ts +++ b/web/src/admin/sources/plex/PlexSourceViewPage.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/policies/BoundPoliciesList"; import "@goauthentik/admin/sources/plex/PlexSourceForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import "@goauthentik/components/events/ObjectChangelog"; @@ -21,7 +22,11 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { PlexSource, SourcesApi } from "@goauthentik/api"; +import { + PlexSource, + RbacPermissionsAssignedByUsersListModelEnum, + SourcesApi, +} from "@goauthentik/api"; @customElement("ak-source-plex-view") export class PlexSourceViewPage extends AKElement { @@ -131,6 +136,12 @@ export class PlexSourceViewPage extends AKElement { + `; } } diff --git a/web/src/admin/sources/saml/SAMLSourceViewPage.ts b/web/src/admin/sources/saml/SAMLSourceViewPage.ts index b85768242..56a8750c9 100644 --- a/web/src/admin/sources/saml/SAMLSourceViewPage.ts +++ b/web/src/admin/sources/saml/SAMLSourceViewPage.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/policies/BoundPoliciesList"; import "@goauthentik/admin/sources/saml/SAMLSourceForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import "@goauthentik/components/events/ObjectChangelog"; @@ -22,7 +23,12 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { SAMLMetadata, SAMLSource, SourcesApi } from "@goauthentik/api"; +import { + RbacPermissionsAssignedByUsersListModelEnum, + SAMLMetadata, + SAMLSource, + SourcesApi, +} from "@goauthentik/api"; @customElement("ak-source-saml-view") export class SAMLSourceViewPage extends AKElement { @@ -206,6 +212,12 @@ export class SAMLSourceViewPage extends AKElement { + `; } } diff --git a/web/src/admin/stages/StageListPage.ts b/web/src/admin/stages/StageListPage.ts index 3308fb50e..fb28cf42d 100644 --- a/web/src/admin/stages/StageListPage.ts +++ b/web/src/admin/stages/StageListPage.ts @@ -24,6 +24,7 @@ import { uiConfig } from "@goauthentik/common/ui/config"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ProxyForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -149,6 +150,8 @@ export class StageListPage extends TablePage {
+ + ${this.renderStageActions(item)}`, ]; } diff --git a/web/src/admin/stages/invitation/InvitationListLink.ts b/web/src/admin/stages/invitation/InvitationListLink.ts index 6f96db4f5..a08033c87 100644 --- a/web/src/admin/stages/invitation/InvitationListLink.ts +++ b/web/src/admin/stages/invitation/InvitationListLink.ts @@ -9,7 +9,6 @@ import { until } from "lit/directives/until.js"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; -import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { Invitation, StagesApi } from "@goauthentik/api"; @@ -23,7 +22,7 @@ export class InvitationListLink extends AKElement { selectedFlow?: string; static get styles(): CSSResult[] { - return [PFBase, PFForm, PFFormControl, PFFlex, PFDescriptionList]; + return [PFBase, PFForm, PFFormControl, PFDescriptionList]; } renderLink(): string { diff --git a/web/src/admin/stages/invitation/InvitationListPage.ts b/web/src/admin/stages/invitation/InvitationListPage.ts index 41b81e5f1..2288b0691 100644 --- a/web/src/admin/stages/invitation/InvitationListPage.ts +++ b/web/src/admin/stages/invitation/InvitationListPage.ts @@ -7,6 +7,7 @@ import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -19,7 +20,12 @@ import { ifDefined } from "lit/directives/if-defined.js"; import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; -import { FlowDesignationEnum, Invitation, StagesApi } from "@goauthentik/api"; +import { + FlowDesignationEnum, + Invitation, + RbacPermissionsAssignedByUsersListModelEnum, + StagesApi, +} from "@goauthentik/api"; @customElement("ak-stage-invitation-list") export class InvitationListPage extends TablePage { @@ -124,15 +130,20 @@ export class InvitationListPage extends TablePage { html`${item.createdBy?.username}`, html`${item.expires?.toLocaleString() || msg("-")}`, html` - ${msg("Update")} - ${msg("Update Invitation")} - - - `, + ${msg("Update")} + ${msg("Update Invitation")} + + + + + `, ]; } diff --git a/web/src/admin/stages/prompt/PromptListPage.ts b/web/src/admin/stages/prompt/PromptListPage.ts index a67f566e1..c2b84a689 100644 --- a/web/src/admin/stages/prompt/PromptListPage.ts +++ b/web/src/admin/stages/prompt/PromptListPage.ts @@ -5,6 +5,7 @@ import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -14,7 +15,7 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { Prompt, StagesApi } from "@goauthentik/api"; +import { Prompt, RbacPermissionsAssignedByUsersListModelEnum, StagesApi } from "@goauthentik/api"; @customElement("ak-stage-prompt-list") export class PromptListPage extends TablePage { @@ -88,15 +89,20 @@ export class PromptListPage extends TablePage { return html`
  • ${stage.name}
  • `; })}`, html` - ${msg("Update")} - ${msg("Update Prompt")} - - - `, + ${msg("Update")} + ${msg("Update Prompt")} + + + + + `, ]; } diff --git a/web/src/admin/tenants/TenantListPage.ts b/web/src/admin/tenants/TenantListPage.ts index 560b0d097..2edaeb2d4 100644 --- a/web/src/admin/tenants/TenantListPage.ts +++ b/web/src/admin/tenants/TenantListPage.ts @@ -5,6 +5,7 @@ import { PFColor } from "@goauthentik/elements/Label"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -14,7 +15,7 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { CoreApi, Tenant } from "@goauthentik/api"; +import { CoreApi, RbacPermissionsAssignedByUsersListModelEnum, Tenant } from "@goauthentik/api"; @customElement("ak-tenant-list") export class TenantListPage extends TablePage { @@ -85,15 +86,21 @@ export class TenantListPage extends TablePage { ${item._default ? msg("Yes") : msg("No")} `, html` - ${msg("Update")} - ${msg("Update Tenant")} - - - `, + ${msg("Update")} + ${msg("Update Tenant")} + + + + + + `, ]; } diff --git a/web/src/admin/tokens/TokenListPage.ts b/web/src/admin/tokens/TokenListPage.ts index ea6c979df..ec4d1018d 100644 --- a/web/src/admin/tokens/TokenListPage.ts +++ b/web/src/admin/tokens/TokenListPage.ts @@ -7,6 +7,7 @@ import "@goauthentik/elements/buttons/Dropdown"; import "@goauthentik/elements/buttons/TokenCopyButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; import { TablePage } from "@goauthentik/elements/table/TablePage"; @@ -16,7 +17,12 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { CoreApi, IntentEnum, Token } from "@goauthentik/api"; +import { + CoreApi, + IntentEnum, + RbacPermissionsAssignedByUsersListModelEnum, + Token, +} from "@goauthentik/api"; @customElement("ak-token-list") export class TokenListPage extends TablePage { @@ -120,7 +126,19 @@ export class TokenListPage extends TablePage { ` - : html``} + : html` `} + + { } renderForm(): TemplateResult { - return html`${this.group?.isSuperuser ? html`` : html``} - -
    - { - this.usersToAdd = items; - this.requestUpdate(); - return Promise.resolve(); - }} - > - - -
    - - ${this.usersToAdd.map((user) => { - return html` { - const idx = this.usersToAdd.indexOf(user); - this.usersToAdd.splice(idx, 1); - this.requestUpdate(); - }} - > - ${UserOption(user)} - `; - })} - -
    + return html` +
    + { + this.usersToAdd = items; + this.requestUpdate(); + return Promise.resolve(); + }} + > + + +
    + + ${this.usersToAdd.map((user) => { + return html` { + const idx = this.usersToAdd.indexOf(user); + this.usersToAdd.splice(idx, 1); + this.requestUpdate(); + }} + > + ${UserOption(user)} + `; + })} +
    - `; +
    +
    `; } } diff --git a/web/src/admin/users/UserAssignedGlobalPermissionsTable.ts b/web/src/admin/users/UserAssignedGlobalPermissionsTable.ts new file mode 100644 index 000000000..99f171c81 --- /dev/null +++ b/web/src/admin/users/UserAssignedGlobalPermissionsTable.ts @@ -0,0 +1,88 @@ +import "@goauthentik/admin/users/UserPermissionForm"; +import { DEFAULT_CONFIG } from "@goauthentik/app/common/api/config"; +import { groupBy } from "@goauthentik/app/common/utils"; +import { PaginatedResponse, Table, TableColumn } from "@goauthentik/app/elements/table/Table"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +import "@goauthentik/elements/forms/ModalForm"; +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 { Permission, RbacApi } from "@goauthentik/api"; + +@customElement("ak-user-assigned-global-permissions-table") +export class UserAssignedGlobalPermissionsTable extends Table { + @property({ type: Number }) + userId?: number; + + checkbox = true; + + apiEndpoint(page: number): Promise> { + return new RbacApi(DEFAULT_CONFIG).rbacPermissionsList({ + user: this.userId || 0, + page: page, + ordering: this.order, + search: this.search, + }); + } + + groupBy(items: Permission[]): [string, Permission[]][] { + return groupBy(items, (obj) => { + return obj.appLabelVerbose; + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn("Model", "model"), + new TableColumn("Permission", ""), + new TableColumn(""), + ]; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${msg("Assign")} + ${msg("Assign permission to user")} + + + + + `; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return [{ key: msg("Permission"), value: item.name }]; + }} + .delete=${(item: Permission) => { + return new RbacApi( + DEFAULT_CONFIG, + ).rbacPermissionsAssignedByUsersUnassignPartialUpdate({ + id: this.userId || 0, + patchedPermissionAssignRequest: { + permissions: [`${item.appLabel}.${item.codename}`], + }, + }); + }} + > + + `; + } + + row(item: Permission): TemplateResult[] { + return [html`${item.modelVerbose}`, html`${item.name}`, html`✓`]; + } +} diff --git a/web/src/admin/users/UserAssignedObjectPermissionsTable.ts b/web/src/admin/users/UserAssignedObjectPermissionsTable.ts new file mode 100644 index 000000000..2b5589dc4 --- /dev/null +++ b/web/src/admin/users/UserAssignedObjectPermissionsTable.ts @@ -0,0 +1,90 @@ +import { DEFAULT_CONFIG } from "@goauthentik/app/common/api/config"; +import { groupBy } from "@goauthentik/app/common/utils"; +import { PaginatedResponse, Table, TableColumn } from "@goauthentik/app/elements/table/Table"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +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 { ExtraUserObjectPermission, RbacApi } from "@goauthentik/api"; + +@customElement("ak-user-assigned-object-permissions-table") +export class UserAssignedObjectPermissionsTable extends Table { + @property({ type: Number }) + userId?: number; + + checkbox = true; + + apiEndpoint(page: number): Promise> { + return new RbacApi(DEFAULT_CONFIG).rbacPermissionsUsersList({ + userId: this.userId || 0, + page: page, + ordering: this.order, + search: this.search, + }); + } + + groupBy(items: ExtraUserObjectPermission[]): [string, ExtraUserObjectPermission[]][] { + return groupBy(items, (obj) => { + return obj.appLabelVerbose; + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn("Model", "model"), + new TableColumn("Permission", ""), + new TableColumn("Object", ""), + new TableColumn(""), + ]; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return [ + { key: msg("Permission"), value: item.name }, + { key: msg("Object"), value: item.objectDescription || item.objectPk }, + ]; + }} + .delete=${(item: ExtraUserObjectPermission) => { + return new RbacApi( + DEFAULT_CONFIG, + ).rbacPermissionsAssignedByUsersUnassignPartialUpdate({ + id: this.userId || 0, + patchedPermissionAssignRequest: { + permissions: [`${item.appLabel}.${item.codename}`], + objectPk: item.objectPk, + }, + }); + }} + > + + `; + } + + row(item: ExtraUserObjectPermission): TemplateResult[] { + return [ + html`${item.modelVerbose}`, + html`${item.name}`, + html`${item.objectDescription + ? html`${item.objectDescription}` + : html` +
    ${item.objectPk}
    +
    `}`, + html`✓`, + ]; + } +} diff --git a/web/src/admin/users/UserDevicesList.ts b/web/src/admin/users/UserDevicesTable.ts similarity index 96% rename from web/src/admin/users/UserDevicesList.ts rename to web/src/admin/users/UserDevicesTable.ts index 6db5af610..b120c3265 100644 --- a/web/src/admin/users/UserDevicesList.ts +++ b/web/src/admin/users/UserDevicesTable.ts @@ -10,8 +10,8 @@ import { customElement, property } from "lit/decorators.js"; import { AuthenticatorsApi, Device } from "@goauthentik/api"; -@customElement("ak-user-device-list") -export class UserDeviceList extends Table { +@customElement("ak-user-device-table") +export class UserDeviceTable extends Table { @property({ type: Number }) userId?: number; diff --git a/web/src/admin/users/UserPermissionForm.ts b/web/src/admin/users/UserPermissionForm.ts new file mode 100644 index 000000000..1f89045e0 --- /dev/null +++ b/web/src/admin/users/UserPermissionForm.ts @@ -0,0 +1,88 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/components/ak-toggle-group"; +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/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; +import "@goauthentik/elements/rbac/PermissionSelectModal"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import { Permission, RbacApi } from "@goauthentik/api"; + +interface UserPermissionAssign { + permissions: string[]; +} + +@customElement("ak-user-permission-form") +export class UserPermissionForm extends ModelForm { + @state() + permissionsToAdd: Permission[] = []; + + @property({ type: Number }) + userId?: number; + + async load(): Promise {} + + loadInstance(): Promise { + throw new Error("Method not implemented."); + } + + getSuccessMessage(): string { + return msg("Successfully assigned permission."); + } + + async send(data: UserPermissionAssign): Promise { + await new RbacApi(DEFAULT_CONFIG).rbacPermissionsAssignedByUsersAssignCreate({ + id: this.userId || 0, + permissionAssignRequest: { + permissions: data.permissions, + }, + }); + this.permissionsToAdd = []; + return; + } + + renderForm(): TemplateResult { + return html`
    + +
    + { + this.permissionsToAdd = items; + this.requestUpdate(); + return Promise.resolve(); + }} + > + + +
    + + ${this.permissionsToAdd.map((permission) => { + return html` { + const idx = this.permissionsToAdd.indexOf(permission); + this.permissionsToAdd.splice(idx, 1); + this.requestUpdate(); + }} + > + ${permission.name} + `; + })} + +
    +
    +
    +
    `; + } +} diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index 0212f1336..c97a20298 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -3,7 +3,10 @@ import "@goauthentik/admin/users/UserActiveForm"; import "@goauthentik/admin/users/UserChart"; import "@goauthentik/admin/users/UserForm"; import "@goauthentik/admin/users/UserPasswordForm"; +import "@goauthentik/app/admin/users/UserAssignedGlobalPermissionsTable"; +import "@goauthentik/app/admin/users/UserAssignedObjectPermissionsTable"; import { me } from "@goauthentik/app/common/users"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { MessageLevel } from "@goauthentik/common/messages"; @@ -35,12 +38,17 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; -import PFFlex from "@patternfly/patternfly/utilities/Flex/flex.css"; import PFSizing from "@patternfly/patternfly/utilities/Sizing/sizing.css"; -import { CapabilitiesEnum, CoreApi, SessionUser, User } from "@goauthentik/api"; +import { + CapabilitiesEnum, + CoreApi, + RbacPermissionsAssignedByUsersListModelEnum, + SessionUser, + User, +} from "@goauthentik/api"; -import "./UserDevicesList"; +import "./UserDevicesTable"; @customElement("ak-user-view") export class UserViewPage extends AKElement { @@ -68,7 +76,6 @@ export class UserViewPage extends AKElement { return [ PFBase, PFPage, - PFFlex, PFButton, PFDisplay, PFGrid, @@ -443,7 +450,35 @@ export class UserViewPage extends AKElement { >
    - + +
    +
    + + +
    +
    +
    +
    ${msg("Assigned global permissions")}
    +
    + + +
    +
    +
    +
    ${msg("Assigned object permissions")}
    +
    + + +
    diff --git a/web/src/common/errors.ts b/web/src/common/errors.ts index 7ef4a308c..ad6156bfa 100644 --- a/web/src/common/errors.ts +++ b/web/src/common/errors.ts @@ -1,3 +1,30 @@ +import { + GenericError, + GenericErrorFromJSON, + ResponseError, + ValidationError, + ValidationErrorFromJSON, +} from "@goauthentik/api"; + export class SentryIgnoredError extends Error {} export class NotFoundError extends Error {} export class RequestError extends Error {} + +export type APIErrorTypes = ValidationError | GenericError; + +export async function parseAPIError(error: Error): Promise { + if (!(error instanceof ResponseError)) { + return error; + } + if (error.response.status < 400 && error.response.status > 499) { + return error; + } + const body = await error.response.json(); + if (error.response.status === 400) { + return ValidationErrorFromJSON(body); + } + if (error.response.status === 403) { + return GenericErrorFromJSON(body); + } + return body; +} diff --git a/web/src/common/users.ts b/web/src/common/users.ts index 5378236ce..293047572 100644 --- a/web/src/common/users.ts +++ b/web/src/common/users.ts @@ -45,6 +45,7 @@ export function me(): Promise { username: "", name: "", settings: {}, + systemPermissions: [], }, }; if (ex.response?.status === 401 || ex.response?.status === 403) { diff --git a/web/src/elements/PageHeader.ts b/web/src/elements/PageHeader.ts index 1f7539085..3f187291b 100644 --- a/web/src/elements/PageHeader.ts +++ b/web/src/elements/PageHeader.ts @@ -13,7 +13,7 @@ import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFContent from "@patternfly/patternfly/components/Content/content.css"; @@ -55,6 +55,7 @@ export class PageHeader extends AKElement { @property() description?: string; + @state() _header = ""; static get styles(): CSSResult[] { diff --git a/web/src/elements/ak-locale-context/ak-locale-context.ts b/web/src/elements/ak-locale-context/ak-locale-context.ts index af69fbf19..912d6b711 100644 --- a/web/src/elements/ak-locale-context/ak-locale-context.ts +++ b/web/src/elements/ak-locale-context/ak-locale-context.ts @@ -74,7 +74,7 @@ export class LocaleContext extends LitElement { console.warn(`Received a non-custom event at EVENT_LOCALE_REQUEST: ${ev}`); return; } - console.log("Locale update request received."); + console.debug("authentik/locale: Locale update request received."); this.updateLocale(ev.detail.locale); } diff --git a/web/src/elements/charts/Chart.ts b/web/src/elements/charts/Chart.ts index eb92dbfc3..0501db22a 100644 --- a/web/src/elements/charts/Chart.ts +++ b/web/src/elements/charts/Chart.ts @@ -22,7 +22,7 @@ import { msg, str } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; import { property, state } from "lit/decorators.js"; -import { UiThemeEnum } from "@goauthentik/api"; +import { ResponseError, UiThemeEnum } from "@goauthentik/api"; Chart.register(Legend, Tooltip); Chart.register(LineController, BarController, DoughnutController); @@ -65,6 +65,9 @@ export abstract class AKChart extends AKElement { @state() chart?: Chart; + @state() + error?: ResponseError; + @property() centerText?: string; @@ -129,19 +132,23 @@ export abstract class AKChart extends AKElement { } firstUpdated(): void { - this.apiRequest().then((r) => { - const canvas = this.shadowRoot?.querySelector("canvas"); - if (!canvas) { - console.warn("Failed to get canvas element"); - return; - } - const ctx = canvas.getContext("2d"); - if (!ctx) { - console.warn("failed to get 2d context"); - return; - } - this.chart = this.configureChart(r, ctx); - }); + this.apiRequest() + .then((r) => { + const canvas = this.shadowRoot?.querySelector("canvas"); + if (!canvas) { + console.warn("Failed to get canvas element"); + return; + } + const ctx = canvas.getContext("2d"); + if (!ctx) { + console.warn("failed to get 2d context"); + return; + } + this.chart = this.configureChart(r, ctx); + }) + .catch((exc: ResponseError) => { + this.error = exc; + }); } getChartType(): string { @@ -204,7 +211,15 @@ export abstract class AKChart extends AKElement { render(): TemplateResult { return html`
    - ${this.chart ? html`` : html``} + ${this.error + ? html` + +

    ${this.error.response.statusText}

    +
    + ` + : html`${this.chart + ? html`` + : html``}`} ${this.centerText ? html` ${this.centerText} ` : html``}
    diff --git a/web/src/elements/forms/DeleteBulkForm.ts b/web/src/elements/forms/DeleteBulkForm.ts index 5d56f19c0..693184b71 100644 --- a/web/src/elements/forms/DeleteBulkForm.ts +++ b/web/src/elements/forms/DeleteBulkForm.ts @@ -20,7 +20,6 @@ type BulkDeleteMetadata = { key: string; value: string }[]; @customElement("ak-delete-objects-table") export class DeleteObjectsTable extends Table { - expandable = true; paginated = false; @property({ attribute: false }) @@ -70,6 +69,11 @@ export class DeleteObjectsTable extends Table { return html``; } + firstUpdated(): void { + this.expandable = this.usedBy !== undefined; + super.firstUpdated(); + } + renderExpanded(item: T): TemplateResult { const handler = async () => { if (!this.usedByData.has(item) && this.usedBy) { diff --git a/web/src/elements/rbac/ObjectPermissionModal.ts b/web/src/elements/rbac/ObjectPermissionModal.ts new file mode 100644 index 000000000..596b2b2c5 --- /dev/null +++ b/web/src/elements/rbac/ObjectPermissionModal.ts @@ -0,0 +1,74 @@ +import { AKElement } from "@goauthentik/app/elements/Base"; +import "@goauthentik/elements/forms/ModalForm"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import "@goauthentik/elements/rbac/ObjectPermissionsPage"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api"; + +/** + * This is a bit of a hack to get the viewport checking from ModelForm, + * even though we actually don't need a form here. + * #TODO: Rework this in the future + */ +@customElement("ak-rbac-object-permission-modal-form") +export class ObjectPermissionsPageForm extends ModelForm { + @property() + model?: RbacPermissionsAssignedByUsersListModelEnum; + + @property() + objectPk?: string | number; + + loadInstance(): Promise { + return Promise.resolve(); + } + send(): Promise { + return Promise.resolve(); + } + + renderForm(): TemplateResult { + return html` + `; + } +} + +@customElement("ak-rbac-object-permission-modal") +export class ObjectPermissionModal extends AKElement { + @property() + model?: RbacPermissionsAssignedByUsersListModelEnum; + + @property() + objectPk?: string | number; + + static get styles(): CSSResult[] { + return [PFBase, PFButton]; + } + + render(): TemplateResult { + return html` + + ${msg("Update Permissions")} + + + + `; + } +} diff --git a/web/src/elements/rbac/ObjectPermissionsPage.ts b/web/src/elements/rbac/ObjectPermissionsPage.ts new file mode 100644 index 000000000..5d0110bd8 --- /dev/null +++ b/web/src/elements/rbac/ObjectPermissionsPage.ts @@ -0,0 +1,68 @@ +import { AKElement } from "@goauthentik/app/elements/Base"; +import "@goauthentik/app/elements/rbac/RoleObjectPermissionTable"; +import "@goauthentik/app/elements/rbac/UserObjectPermissionTable"; +import "@goauthentik/elements/Tabs"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api"; + +@customElement("ak-rbac-object-permission-page") +export class ObjectPermissionPage extends AKElement { + @property() + model?: RbacPermissionsAssignedByUsersListModelEnum; + + @property() + objectPk?: string | number; + + static get styles(): CSSResult[] { + return [PFBase, PFGrid, PFPage, PFCard]; + } + render(): TemplateResult { + return html` +
    +
    +
    +
    User Object Permissions
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    Role Object Permissions
    +
    + + +
    +
    +
    +
    +
    `; + } +} diff --git a/web/src/elements/rbac/PermissionSelectModal.ts b/web/src/elements/rbac/PermissionSelectModal.ts new file mode 100644 index 000000000..648d88f17 --- /dev/null +++ b/web/src/elements/rbac/PermissionSelectModal.ts @@ -0,0 +1,95 @@ +import { groupBy } from "@goauthentik/app/common/utils"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { uiConfig } from "@goauthentik/common/ui/config"; +import "@goauthentik/elements/buttons/SpinnerButton"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; +import { TableColumn } from "@goauthentik/elements/table/Table"; +import { TableModal } from "@goauthentik/elements/table/TableModal"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; + +import { Permission, RbacApi } from "@goauthentik/api"; + +@customElement("ak-rbac-permission-select-table") +export class PermissionSelectModal extends TableModal { + checkbox = true; + checkboxChip = true; + + searchEnabled(): boolean { + return true; + } + + @property() + confirm!: (selectedItems: Permission[]) => Promise; + + order = "content_type__app_label,content_type__model"; + + static get styles(): CSSResult[] { + return super.styles.concat(PFBanner); + } + + async apiEndpoint(page: number): Promise> { + return new RbacApi(DEFAULT_CONFIG).rbacPermissionsList({ + ordering: this.order, + page: page, + pageSize: (await uiConfig()).pagination.perPage, + search: this.search || "", + }); + } + + groupBy(items: Permission[]): [string, Permission[]][] { + return groupBy(items, (perm) => { + return perm.appLabelVerbose; + }); + } + + columns(): TableColumn[] { + return [new TableColumn(msg("Name"), "codename"), new TableColumn(msg("Model"), "")]; + } + + row(item: Permission): TemplateResult[] { + return [ + html`
    +
    ${item.name}
    +
    `, + html`${item.modelVerbose}`, + ]; + } + + renderSelectedChip(item: Permission): TemplateResult { + return html`${item.name}`; + } + + renderModalInner(): TemplateResult { + return html`
    +
    +

    ${msg("Select permissions to grant")}

    +
    +
    +
    ${this.renderTable()}
    +
    + { + return this.confirm(this.selectedElements).then(() => { + this.open = false; + }); + }} + class="pf-m-primary" + > + ${msg("Add")}   + { + this.open = false; + }} + class="pf-m-secondary" + > + ${msg("Cancel")} + +
    `; + } +} diff --git a/web/src/elements/rbac/RoleObjectPermissionForm.ts b/web/src/elements/rbac/RoleObjectPermissionForm.ts new file mode 100644 index 000000000..230d30a2e --- /dev/null +++ b/web/src/elements/rbac/RoleObjectPermissionForm.ts @@ -0,0 +1,107 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/components/ak-toggle-group"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import { + ModelEnum, + PaginatedPermissionList, + RbacApi, + RbacRolesListRequest, + Role, +} from "@goauthentik/api"; + +interface RoleAssignData { + role: string; + permissions: { + [key: string]: boolean; + }; +} + +@customElement("ak-rbac-role-object-permission-form") +export class RoleObjectPermissionForm extends ModelForm { + @property() + model?: ModelEnum; + + @property() + objectPk?: string; + + @state() + modelPermissions?: PaginatedPermissionList; + + async load(): Promise { + const [appLabel, modelName] = (this.model || "").split("."); + this.modelPermissions = await new RbacApi(DEFAULT_CONFIG).rbacPermissionsList({ + contentTypeModel: modelName, + contentTypeAppLabel: appLabel, + ordering: "codename", + }); + } + + loadInstance(): Promise { + throw new Error("Method not implemented."); + } + + getSuccessMessage(): string { + return msg("Successfully assigned permission."); + } + + send(data: RoleAssignData): Promise { + return new RbacApi(DEFAULT_CONFIG).rbacPermissionsAssignedByRolesAssignCreate({ + uuid: data.role, + permissionAssignRequest: { + permissions: Object.keys(data.permissions).filter((key) => data.permissions[key]), + model: this.model!, + objectPk: this.objectPk, + }, + }); + } + + renderForm(): TemplateResult { + if (!this.modelPermissions) { + return html``; + } + return html`
    + + => { + const args: RbacRolesListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const roles = await new RbacApi(DEFAULT_CONFIG).rbacRolesList(args); + return roles.results; + }} + .renderElement=${(role: Role): string => { + return role.name; + }} + .value=${(role: Role | undefined): string | undefined => { + return role?.pk; + }} + > + + + ${this.modelPermissions?.results.map((perm) => { + return html` + + `; + })} +
    `; + } +} diff --git a/web/src/elements/rbac/RoleObjectPermissionTable.ts b/web/src/elements/rbac/RoleObjectPermissionTable.ts new file mode 100644 index 000000000..45a807495 --- /dev/null +++ b/web/src/elements/rbac/RoleObjectPermissionTable.ts @@ -0,0 +1,97 @@ +import { DEFAULT_CONFIG } from "@goauthentik/app/common/api/config"; +import { PaginatedResponse, Table, TableColumn } from "@goauthentik/app/elements/table/Table"; +import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/RoleObjectPermissionForm"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + PaginatedPermissionList, + RbacApi, + RbacPermissionsAssignedByRolesListModelEnum, + RoleAssignedObjectPermission, +} from "@goauthentik/api"; + +@customElement("ak-rbac-role-object-permission-table") +export class RoleAssignedObjectPermissionTable extends Table { + @property() + model?: RbacPermissionsAssignedByRolesListModelEnum; + + @property() + objectPk?: string | number; + + @state() + modelPermissions?: PaginatedPermissionList; + + async apiEndpoint(page: number): Promise> { + const perms = await new RbacApi(DEFAULT_CONFIG).rbacPermissionsAssignedByRolesList({ + page: page, + // TODO: better default + model: this.model || RbacPermissionsAssignedByRolesListModelEnum.CoreUser, + objectPk: this.objectPk?.toString(), + }); + const [appLabel, modelName] = (this.model || "").split("."); + const modelPermissions = await new RbacApi(DEFAULT_CONFIG).rbacPermissionsList({ + contentTypeModel: modelName, + contentTypeAppLabel: appLabel, + ordering: "codename", + }); + modelPermissions.results = modelPermissions.results.filter((value) => { + return !value.codename.startsWith("add_"); + }); + this.modelPermissions = modelPermissions; + return perms; + } + + columns(): TableColumn[] { + const baseColumns = [new TableColumn("User", "user")]; + // We don't check pagination since models shouldn't need to have that many permissions? + this.modelPermissions?.results.forEach((perm) => { + baseColumns.push(new TableColumn(perm.name, perm.codename)); + }); + return baseColumns; + } + + renderObjectCreate(): TemplateResult { + return html` + ${msg("Assign")} + ${msg("Assign permission to role")} + + + + `; + } + + row(item: RoleAssignedObjectPermission): TemplateResult[] { + const baseRow = [html` ${item.name}`]; + this.modelPermissions?.results.forEach((perm) => { + const granted = + item.permissions.filter((uperm) => uperm.codename === perm.codename).length > 0; + baseRow.push(html` + { + console.log(granted); + }} + class="pf-m-link" + > + ${granted + ? html`` + : html`X`} + + `); + }); + return baseRow; + } +} diff --git a/web/src/elements/rbac/UserObjectPermissionForm.ts b/web/src/elements/rbac/UserObjectPermissionForm.ts new file mode 100644 index 000000000..3b3b66402 --- /dev/null +++ b/web/src/elements/rbac/UserObjectPermissionForm.ts @@ -0,0 +1,111 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/components/ak-toggle-group"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import { + CoreApi, + CoreUsersListRequest, + ModelEnum, + PaginatedPermissionList, + RbacApi, + User, +} from "@goauthentik/api"; + +interface UserAssignData { + user: number; + permissions: { + [key: string]: boolean; + }; +} + +@customElement("ak-rbac-user-object-permission-form") +export class UserObjectPermissionForm extends ModelForm { + @property() + model?: ModelEnum; + + @property() + objectPk?: string; + + @state() + modelPermissions?: PaginatedPermissionList; + + async load(): Promise { + const [appLabel, modelName] = (this.model || "").split("."); + this.modelPermissions = await new RbacApi(DEFAULT_CONFIG).rbacPermissionsList({ + contentTypeModel: modelName, + contentTypeAppLabel: appLabel, + ordering: "codename", + }); + } + + loadInstance(): Promise { + throw new Error("Method not implemented."); + } + + getSuccessMessage(): string { + return msg("Successfully assigned permission."); + } + + send(data: UserAssignData): Promise { + return new RbacApi(DEFAULT_CONFIG).rbacPermissionsAssignedByUsersAssignCreate({ + id: data.user, + permissionAssignRequest: { + permissions: Object.keys(data.permissions).filter((key) => data.permissions[key]), + model: this.model!, + objectPk: this.objectPk!, + }, + }); + } + + renderForm(): TemplateResult { + if (!this.modelPermissions) { + return html``; + } + return html`
    + + => { + const args: CoreUsersListRequest = { + ordering: "username", + }; + if (query !== undefined) { + args.search = query; + } + const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args); + return users.results; + }} + .renderElement=${(user: User): string => { + return user.username; + }} + .renderDescription=${(user: User): TemplateResult => { + return html`${user.name}`; + }} + .value=${(user: User | undefined): number | undefined => { + return user?.pk; + }} + > + + + ${this.modelPermissions?.results.map((perm) => { + return html` + + `; + })} +
    `; + } +} diff --git a/web/src/elements/rbac/UserObjectPermissionTable.ts b/web/src/elements/rbac/UserObjectPermissionTable.ts new file mode 100644 index 000000000..3c52ca1e6 --- /dev/null +++ b/web/src/elements/rbac/UserObjectPermissionTable.ts @@ -0,0 +1,90 @@ +import { DEFAULT_CONFIG } from "@goauthentik/app/common/api/config"; +import { PaginatedResponse, Table, TableColumn } from "@goauthentik/app/elements/table/Table"; +import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/UserObjectPermissionForm"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + PaginatedPermissionList, + RbacApi, + RbacPermissionsAssignedByUsersListModelEnum, + UserAssignedObjectPermission, +} from "@goauthentik/api"; + +@customElement("ak-rbac-user-object-permission-table") +export class UserAssignedObjectPermissionTable extends Table { + @property() + model?: RbacPermissionsAssignedByUsersListModelEnum; + + @property() + objectPk?: string | number; + + @state() + modelPermissions?: PaginatedPermissionList; + + async apiEndpoint(page: number): Promise> { + const perms = await new RbacApi(DEFAULT_CONFIG).rbacPermissionsAssignedByUsersList({ + page: page, + // TODO: better default + model: this.model || RbacPermissionsAssignedByUsersListModelEnum.CoreUser, + objectPk: this.objectPk?.toString(), + }); + const [appLabel, modelName] = (this.model || "").split("."); + const modelPermissions = await new RbacApi(DEFAULT_CONFIG).rbacPermissionsList({ + contentTypeModel: modelName, + contentTypeAppLabel: appLabel, + ordering: "codename", + }); + modelPermissions.results = modelPermissions.results.filter((value) => { + return !value.codename.startsWith("add_"); + }); + this.modelPermissions = modelPermissions; + return perms; + } + + columns(): TableColumn[] { + const baseColumns = [new TableColumn("User", "user")]; + // We don't check pagination since models shouldn't need to have that many permissions? + this.modelPermissions?.results.forEach((perm) => { + baseColumns.push(new TableColumn(perm.name, perm.codename)); + }); + return baseColumns; + } + + renderObjectCreate(): TemplateResult { + return html` + ${msg("Assign")} + ${msg("Assign permission to user")} + + + + `; + } + + row(item: UserAssignedObjectPermission): TemplateResult[] { + const baseRow = [html` ${item.username} `]; + this.modelPermissions?.results.forEach((perm) => { + let cell = html`X`; + if (item.permissions.filter((uperm) => uperm.codename === perm.codename).length > 0) { + cell = html``; + } else if (item.isSuperuser) { + cell = html``; + } + baseRow.push(cell); + }); + return baseRow; + } +} diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index b9cf20b14..642af4801 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -1,3 +1,4 @@ +import { APIErrorTypes, parseAPIError } from "@goauthentik/app/common/errors"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { groupBy } from "@goauthentik/common/utils"; import { AKElement } from "@goauthentik/elements/Base"; @@ -148,7 +149,7 @@ export abstract class Table extends AKElement { expandedElements: T[] = []; @state() - hasError?: Error; + error?: APIErrorTypes; static get styles(): CSSResult[] { return [ @@ -191,7 +192,7 @@ export abstract class Table extends AKElement { this.isLoading = true; try { this.data = await this.apiEndpoint(this.page); - this.hasError = undefined; + this.error = undefined; this.page = this.data.pagination.current; const newSelected: T[] = []; const newExpanded: T[] = []; @@ -228,7 +229,7 @@ export abstract class Table extends AKElement { this.expandedElements = newExpanded; } catch (ex) { this.isLoading = false; - this.hasError = ex as Error; + this.error = await parseAPIError(ex as Error); } } @@ -249,25 +250,32 @@ export abstract class Table extends AKElement {
    ${inner ? inner - : html``} + : html`
    ${this.renderObjectCreate()}
    +
    `}
    `; } + renderObjectCreate(): TemplateResult { + return html``; + } + renderError(): TemplateResult { + if (!this.error) { + return html``; + } return html` - ${this.hasError instanceof ResponseError - ? html`
    ${this.hasError.message}
    ` - : html`
    ${this.hasError?.toString()}
    `} + ${this.error instanceof ResponseError + ? html`
    ${this.error.message}
    ` + : html`
    ${this.error.detail}
    `}
    `; } private renderRows(): TemplateResult[] | undefined { - if (this.hasError) { + if (this.error) { return [this.renderEmpty(this.renderError())]; } if (!this.data || this.isLoading) { @@ -277,7 +285,7 @@ export abstract class Table extends AKElement { return [this.renderEmpty()]; } const groupedResults = this.groupBy(this.data.results); - if (groupedResults.length === 1) { + if (groupedResults.length === 1 && groupedResults[0][0] === "") { return this.renderRowGroup(groupedResults[0][1]); } return groupedResults.map(([group, items]) => { @@ -397,14 +405,15 @@ export abstract class Table extends AKElement { } renderToolbar(): TemplateResult { - return html` { - return this.fetch(); - }} - class="pf-m-secondary" - > - ${msg("Refresh")}`; + return html` ${this.renderObjectCreate()} + { + return this.fetch(); + }} + class="pf-m-secondary" + > + ${msg("Refresh")}`; } renderToolbarSelected(): TemplateResult { @@ -419,18 +428,20 @@ export abstract class Table extends AKElement { if (!this.searchEnabled()) { return html``; } - return html` { - this.search = value; - this.fetch(); - updateURLParams({ - search: value, - }); - }} - > - `; + return html`
    + { + this.search = value; + this.fetch(); + updateURLParams({ + search: value, + }); + }} + > + +
    `; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -441,7 +452,7 @@ export abstract class Table extends AKElement { renderToolbarContainer(): TemplateResult { return html`
    -
    ${this.renderSearch()}
    + ${this.renderSearch()}
    ${this.renderToolbar()}
    ${this.renderToolbarAfter()}
    ${this.renderToolbarSelected()}
    diff --git a/web/src/elements/table/TablePage.ts b/web/src/elements/table/TablePage.ts index 8ca297fe6..d3f207622 100644 --- a/web/src/elements/table/TablePage.ts +++ b/web/src/elements/table/TablePage.ts @@ -70,14 +70,6 @@ export abstract class TablePage extends Table { `; } - renderObjectCreate(): TemplateResult { - return html``; - } - - renderToolbar(): TemplateResult { - return html`${this.renderObjectCreate()}${super.renderToolbar()}`; - } - render(): TemplateResult { return html` { @@ -24,7 +28,7 @@ export class ConsentStage extends BaseStage { if (permission.name === "") { return html``; diff --git a/web/src/user/UserInterface.ts b/web/src/user/UserInterface.ts index 0d83982a1..fbd21d347 100644 --- a/web/src/user/UserInterface.ts +++ b/web/src/user/UserInterface.ts @@ -175,6 +175,10 @@ export class UserInterface extends Interface { default: userDisplay = this.me.user.username; } + const canAccessAdmin = + this.me.user.isSuperuser || + // TODO: somehow add `access_admin_interface` to the API schema + this.me.user.systemPermissions.includes("access_admin_interface"); return html`
    @@ -280,7 +284,7 @@ export class UserInterface extends Interface {
    - ${this.me.user.isSuperuser + ${canAccessAdmin ? html` Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info diff --git a/web/xliff/en.xlf b/web/xliff/en.xlf index 98ce779f9..0e80e8b12 100644 --- a/web/xliff/en.xlf +++ b/web/xliff/en.xlf @@ -6252,6 +6252,111 @@ Bindings to groups/users are checked against the user of the event. Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info diff --git a/web/xliff/es.xlf b/web/xliff/es.xlf index b428ab068..d9fc14013 100644 --- a/web/xliff/es.xlf +++ b/web/xliff/es.xlf @@ -5846,6 +5846,111 @@ Bindings to groups/users are checked against the user of the event. Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info diff --git a/web/xliff/fr.xlf b/web/xliff/fr.xlf index 0174261e0..178e43b15 100644 --- a/web/xliff/fr.xlf +++ b/web/xliff/fr.xlf @@ -7827,6 +7827,111 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info diff --git a/web/xliff/pl.xlf b/web/xliff/pl.xlf index ad3cb85ca..0f43daabe 100644 --- a/web/xliff/pl.xlf +++ b/web/xliff/pl.xlf @@ -6085,6 +6085,111 @@ Bindings to groups/users are checked against the user of the event. Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info diff --git a/web/xliff/pseudo-LOCALE.xlf b/web/xliff/pseudo-LOCALE.xlf index 2eabcf7c5..b54aac0a8 100644 --- a/web/xliff/pseudo-LOCALE.xlf +++ b/web/xliff/pseudo-LOCALE.xlf @@ -6187,6 +6187,111 @@ Bindings to groups/users are checked against the user of the event. Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info diff --git a/web/xliff/tr.xlf b/web/xliff/tr.xlf index deaddd663..a2d79415f 100644 --- a/web/xliff/tr.xlf +++ b/web/xliff/tr.xlf @@ -5839,6 +5839,111 @@ Bindings to groups/users are checked against the user of the event. Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info diff --git a/web/xliff/zh-Hans.xlf b/web/xliff/zh-Hans.xlf index 9fb60a94a..cf3cfab8d 100644 --- a/web/xliff/zh-Hans.xlf +++ b/web/xliff/zh-Hans.xlf @@ -1,4 +1,4 @@ - + @@ -613,9 +613,9 @@ - The URL "" was not found. - 未找到 URL " - "。 + The URL "" was not found. + 未找到 URL " + "。 @@ -1067,8 +1067,8 @@ - To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. - 要允许任何重定向 URI,请将此值设置为 ".*"。请注意这可能带来的安全影响。 + To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. + 要允许任何重定向 URI,请将此值设置为 ".*"。请注意这可能带来的安全影响。 @@ -1809,8 +1809,8 @@ - Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". - 输入完整 URL、相对路径,或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。 + Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". + 输入完整 URL、相对路径,或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。 @@ -3233,8 +3233,8 @@ doesn't pass when either or both of the selected options are equal or above the - Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' - 包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...' + Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' + 包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...' @@ -4026,8 +4026,8 @@ doesn't pass when either or both of the selected options are equal or above the - When using an external logging solution for archiving, this can be set to "minutes=5". - 使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。 + When using an external logging solution for archiving, this can be set to "minutes=5". + 使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。 @@ -4036,8 +4036,8 @@ doesn't pass when either or both of the selected options are equal or above the - Format: "weeks=3;days=2;hours=3,seconds=2". - 格式:"weeks=3;days=2;hours=3,seconds=2"。 + Format: "weeks=3;days=2;hours=3,seconds=2". + 格式:"weeks=3;days=2;hours=3,seconds=2"。 @@ -4233,10 +4233,10 @@ doesn't pass when either or both of the selected options are equal or above the - Are you sure you want to update ""? + Are you sure you want to update ""? 您确定要更新 - " - " 吗? + " + " 吗? @@ -5332,7 +5332,7 @@ doesn't pass when either or both of the selected options are equal or above the - A "roaming" authenticator, like a YubiKey + A "roaming" authenticator, like a YubiKey 像 YubiKey 这样的“漫游”身份验证器 @@ -5667,10 +5667,10 @@ doesn't pass when either or both of the selected options are equal or above the - ("", of type ) + ("", of type ) - (" - ",类型为 + (" + ",类型为 @@ -5719,7 +5719,7 @@ doesn't pass when either or both of the selected options are equal or above the - If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. + If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. 如果设置时长大于 0,用户可以选择“保持登录”选项,这将使用户的会话延长此处设置的时间。 @@ -7836,7 +7836,112 @@ Bindings to groups/users are checked against the user of the event. Don't show this message again. 不要再显示此消息。 + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info - \ No newline at end of file + diff --git a/web/xliff/zh-Hant.xlf b/web/xliff/zh-Hant.xlf index ddb47517e..8cfcc6044 100644 --- a/web/xliff/zh-Hant.xlf +++ b/web/xliff/zh-Hant.xlf @@ -5891,6 +5891,111 @@ Bindings to groups/users are checked against the user of the event. Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info diff --git a/web/xliff/zh_TW.xlf b/web/xliff/zh_TW.xlf index 5aa3bc105..8f95191e6 100644 --- a/web/xliff/zh_TW.xlf +++ b/web/xliff/zh_TW.xlf @@ -5890,6 +5890,111 @@ Bindings to groups/users are checked against the user of the event. Don't show this message again. + + + Failed to fetch + + + Failed to fetch data. + + + Successfully assigned permission. + + + Role + + + Assign + + + Assign permission to role + + + Assign to new role + + + Directly assigned + + + Assign permission to user + + + Assign to new user + + + User Object Permissions + + + Role Object Permissions + + + Roles + + + Select roles to grant this groups' users' permissions from the selected roles. + + + Update Permissions + + + Editing is disabled for managed tokens + + + Select permissions to grant + + + Permissions to add + + + Select permissions + + + Assign permission + + + Permission(s) + + + Permission + + + User doesn't have view permission so description cannot be retrieved. + + + Assigned permissions + + + Assigned global permissions + + + Assigned object permissions + + + Successfully updated role. + + + Successfully created role. + + + Manage roles which grant permissions to objects within authentik. + + + Role(s) + + + Update Role + + + Create Role + + + Role doesn't have view permission so description cannot be retrieved. + + + Role + + + Role Info