diff --git a/authentik/core/api/groups.py b/authentik/core/api/groups.py index b0181005f95d..8675ccdf41e7 100644 --- a/authentik/core/api/groups.py +++ b/authentik/core/api/groups.py @@ -85,6 +85,7 @@ class GroupSerializer(ModelSerializer): source="roles", required=False, ) + inherited_roles_obj = SerializerMethodField(allow_null=True) num_pk = IntegerField(read_only=True) @property @@ -108,6 +109,13 @@ def _should_include_parents(self) -> bool: return True return str(request.query_params.get("include_parents", "false")).lower() == "true" + @property + def _should_include_inherited_roles(self) -> bool: + request: Request = self.context.get("request", None) + if not request: + return True + return str(request.query_params.get("include_inherited_roles", "false")).lower() == "true" + @extend_schema_field(PartialUserSerializer(many=True)) def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None: if not self._should_include_users: @@ -126,6 +134,15 @@ def get_parents_obj(self, instance: Group) -> list[RelatedGroupSerializer] | Non return None return RelatedGroupSerializer(instance.parents, many=True).data + @extend_schema_field(RoleSerializer(many=True)) + def get_inherited_roles_obj(self, instance: Group) -> list | None: + """Return only inherited roles from ancestor groups (excludes direct roles)""" + if not self._should_include_inherited_roles: + return None + direct_role_pks = instance.roles.values_list("pk", flat=True) + inherited_roles = instance.all_roles().exclude(pk__in=direct_role_pks) + return RoleSerializer(inherited_roles, many=True).data + def validate_is_superuser(self, superuser: bool): """Ensure that the user creating this group has permissions to set the superuser flag""" request: Request = self.context.get("request", None) @@ -167,6 +184,7 @@ class Meta: "attributes", "roles", "roles_obj", + "inherited_roles_obj", "children", "children_obj", ] @@ -289,6 +307,7 @@ def get_queryset(self): OpenApiParameter("include_users", bool, default=True), OpenApiParameter("include_children", bool, default=False), OpenApiParameter("include_parents", bool, default=False), + OpenApiParameter("include_inherited_roles", bool, default=False), ] ) def list(self, request, *args, **kwargs): @@ -299,6 +318,7 @@ def list(self, request, *args, **kwargs): OpenApiParameter("include_users", bool, default=True), OpenApiParameter("include_children", bool, default=False), OpenApiParameter("include_parents", bool, default=False), + OpenApiParameter("include_inherited_roles", bool, default=False), ] ) def retrieve(self, request, *args, **kwargs): diff --git a/authentik/rbac/api/roles.py b/authentik/rbac/api/roles.py index 086cdf74669a..c9793df05f52 100644 --- a/authentik/rbac/api/roles.py +++ b/authentik/rbac/api/roles.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Permission from django.http import Http404 -from django_filters.filters import AllValuesMultipleFilter, BooleanFilter +from django_filters.filters import AllValuesMultipleFilter, BooleanFilter, CharFilter, NumberFilter from django_filters.filterset import FilterSet from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field @@ -22,7 +22,7 @@ from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import ModelSerializer, PassiveSerializer -from authentik.core.models import User +from authentik.core.models import Group, User from authentik.rbac.decorators import permission_required from authentik.rbac.models import Role, get_permission_choices @@ -65,15 +65,63 @@ class Meta: class RoleFilterSet(FilterSet): - """Filter for PropertyMapping""" + """Filter for Role""" managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) managed__isnull = BooleanFilter(field_name="managed", lookup_expr="isnull") + inherited = BooleanFilter( + method="filter_inherited", + label="Include inherited roles (requires users or ak_groups filter)", + ) + + users = extend_schema_field(OpenApiTypes.INT)( + NumberFilter( + method="filter_users", + label="Filter by user (use with inherited=true for all roles)", + ) + ) + + ak_groups = extend_schema_field(OpenApiTypes.UUID)( + CharFilter( + method="filter_ak_groups", + label="Filter by group (use with inherited=true for all roles)", + ) + ) + + def filter_inherited(self, queryset, name, value): + """This filter is handled by filter_users and filter_ak_groups""" + return queryset + + def filter_users(self, queryset, name, value): + """Filter roles by user, optionally including inherited roles""" + user = User.objects.filter(pk=value).first() + if not user: + return queryset.none() + + include_inherited = self.data.get("inherited", "").lower() == "true" + if include_inherited: + return user.all_roles() + return queryset.filter(users=user) + + def filter_ak_groups(self, queryset, name, value): + """Filter roles by group, optionally including inherited roles""" + group = Group.objects.filter(pk=value).first() + if not group: + return queryset.none() + + include_inherited = self.data.get("inherited", "").lower() == "true" + if include_inherited: + return group.all_roles() + return queryset.filter(ak_groups=group) + class Meta: model = Role - fields = ["name", "users", "managed"] + fields = [ + "name", + "managed", + ] class RoleViewSet(UsedByMixin, ModelViewSet): diff --git a/internal/outpost/ldap/search/memory/memory.go b/internal/outpost/ldap/search/memory/memory.go index 38a983ca3394..25fdf366809d 100644 --- a/internal/outpost/ldap/search/memory/memory.go +++ b/internal/outpost/ldap/search/memory/memory.go @@ -165,7 +165,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, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, []string{}, []api.RelatedGroup{}) + fg := api.NewGroup(g.Pk, g.NumPk, g.Name, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, nil, []string{}, []api.RelatedGroup{}) fg.SetUsers([]int32{flag.UserPk}) fg.SetAttributes(g.Attributes) fg.SetIsSuperuser(*g.IsSuperuser) diff --git a/schema.yml b/schema.yml index f1857cfb5231..e502ce3f79fd 100644 --- a/schema.yml +++ b/schema.yml @@ -3385,6 +3385,11 @@ paths: schema: type: boolean default: false + - in: query + name: include_inherited_roles + schema: + type: boolean + default: false - in: query name: include_parents schema: @@ -3478,6 +3483,11 @@ paths: schema: type: boolean default: false + - in: query + name: include_inherited_roles + schema: + type: boolean + default: false - in: query name: include_parents schema: @@ -20047,6 +20057,16 @@ paths: operationId: rbac_roles_list description: Role viewset parameters: + - in: query + name: ak_groups + schema: + type: string + format: uuid + - in: query + name: inherited + schema: + type: boolean + description: Include inherited roles (requires users or ak_groups filter) - in: query name: managed schema: @@ -20067,11 +20087,7 @@ paths: - in: query name: users schema: - type: array - items: - type: integer - explode: true - style: form + type: integer tags: - rbac security: @@ -38770,6 +38786,12 @@ components: items: $ref: '#/components/schemas/Role' readOnly: true + inherited_roles_obj: + type: array + items: + $ref: '#/components/schemas/Role' + readOnly: true + nullable: true children: type: array items: @@ -38785,6 +38807,7 @@ components: required: - children - children_obj + - inherited_roles_obj - name - num_pk - parents_obj diff --git a/web/src/admin/groups/GroupViewPage.ts b/web/src/admin/groups/GroupViewPage.ts index 8f99dac15fc8..da33d6ca9846 100644 --- a/web/src/admin/groups/GroupViewPage.ts +++ b/web/src/admin/groups/GroupViewPage.ts @@ -1,6 +1,7 @@ import "#admin/groups/GroupForm"; import "#admin/groups/RelatedUserList"; import "#admin/rbac/ObjectPermissionsPage"; +import "#admin/roles/RelatedRoleList"; import "#components/ak-status-label"; import "#components/events/ObjectChangelog"; import "#elements/CodeMirror"; @@ -21,7 +22,7 @@ import { setPageDetails } from "#components/ak-page-navbar"; import { CoreApi, Group, RbacPermissionsAssignedByRolesListModelEnum } from "@goauthentik/api"; import { msg, str } from "@lit/localize"; -import { CSSResult, html, nothing, PropertyValues } from "lit"; +import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; @@ -43,6 +44,7 @@ export class GroupViewPage extends AKElement { .coreGroupsRetrieve({ groupUuid: id, includeUsers: false, + includeInheritedRoles: true, }) .then((group) => { this.group = group; @@ -137,6 +139,34 @@ export class GroupViewPage extends AKElement { `; })} + ${(this.group.inheritedRolesObj ?? []).map( + (role) => { + return html`