8000 Support mapping existing Realm and Client roles to Organization Groups by vramik · Pull Request #48670 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/documentation/release_notes/topics/26_7_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ The `manage-realm` role continues to implicitly grant full organization manageme

For per-organization granularity, organizations are now a first-class resource type in Fine-Grained Admin Permissions. Administrators can create permissions to control which specific organizations a delegated administrator can view or manage — for example, granting access to manage one organization without giving access to all organizations in the realm. When Fine-Grained Admin Permissions is enabled, organization member queries also respect user-level permissions, returning only members the administrator is permitted to view.

== Role mappings on organization groups

Organization groups now support realm and client role assignments. Roles assigned to organization groups are automatically included in `realm_access` and `resource_access` token claims for group members. Additionally, the *Organization Group Membership* protocol mapper (OIDC and SAML) can optionally include these roles within the `organization` claim, organized per organization, by enabling the *Add group role mappings* configuration option.

For more details, see the link:{adminguide_link}#pass:a[_managing_groups_][Managing organization groups] guide.

== Passkey authenticator icons in login and account console

The login page and account console now display vendor-specific icons for registered passkeys and security keys.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ Include groups in the UserInfo endpoint response.
Add to introspection::
Include groups in the Introspection endpoint response.

Add group role mappings::
When enabled, realm and client roles assigned to the user's o 8000 rganization groups are included in the token for each organization. See <<oidc-token-with-roles,Token structure with roles>> for the output format.

. Click *Save*.

The `organization` scope is added as an optional scope to all clients by default. Applications request it using `scope=organization` (or `scope=organization:alias` for a specific organization, or multiple specific organizations like `scope=organization:org-a organization:org-b`).
Expand Down Expand Up @@ -163,6 +166,35 @@ Notice:
- Make sure you have a single `organization-membership` mapper so that groups are included in the same claim as the organization data
- Multiple organizations can be included if the user is a member of multiple organizations and uses `scope=organization:*` or multiple specific scopes like `scope=organization:org-a organization:org-b`

[[oidc-token-with-roles]]
==== Token structure with roles

When *Add group role mappings* is enabled on the mapper, realm and client roles assigned to the user's organization groups are included under `realm_access` and `resource_access` keys, following the same structure as standard Keycloak token role claims:

[source,json]
----
{
"organization": {
"orgA": {
"id": "f8d3c4e1-...",
"groups": [ "/Engineering/Backend" ],
"realm_access": {
"roles": [ "org-admin", "viewer" ]
},
"resource_access": {
"my-client": {
"roles": [ "client-editor" ]
}
}
}
}
}
----

Composite roles are automatically expanded—if a group has a composite role, all its child roles are included.

NOTE: These roles are specific to the organization context and appear inside the `organization` claim. Standard `realm_access` and `resource_access` claims already include roles from all groups (including organization groups) regardless of this mapper setting.

=== SAML: Organization groups in assertions

SAML automatically includes groups for all organizations the user is a member of. Like OIDC, the group mapper requires the organization membership mapper to be present in the same scope.
Expand All @@ -180,7 +212,12 @@ The built-in `saml_organization` scope already has the Organization Membership m
. Select *Organization Group Membership*.
. Click *Save*.

The mapper has no additional configuration options. Groups are automatically included for all SAML clients.
The mapper has one optional configuration:

Add group role mappings::
When enabled, realm and client roles assigned to the user's organization groups are included as additional SAML attributes. See <<saml-assertion-with-roles,SAML assertion structure with roles>> for the output format.

Groups are automatically included for all SAML clients.

==== Option 2: Create custom SAML scope with both mappers

Expand Down Expand Up @@ -219,6 +256,30 @@ When a user from Organization A's `/Engineering/Backend` group authenticates, th

Each organization gets its own attribute named `+organization.{alias}.groups+` with group paths as values, where `alias` is the organization alias.

[[saml-assertion-with-roles]]
==== SAML assertion structure with roles

When *Add group role mappings* is enabled on the mapper, realm and client roles assigned to the user's organization groups are included as additional attributes:

[source,xml]
----
<Attribute Name="organization.orgA.groups">
<AttributeValue>/Engineering/Backend</AttributeValue>
</Attribute>
<Attribute Name="organization.orgA.realm_access.roles">
<AttributeValue>org-admin</AttributeValue>
<AttributeValue>viewer</AttributeValue>
</Attribute>
<Attribute Name="organization.orgA.resource_access.my-client.roles">
<AttributeValue>client-editor</AttributeValue>
</Attribute>
----

Composite roles are automatically expanded. Each role type uses a separate attribute:

- `+organization.{alias}.realm_access.roles+` — realm roles from the user's organization groups
- `+organization.{alias}.resource_access.{clientId}.roles+` — client roles, with one attribute per client

NOTE: Unlike OIDC, SAML clients don't need to request a scope at runtime. The `saml_organization` scope is added as a default scope, so groups are automatically included in assertions for all organizations the user is a member of.

== Managing group attributes
Expand All @@ -234,6 +295,24 @@ Groups can carry attributes—key/value metadata stored at the group level.

