8000 Allow extending fine-grained admin permissions for groups to their children by baptistegh · Pull Request #55019 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8000
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ public Class<ScopePermissionRepresentation> getRepresentationType() {
public ScopePermissionRepresentation toRepresentation(Policy policy, AuthorizationProvider authorization) {
ScopePermissionRepresentation representation = new ScopePermissionRepresentation();
representation.setResourceType(policy.getConfig().get("defaultResourceType"));
if (AdminPermissionsSchema.SCHEMA.isExtendChildren(policy)) {
representation.setExtendChildren(Boolean.TRUE);
}
return representation;
}

Expand Down Expand Up @@ -93,6 +96,22 @@ private void updateResourceType(Policy policy, ScopePermissionRepresentation rep
return resourceType != null ? representation.getResourceType() : null;
});

Boolean extendChildren = representation.getExtendChildren();

// null means the flag is not being changed, so that clients unaware of it do not reset it on update
if (Boolean.TRUE.equals(extendChildren)) {
config.put(AdminPermissionsSchema.EXTEND_CHILDREN, Boolean.TRUE.toString());
} else if (Boolean.FALSE.equals(extendChildren)) {
config.remove(AdminPermissionsSchema.EXTEND_CHILDREN);
}

// the flag only applies to specific groups. It is removed once the permission no longer targets them, so
// that a client unaware of it does not silently extend the permission again when targeting specific groups
// later. The resources of the permission are already updated at this point
if (!AdminPermissionsSchema.SCHEMA.targetsSpecificGroups(policy)) {
config.remove(AdminPermissionsSchema.EXTEND_CHILDREN);
}

policy.setConfig(config);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,24 @@
*/
public class ScopePermissionRepresentation extends AbstractPolicyRepresentation {

private Boolean extendChildren;

@Override
public String getType() {
return "scope";
}

/**
* Only applicable to admin permissions for the {@code Groups} resource type. When {@code true}, the permission also
* applies to all the descendants of the groups associated with the permission.
*
* @return {@code true} if the permission extends to children groups, or {@code null} if not set
*/
public Boolean getExtendChildren() {
return extendChildren;
}

public void setExtendChildren(Boolean extendChildren) {
this.extendChildren = extendChildren;
}
}
10 changes: 10 additions & 0 deletions docs/documentation/release_notes/topics/27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ This removes the need for a separate API call to discover the service account us
The dedicated `GET /admin/realms/\{realm}/clients/\{id}/service-account-user` endpoint has been removed.
See the upgrading guide for migration details.

== Delegate the administration of a group hierarchy with fine-grained admin permissions
// https://github.com/keycloak/keycloak/issues/43865

Until now, a fine-grained admin permission for a group applied only to that group, so a delegated administrator could not manage the subgroups of a group without a full administrator granting a permission on each of them, including every newly created subgroup.

A permission for specific groups can now be extended to all their subgroups, including subgroups created later, by enabling the new *Extend to children* option.
A subgroup can still override an inherited permission with a permission of its own.

For more details, see link:{adminguide_link}#_extending-group-permissions-to-subgroups[Extending group permissions to subgroups] in the {adminguide_name}.

= Security and Standards

== PAR discovery respects the feature flag
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,50 @@ For example, given a hierarchy `Department > Team A > Subteam X`:
* To also restore access to `Subteam X` members, you must add a separate allow for `view-members` on `Subteam X`.
* Granting a direct `view` permission on a specific user in `Team A` does not restore access if the group deny is in effect.

[[_extending-group-permissions-to-subgroups]]
====== Extending gr BD0 oup permissions to subgroups

By default, a permission for specific groups applies only to the groups selected in the permission.
To also apply it to all the subgroups of the selected groups, including subgroups created later, enable *Extend to children* on the permission.
When using the Admin REST API, set the `extendChildren` property of the permission to `true`.
This option is only available for permissions o 10000 f the *Groups* resource type that apply to specific groups.

