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`
  • + ${role.name} + + + + ${msg( + "Inherited", + )} + + +
  • `; + }, + )} @@ -203,6 +233,15 @@ export class GroupViewPage extends AKElement { +
    + ${this.renderTabRoles(this.group)} +
    `; } + protected renderTabRoles(group: Group): TemplateResult { + return html` + +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + `; + } + updated(changed: PropertyValues) { super.updated(changed); setPageDetails({ diff --git a/web/src/admin/roles/RelatedRoleList.ts b/web/src/admin/roles/RelatedRoleList.ts index 5d3e57a138e8..ca8a51803da8 100644 --- a/web/src/admin/roles/RelatedRoleList.ts +++ b/web/src/admin/roles/RelatedRoleList.ts @@ -14,14 +14,16 @@ import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { SlottedTemplateResult } from "#elements/types"; import { ifPresent } from "#elements/utils/attributes"; -import { RbacApi, Role, User } from "@goauthentik/api"; +import { Group, RbacApi, Role, User } from "@goauthentik/api"; import { msg, str } from "@lit/localize"; -import { html, nothing, TemplateResult } from "lit"; +import { html, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; @customElement("ak-role-related-add") export class RelatedRoleAdd extends Form<{ roles: string[] }> { + #api = new RbacApi(DEFAULT_CONFIG); + @property({ attribute: false }) public user: User | null = null; @@ -36,7 +38,7 @@ export class RelatedRoleAdd extends Form<{ roles: string[] }> { await Promise.all( data.roles.map((role) => { if (!this.user) return Promise.resolve(); - return new RbacApi(DEFAULT_CONFIG).rbacRolesAddUserCreate({ + return this.#api.rbacRolesAddUserCreate({ uuid: role, userAccountSerializerForRoleRequest: { pk: this.user.pk, @@ -86,6 +88,8 @@ export class RelatedRoleAdd extends Form<{ roles: string[] }> { @customElement("ak-role-related-list") export class RelatedRoleList extends Table { + #api = new RbacApi(DEFAULT_CONFIG); + checkbox = true; clearOnRefresh = true; protected override searchEnabled = true; @@ -96,19 +100,54 @@ export class RelatedRoleList extends Table { @property({ attribute: false }) public targetUser: User | null = null; + @property({ attribute: false }) + public targetGroup: Group | null = null; + + @property({ type: Boolean }) + public showInherited = false; + + willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + if (changedProperties.has("showInherited")) { + // Disable checkboxes in showInherited mode (view-only) + this.checkbox = !this.showInherited; + } + } + async apiEndpoint(): Promise> { - return new RbacApi(DEFAULT_CONFIG).rbacRolesList({ - ...(await this.defaultEndpointConfig()), - users: this.targetUser ? [this.targetUser.pk] : [], + const config = await this.defaultEndpointConfig(); + + if (this.targetGroup) { + return this.#api.rbacRolesList({ + ...config, + akGroups: this.targetGroup.pk, + inherited: this.showInherited, + }); + } + + return this.#api.rbacRolesList({ + ...config, + users: this.targetUser?.pk, + inherited: this.showInherited, }); } - protected columns: TableColumn[] = [ - [msg("Name"), "name"], - [msg("Actions"), null, msg("Row Actions")], - ]; + protected get columns(): TableColumn[] { + // Hide actions column in showInherited mode (view-only) + if (this.showInherited) { + return [[msg("Name"), "name"]]; + } + return [ + [msg("Name"), "name"], + [msg("Actions"), null, msg("Row Actions")], + ]; + } - renderToolbarSelected(): TemplateResult { + renderToolbarSelected(): SlottedTemplateResult { + // Don't render Remove button in showInherited mode (view-only) + if (this.showInherited) { + return nothing; + } const disabled = !this.selectedElements.length; return html` { .objects=${this.selectedElements} .delete=${(item: Role) => { if (!this.targetUser) return; - return new RbacApi(DEFAULT_CONFIG).rbacRolesRemoveUserCreate({ + return this.#api.rbacRolesRemoveUserCreate({ uuid: item.pk, userAccountSerializerForRoleRequest: { pk: this.targetUser.pk, @@ -134,10 +173,46 @@ export class RelatedRoleList extends Table { `; } + protected isInherited(role: Role): boolean { + if (this.targetGroup) { + // For groups, check if role is in direct roles + if (!this.targetGroup.roles) return false; + + return !this.targetGroup.roles.includes(role.pk); + } + + if (this.targetUser) { + // For users, check if role is in direct roles + + if (!this.targetUser.roles) return false; + + return !this.targetUser.roles.includes(role.pk); + } + + return false; + } + row(item: Role): SlottedTemplateResult[] { + const inherited = this.showInherited && this.isInherited(item); + const inheritedTooltip = this.targetGroup + ? msg("Inherited from parent group") + : msg("Inherited from group"); + const nameCell = html`${item.name} ${inherited + ? html` + +  ${msg("Inherited")} + + ` + : nothing}`; + + // Hide actions in showInherited mode (view-only) + if (this.showInherited) { + return [nameCell]; + } + return [ - html`${item.name}`, - html` + nameCell, + html` ${msg("Update")} ${msg("Update Role")} @@ -151,6 +226,10 @@ export class RelatedRoleList extends Table { } renderToolbar(): TemplateResult { + // Hide add buttons in showInherited mode (view-only) + if (this.showInherited || this.targetGroup) { + return html`${super.renderToolbar()}`; + } return html` ${this.targetUser ? html` diff --git a/web/src/admin/users/RoleSelectModal.ts b/web/src/admin/users/RoleSelectModal.ts index 0ca35272ea7b..354c2964adad 100644 --- a/web/src/admin/users/RoleSelectModal.ts +++ b/web/src/admin/users/RoleSelectModal.ts @@ -49,7 +49,9 @@ export class RoleSelectModal extends TableModal { renderModalInner(): SlottedTemplateResult { return html`
    -

    ${msg("Assign User to Groups")}

    +

    + ${msg("Select roles to attach to the user")} +

    ${this.renderTable()}
    diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index 1a3b1c214d2f..984ec590600a 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -370,6 +370,42 @@ export class UserViewPage extends WithCapabilitiesConfig(WithSession(AKElement)) `; } + protected renderTabRoles(user: User): TemplateResult { + return html` + +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + `; + } + render() { if (!this.user) { return nothing; @@ -452,13 +488,8 @@ export class UserViewPage extends WithCapabilitiesConfig(WithSession(AKElement)) slot="page-roles" id="page-roles" aria-label=${msg("Roles")} - class="pf-c-page__main-section pf-m-no-padding-mobile" > -
    -
    - -
    -
    + ${this.renderTabRoles(this.user)}