Group attributes are stored separately from user attributes. To include group attributes in tokens, configure a *User Attribute* mapper with the *Aggregate attributes* option enabled. The mapper will then combine matching attributes from both the user and all their groups.

== Assigning roles to groups

Organization groups support realm and client role mappings, just like realm groups. This lets you define role-based access that applies to all members of a group.

.Procedure

. Select an organization group.
. Click the *Role mapping* tab.
. Click *Assign role*.
. Select one or more realm or client roles.
. Click *Assign*.

Roles assigned to organization groups are automatically included in `realm_access` and `resource_access` token claims for members of those groups—no additional mapper configuration is required.

To also include these roles within the `organization` claim (organized per organization), enable the *Add group role mappings* option on the *Organization Group Membership* mapper. See <<oidc-token-with-roles,Token structure with roles>> and <<saml-assertion-with-roles,SAML assertion structure with roles>> for details.

NOTE: Assigning roles to organization groups requires the `manage-organizations` realm admin role (or equivalent Fine-Grained Admin Permission on the organization) and the `map-role` permission on the specific role being assigned.

== Important distinctions

=== Organization groups vs realm groups
Expand All @@ -250,7 +329,7 @@ Group attributes are stored separately from user attributes. To include group at
|Fully isolated—same paths can exist across organizations

|Role assignment
|Not supported (coming in future releases)
|Supported—realm and client roles can be assigned to organization groups via the Admin API and Admin Console

|Authorization policies
|Cannot be used in authorization policies—use realm groups instead
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ keycloak.access_token['/preferred_username']
keycloak.access_token['/custom_claim']
resource_id
alias
clientId
user_id
ticket_id
$evaluation.grant()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ A transaction is started automatically before the REST endpoint code is invoked,

As before, transactions can be nested, which will pause the outer transaction once the nested transaction starts, and will resume the outer transaction once the nested transaction ends.

=== Organization group representations now include role mappings

Organization group representations returned by the Admin REST API now include `realmRoles` and `clientRoles` fields. Previously, these fields were `null` for organization groups. They are now empty lists/maps by default, and populated when roles are assigned to the group. If your code checks for `null` to detect organization groups or conditionally processes role mappings, update it to handle the new behavior.

=== Realm search now matches by display name

When searching for realms in the admin console, the search now also matches against the realm's display name in addition to the realm name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public interface OrganizationGroupResource {
@Produces(MediaType.APPLICATION_JSON)
GroupRepresentation toRepresentation(@QueryParam("subGroupsCount") boolean subGroupsCount);

@Path("role-mappings")
RoleMappingResource roles();

@PUT
@Consumes(MediaType.APPLICATION_JSON)
Response update(GroupRepresentation rep);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3716,6 +3716,8 @@ addOrganizationId.label=Add organization id
addOrganizationId.help=If enabled, the organization id will be available for each organization mapped to the token.
addOrganizationDomain.label=Add organization domain
addOrganizationDomain.help=If enabled, the organization domain matching the user's email domain will be available for each organization mapped to the token.
addGroupRoleMappings.label=Add group role mappings
addGroupRoleMappings.help=If enabled, realm and client roles assigned to the user's organization groups will be included in the token for each organization.
identityProviderUnlink=Unlink identity provider?
identityProviderUnlinkConfirm=Are you sure you want to unlink this identity provider?
disableConfirmUserTitle=Disable user?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type AddRoleMappingModalProps = {
onClose: () => void;
title?: string;
actionLabel?: string;
groupsResource?: any;
};