This is useful for delegating the administration of a group hierarchy.
For example, an administrator granted `view` and `manage` on `Department` with *Extend to children* enabled can create subgroups under `Department` and manage them without any further permission.

The following rules decide which permission applies to a subgroup for a given scope:

* If the subgroup has a permission of its own for the scope, only that permission is evaluated, regardless of the administrators it targets.
* Otherwise, for the `view`, `manage`, and `manage-membership` scopes, the closest ancestor with an extended permission for the scope decides.
* For the member scopes (`view-members`, `manage-members`, `impersonate-members`, `delegate-members`, and `manage-membership-of-members`), every ancestor with an extended permission for the scope is evaluated, so a deny from any of them takes precedence.
Once a member scope is inherited from an extended ancestor, the permissions of the other ancestors for that scope are also evaluated, even without *Extend to children*.
This is consistent with how member scopes protect the users of a group and of its subgroups when evaluating the users themselves.
* A permission inherited from an ancestor only decides the scopes that are not decided by a more specific group, even if the permission has more scopes.
* A permission inherited from an ancestor takes precedence over a permission for all groups, as a permission for a specific group does.
* For the `view`, `manage`, and `manage-membership` scopes, a permission without *Extend to children* only applies to its own groups and does not stop the inheritance.
As a result, an administrator can be granted access to a subgroup while being denied access to its parent group.

For example, given a hierarchy `Department > Team A > Subteam X`:

* Granting `view` on `Department` with *Extend to children* enabled grants `view` on `Department`, `Team A`, and `Subteam X`.
* Adding a permission that denies `view` on `Team A`, without *Extend to children*, denies `view` on `Team A` only.
`Subteam X` still inherits from `Department`.
* Adding the same deny on `Team A` with *Extend to children* enabled also denies `view` on `Subteam X`.
* Granting `view-members` on `Department` with *Extend to children* enabled and denying `view-members` on `Team A`, without *Extend to children*, denies `view-members` on `Team A` and `Subteam X`.
The members of `Subteam X` are neither viewable nor included in user search results.

When *Extend to children* is enabled on a permission with the `view-members` scope, members of the subgroups are also included in user search and count results.

NOTE: A permission without any policy, which can only be created with the Admin REST API, is ignored when searching groups and users.
It is still a permission of its own for its groups in direct checks, so it denies its scopes on these groups instead of the inherited permissions.
For member scopes, the members of these groups might then be included in user search results while being denied when accessed directly.
To avoid this, associate at least one policy with every permission.

====== Search visibility for group member permissions

When `view-members` is granted on a parent group, members of child groups are accessible via direct user lookup (for example, `GET /admin/realms/{realm}/users/{userId}`) but may not appear in user search or count results. The search mechanism uses a partial evaluation that matches users against the specific groups referenced in the permission, not against their descendants.

To ensure that members of child groups appear in search and count results, grant `view-members` on each child group explicitly.
To ensure that members of child groups appear in search and count results, grant `view-members` on each child group explicitly, or enable *Extend to children* on the permission.