export type FilterType = "roles" | "clients";
Expand Down Expand Up @@ -124,6 +125,7 @@ export const AddRoleMappingModal = ({
onClose,
title,
actionLabel,
groupsResource,
}: AddRoleMappingModalProps) => {
const { adminClient } = useAdminClient();

Expand All @@ -147,7 +149,12 @@ export const AddRoleMappingModal = ({
params.search = search;
}

const roles = await getAvailableRoles(adminClient, type, { ...params, id });
const roles = await getAvailableRoles(
adminClient,
type,
{ ...params, id },
groupsResource,
);
const sorted = localeSort(roles, compareRow);
return sorted.map((row) => {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type RoleMappingProps = {
type: ResourcesKey;
isManager?: boolean;
save: (rows: Row[]) => Promise<void>;
groupsResource?: any;
};

export const RoleMapping = ({
Expand All @@ -92,6 +93,7 @@ export const RoleMapping = ({
type,
isManager = true,
save,
groupsResource,
}: RoleMappingProps) => {
const { adminClient } = useAdminClient();

Expand Down Expand Up @@ -128,7 +130,7 @@ export const RoleMapping = ({
}));
}

const roles = await getMapping(adminClient, type, id);
const roles = await getMapping(adminClient, type, id, groupsResource);
const realmRolesMapping =
roles.realmMappings?.map((role) => ({ role })) || [];
const clientMapping = Object.values(roles.clientMappings || {})
Expand Down Expand Up @@ -180,6 +182,7 @@ export const RoleMapping = ({
name={name}
>
=> setShowAssign(false)}
groupsResource={groupsResource}
/>
)}
<DeleteConfirm />
Expand Down
37 changes: 12 additions & 25 deletions js/apps/admin-ui/src/components/role-mapping/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,27 +96,6 @@ const mapping: ResourceMapping = {
},
};

type queryType =
| DeleteFunctions
| ListAvailableFunction
| ListEffectiveFunction;

const castAdminClient = (
adminClient: KeycloakAdminClient,
resource: ResourcesKey,
) =>
adminClient[resource] as unknown as {
[index in queryType]: (...params: any) => Promise<RoleRepresentation[]>;
};

const applyQuery = (
adminClient: KeycloakAdminClient,
type: ResourcesKey,
query: queryType,
...params: object[]
): Promise<RoleRepresentation[]> =>
castAdminClient(adminClient, type)[query](...params);

export const deleteMapping = (
adminClient: KeycloakAdminClient,
type: ResourcesKey,
Expand All @@ -136,9 +115,12 @@ export const getMapping = async (
adminClient: KeycloakAdminClient,
type: ResourcesKey,
id: string,
groupsResource?: any,
): Promise<MappingsRepresentation> => {
const query = mapping[type]!.listEffective[0];
const result = applyQuery(adminClient, type, query, { id });
const resource =
type === "groups" && groupsResource ? groupsResource : adminClient[type];
const result = (resource as any)[query]({ id });
if (type !== "roles") {
return result as MappingsRepresentation;
}
Expand Down Expand Up @@ -172,9 +154,14 @@ export const getAvailableRoles = async (
adminClient: KeycloakAdminClient,
type: ResourcesKey,
params: Record<string, string | number>,
groupsResource?: any,
): Promise<Row[]> => {
const query = mapping[type]!.listAvailable[1];
return (await applyQuery(adminClient, type, query, params)).map((role) => ({
role,
}));
const resource =
type === "groups" && groupsResource ? groupsResource : adminClient[type];
return (await (resource as any)[query](params)).map(
(role: RoleRepresentation) => ({
role,
}),
);
};
1 change: 1 addition & 0 deletions js/apps/admin-ui/src/groups/GroupRoleMapping.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const GroupRoleMapping = ({
id={id}
type="groups"
save={assignRoles}
groupsResource={groups}
/>
);
};
2 changes: 1 addition & 1 deletion js/apps/admin-ui/src/groups/GroupsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export default function GroupsSection({ orgId }: { orgId?: string } = {}) {
>
<GroupAttributes />
</Tab>
{!isOrganization && canViewRoles && (
{canViewRoles && (
<Tab
eventKey={3}
data-testid="role-mapping-tab"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1737,7 +1737,7 @@ private void importOrganizations(RealmRepresentation rep, RealmModel newRealm) {
}

for (GroupRepresentation groupRep : Optional.ofNullable(orgRep.getGroups()).orElse(Collections.emptyList())) {
importOrganizationGroup(provider, orgModel, groupRep, null);
importOrganizationGroup(provider, orgModel, newRealm, groupRep, null);
}

for (MemberRepresentation member : Optional.ofNullable(orgRep.getMembers()).orElse(Collections.emptyList())) {
Expand Down Expand Up @@ -1774,7 +1774,7 @@ private void validateClientAndRealmTimeouts(RealmModel realm) {
}
}

private void importOrganizationGroup(OrganizationProvider provider, OrganizationModel organization, GroupRepresentation groupRep, GroupModel parent) {
private void importOrganizationGroup(OrganizationProvider provider, OrganizationModel organization, RealmModel realm, GroupRepresentation groupRep, GroupModel parent) {
GroupModel group = provider.createGroup(organization, groupRep.getId(), groupRep.getName(), parent);

if (groupRep.getAttributes() != null) {
Expand All @@ -1783,9 +1783,34 @@ private void importOrganizationGroup(OrganizationProvider provider, Organization
}
}

if (groupRep.getRealmRoles() != null) {
for (String roleString : groupRep.getRealmRoles()) {
RoleModel role = realm.getRole(roleString.trim());
if (role == null) {
role = realm.addRole(roleString.trim());
}
group.grantRole(role);
}
}
if (groupRep.getClientRoles() != null) {
for (Map.Entry<String, List<String>> entry : groupRep.getClientRoles().entrySet()) {
ClientModel client = realm.getClientByClientId(entry.getKey());
if (client == null) {
throw new RuntimeException("Unable to find client role mappings for client: " + entry.getKey());
}
for (String roleName : entry.getValue()) {
RoleModel role = client.getRole(roleName.trim());
if (role == null) {
role = client.addRole(roleName.trim());
}
group.grantRole(role);
}
}
}

if (groupRep.getSubGroups() != null) {
for (GroupRepresentation subGroup : groupRep.getSubGroups()) {
importOrganizationGroup(provider, organization, subGroup, group);
importOrganizationGroup(provider, organization, realm, subGroup, group);
}
}
}
Expand Down
Loading
Loading
0