When a delegated administrator has `view` permission on a group but not on its ancestor groups, the Admin REST API returns the full group hierarchy so the administrator can understand where the group belongs. Ancestor groups without `view` permission are returned as stripped representations containing only `id`, `name`, `path`, `parentId`, and `description`. Sensitive fields such as `attributes`, `realmRoles`, `clientRoles`, and `access` are omitted from these ancestors.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3978,6 +3978,7 @@ usersResources=Users
clientsResources=Clients
groupsResources=Groups
organizationsResources=Organizations
extendToChildrenPermissionHelp=When on, this permission also applies to all the subgroups of the selected groups, including subgroups created later. A subgroup with a permission of its own for a scope is decided by that permission, and the closest group with this option on takes precedence over more distant ones.
resourceTypeHelpText=Specifies which {{resourceType}} are allowed by this permission. If left blank, it is the same as selecting all.
evaluation=Evaluation
addSubFlowTo=Add sub-flow to {{name}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { UserSelect } from "../../components/users/UserSelect";
import { RoleSelect } from "./RoleSelect";
import { ClientSelectComponent } from "./ClientSelectComponent";
import { OrganizationSelect } from "./OrganizationSelect";
import { DefaultSwitchControl } from "../../components/SwitchControl";

type ResourceTypeProps = {
withEnforceAccessTo?: boolean;
Expand Down Expand Up @@ -73,6 +74,7 @@ export const ResourceType = ({
=> {
setIsSpecificResources(false);
form.setValue("resources", []);
form.setValue("extendChildren", false);
}}
className="pf-v5-u-mb-md"
/>
Expand Down Expand Up @@ -103,6 +105,16 @@ export const ResourceType = ({
isRadio={!withEnforceAccessTo}
/>
)}
{withEnforceAccessTo &&
isSpecificResources &&
normalizedResourceType === "groups" && (
<DefaultSwitchControl
name="extendChildren"
label={t("extendToChildren")}
labelIcon={t("extendToChildrenPermissionHelp")}
defaultValue={false}
/>
)}
</>
);
};
72 changes: 71 additions & 1 deletion js/apps/admin-ui/test/permissions/main.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { expect, test } from "@playwright/test";
import { v4 as uuid } from "uuid";
import adminClient from "../utils/AdminClient.ts";
import { clickSaveButton, selectItem } from "../utils/form.ts";
import {
assertSwitchIsChecked,
clickSaveButton,
selectItem,
switchOff,
switchOn,
} from "../utils/form.ts";
import { login } from "../utils/login.ts";
import { assertNotificationMessage } from "../utils/masthead.ts";
import { goToRealm } from "../utils/sidebar.ts";
Expand Down Expand Up @@ -145,6 +151,70 @@ test.describe.serial("Permissions section tests", () => {
await deletePermission(page, "test-group-permission");
});

test("should extend group permission to children", async ({ page }) => {
const extendChildren = "#extendChildren";

await clickCreatePermission(page);
await selectResource(page, "Groups");
await fillPermissionForm(page, {
name: "test-extend-children-permission",
scopes: ["view"],
});
// only available for specific groups
await expect(page.locator(extendChildren)).toBeHidden();
await fillPermissionForm(page, { enforcementMode: "specificResources" });
await expect(page.locator(extendChildren)).toBeVisible();
10BC8 await assertSwitchIsChecked(page, extendChildren, true);

await pickGroup(page, "one");
await switchOn(page, extendChildren);

await clickCreateNewPolicy(page);
await fillPolicyForm(
page,
{
name: "test-extend-children-policy",
description: "test-description",
type: "User",
user: "test-user",
},
true,
);
await clickCreatePolicySaveButton(page);
await assertNotificationMessage(page, "Successfully created the policy");
await clickSaveButton(page);
await assertNotificationMessage(
page,
"Successfully created the permission",
);

await page.reload();
await assertSwitchIsChecked(page, extendChildren);

await switchOff(page, extendChildren);
await clickSaveButton(page);
await assertNotificationMessage(
page,
"Successfully updated the permission",
);

await page.reload();
await assertSwitchIsChecked(page, extendChildren, true);

await goToPermissions(page);
await deletePermission(page, "test-extend-children-permission");
});

test("should not offer to extend permissions of other resource types", async ({
page,
}) => {
await clickCreatePermission(page);
await selectResource(page, "Organizations");
await fillPermissionForm(page, { enforcementMode: "specificResources" });
await expect(page.getByTestId("select-organization-button")).toBeVisible();
await expect(page.locator("#extendChildren")).toBeHidden();
});

test("should edit organization permission", async ({ page }) => {
await clickCreatePermission(page);
await selectResource(page, "Organizations");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ export default interface PolicyRepresentation {
users?: string[];
roles?: PolicyRoleRepresentation[];
resourceType?: string;
extendChildren?: boolean;
}
Loading
0