Escape slashes in full group path representation but disabled by default

Closes #23900

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-01-17 16:30:49 +01:00 committed by Marek Posolda
parent 67e4015f67
commit 6d74e6b289
15 changed files with 380 additions and 34 deletions

View file

@ -12,6 +12,15 @@ Groups are hierarchical. A group can have multiple subgroups but a group can hav
If you have a parent group and a child group, and a user that belongs only to the child group, the user in the child group inherits the attributes and role mappings of both the parent group and the child group. If you have a parent group and a child group, and a user that belongs only to the child group, the user in the child group inherits the attributes and role mappings of both the parent group and the child group.
The hierarchy of a group is sometimes represented using the group path. The path is the complete list of names that represents the hierarchy of a specific group, from top to bottom and separated by slashes `/` (similar to files in a File System). For example a path can be `/top/level1/level2` which means that `top` is a top level group and is parent of `level1`, which in turn is parent of `level2`. This path represents unambiguously the hierarchy for the group `level2`.
Because of historical reasons {project_name}, does not escape slashes in the group name itself. Therefore a group named `level1/group` under `top` uses the path `/top/level1/group`, which is misleading. {project_name} can be started with the option `--spi-group-jpa-escape-slashes-in-group-path` to `true` and then the slashes in the name are escaped with the character `~`. The escape char marks that the slash is part of the name and has no hierarchical meaning. The previous path example would be `/top/level1~/group` when escaped.
[source,bash]
----
bin/kc.[sh|bat] start --spi-group-jpa-escape-slashes-in-group-path=true
----
The following example includes a top-level *Sales* group and a child *North America* subgroup. The following example includes a top-level *Sales* group and a child *North America* subgroup.
To add a group: To add a group:

View file

@ -143,4 +143,17 @@ However, this property is deprecated and will be removed in future releases, so
The management interface uses a different HTTP server than the default {project_name} HTTP server, and it is possible to configure them separately. The management interface uses a different HTTP server than the default {project_name} HTTP server, and it is possible to configure them separately.
Beware, if no values are supplied for the management interface properties, they are inherited from the default {project_name} HTTP server. Beware, if no values are supplied for the management interface properties, they are inherited from the default {project_name} HTTP server.
For more details, see https://www.keycloak.org/server/management-interface[Configuring the Management Interface]. For more details, see https://www.keycloak.org/server/management-interface[Configuring the Management Interface].
= Escaping slashes in group paths
{project_name} has never escaped slashes in the group paths. Because of that, a group named `group/slash` child of `top` uses the full path `/top/group/slash`, which is clearly misleading. Starting with this version, the server can be started to perform escaping of those slashes in the name:
[source,bash]
----
bin/kc.[sh|bat] start --spi-group-jpa-escape-slashes-in-group-path=true
----
The escape char is the tilde character `~`. The previous example results in the path `/top/group~/slash`. The escape marks the last slash is part of the name and not a hierarchy separator.
The escaping is currently disabled by default because it represents a change in behavior. Nevertheless enabling escaping is recommended and it can be the default in future versions.

View file

@ -74,7 +74,10 @@ export const UserGroups = ({ user }: UserGroupsProps) => {
const indirect: GroupRepresentation[] = []; const indirect: GroupRepresentation[] = [];
if (!isDirectMembership) if (!isDirectMembership)
joinedUserGroups.forEach((g) => { joinedUserGroups.forEach((g) => {
const paths = g.path?.substring(1).split("/").slice(0, -1) || []; const paths = (
g.path?.substring(1).match(/((~\/)|[^/])+/g) || []
).slice(0, -1);
indirect.push( indirect.push(
...paths.map((p) => ({ ...paths.map((p) => ({
name: p, name: p,

View file

@ -23,6 +23,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel; import org.keycloak.models.RoleModel;
import org.keycloak.models.cache.infinispan.entities.CachedGroup; import org.keycloak.models.cache.infinispan.entities.CachedGroup;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.RoleUtils; import org.keycloak.models.utils.RoleUtils;
import java.util.HashSet; import java.util.HashSet;
@ -284,4 +285,9 @@ public class GroupAdapter implements GroupModel {
private GroupModel getGroupModel() { private GroupModel getGroupModel() {
return cacheSession.getGroupDelegate().getGroupById(realm, cached.getId()); return cacheSession.getGroupDelegate().getGroupById(realm, cached.getId());
} }
@Override
public boolean escapeSlashesInGroupPath() {
return KeycloakModelUtils.escapeSlashesInGroupPath(keycloakSession);
}
} }

View file

@ -48,11 +48,13 @@ import static org.keycloak.utils.StreamsUtil.closing;
*/ */
public class GroupAdapter implements GroupModel , JpaModel<GroupEntity> { public class GroupAdapter implements GroupModel , JpaModel<GroupEntity> {
protected final KeycloakSession session;
protected GroupEntity group; protected GroupEntity group;
protected EntityManager em; protected EntityManager em;
protected RealmModel realm; protected RealmModel realm;
public GroupAdapter(RealmModel realm, EntityManager em, GroupEntity group) { public GroupAdapter(KeycloakSession session, RealmModel realm, EntityManager em, GroupEntity group) {
this.session = session;
this.em = em; this.em = em;
this.group = group; this.group = group;
this.realm = realm; this.realm = realm;
@ -313,6 +315,8 @@ public class GroupAdapter implements GroupModel , JpaModel<GroupEntity> {
return getId().hashCode(); return getId().hashCode();
} }
@Override
public boolean escapeSlashesInGroupPath() {
return KeycloakModelUtils.escapeSlashesInGroupPath(session);
}
} }

View file

@ -23,22 +23,27 @@ import org.keycloak.models.GroupProvider;
import org.keycloak.models.GroupProviderFactory; import org.keycloak.models.GroupProviderFactory;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
import static org.keycloak.models.jpa.JpaRealmProviderFactory.PROVIDER_ID; import static org.keycloak.models.jpa.JpaRealmProviderFactory.PROVIDER_ID;
import static org.keycloak.models.jpa.JpaRealmProviderFactory.PROVIDER_PRIORITY; import static org.keycloak.models.jpa.JpaRealmProviderFactory.PROVIDER_PRIORITY;
import org.keycloak.provider.ProviderConfigurationBuilder;
public class JpaGroupProviderFactory implements GroupProviderFactory { public class JpaGroupProviderFactory implements GroupProviderFactory {
private Set<String> groupSearchableAttributes = null; private Set<String> groupSearchableAttributes = null;
private boolean escapeSlashesInGroupPath;
@Override @Override
public void init(Config.Scope config) { public void init(Config.Scope config) {
escapeSlashesInGroupPath = config.getBoolean("escapeSlashesInGroupPath", GroupProvider.DEFAULT_ESCAPE_SLASHES);
String[] searchableAttrsArr = config.getArray("searchableAttributes"); String[] searchableAttrsArr = config.getArray("searchableAttributes");
if (searchableAttrsArr == null) { if (searchableAttrsArr == null) {
String s = System.getProperty("keycloak.group.searchableAttributes"); String s = System.getProperty("keycloak.group.searchableAttributes");
@ -77,4 +82,25 @@ public class JpaGroupProviderFactory implements GroupProviderFactory {
return PROVIDER_PRIORITY; return PROVIDER_PRIORITY;
} }
@Override
public boolean escapeSlashesInGroupPath() {
return escapeSlashesInGroupPath;
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return ProviderConfigurationBuilder.create()
.property()
.name("escapeSlashesInGroupPath")
.helpText("If true slashes `/` in group names are escaped with the character `~` when converted to paths.")
.type("boolean")
.defaultValue(false)
.add()
.property()
.name("searchableAttributes")
.helpText("The list of attributes separated by comma that are allowed in client attribute searches.")
.type("string")
.add()
.build();
}
} }

View file

@ -483,7 +483,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
GroupEntity groupEntity = em.find(GroupEntity.class, id); GroupEntity groupEntity = em.find(GroupEntity.class, id);
if (groupEntity == null) return null; if (groupEntity == null) return null;
if (!groupEntity.getRealm().equals(realm.getId())) return null; if (!groupEntity.getRealm().equals(realm.getId())) return null;
GroupAdapter adapter = new GroupAdapter(realm, em, groupEntity); GroupAdapter adapter = new GroupAdapter(session, realm, em, groupEntity);
return adapter; return adapter;
} }
@ -650,7 +650,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
Stream<GroupEntity> results = paginateQuery(query, firstResult, maxResults).getResultStream(); Stream<GroupEntity> results = paginateQuery(query, firstResult, maxResults).getResultStream();
return closing(results return closing(results
.map(g -> (GroupModel) new GroupAdapter(realm, em, g)) .map(g -> (GroupModel) new GroupAdapter(session, realm, em, g))
.sorted(GroupModel.COMPARE_BY_NAME)); .sorted(GroupModel.COMPARE_BY_NAME));
} }
@ -733,7 +733,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
em.persist(groupEntity); em.persist(groupEntity);
em.flush(); em.flush();
return new GroupAdapter(realm, em, groupEntity); return new GroupAdapter(session, realm, em, groupEntity);
} }
@Override @Override
@ -1169,7 +1169,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
TypedQuery<GroupEntity> query = em.createQuery(queryBuilder); TypedQuery<GroupEntity> query = em.createQuery(queryBuilder);
return closing(paginateQuery(query, firstResult, maxResults).getResultStream()) return closing(paginateQuery(query, firstResult, maxResults).getResultStream())
.map(g -> new GroupAdapter(realm, em, g)); .map(g -> new GroupAdapter(session, realm, em, g));
} }
@Override @Override

View file

@ -20,4 +20,8 @@ package org.keycloak.models;
import org.keycloak.provider.ProviderFactory; import org.keycloak.provider.ProviderFactory;
public interface GroupProviderFactory<T extends GroupProvider> extends ProviderFactory<T> { public interface GroupProviderFactory<T extends GroupProvider> extends ProviderFactory<T> {
default boolean escapeSlashesInGroupPath() {
return GroupProvider.DEFAULT_ESCAPE_SLASHES;
}
} }

View file

@ -64,6 +64,7 @@ import java.security.KeyPair;
import java.security.PrivateKey; import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@ -73,8 +74,10 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.regex.Pattern;
import org.keycloak.models.AccountRoles; import org.keycloak.models.AccountRoles;
import org.keycloak.models.GroupProviderFactory;
import org.keycloak.provider.Provider; import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory; import org.keycloak.provider.ProviderFactory;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
@ -97,6 +100,7 @@ public final class KeycloakModelUtils {
public static final String AUTH_TYPE_CLIENT_SECRET_JWT = "client-secret-jwt"; public static final String AUTH_TYPE_CLIENT_SECRET_JWT = "client-secret-jwt";
public static final String GROUP_PATH_SEPARATOR = "/"; public static final String GROUP_PATH_SEPARATOR = "/";
public static final String GROUP_PATH_ESCAPE = "~";
private static final char CLIENT_ROLE_SEPARATOR = '.'; private static final char CLIENT_ROLE_SEPARATOR = '.';
private KeycloakModelUtils() { private KeycloakModelUtils() {
@ -692,10 +696,22 @@ public final class KeycloakModelUtils {
return segments; return segments;
} }
/**
* Helper to get from the session if group path slashes should be escaped or not.
* @param session The session
* @return true or false
*/
public static boolean escapeSlashesInGroupPath(KeycloakSession session) {
GroupProviderFactory fact = (GroupProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(GroupProvider.class);
return fact.escapeSlashesInGroupPath();
}
/** /**
* Finds group by path. Path is separated by '/' character. For example: /group/subgroup/subsubgroup * Finds group by path. Path is separated by '/' character. For example: /group/subgroup/subsubgroup
* <p /> * <p />
* The method takes into consideration also groups with '/' in their name. For example: /group/sub/group/subgroup * The method takes into consideration also groups with '/' in their name. For example: /group/sub/group/subgroup
* This method allows escaping of slashes for example: /parent\/group/child which
* is a two level path for ["parent/group", "child"].
* *
* @param session Keycloak session * @param session Keycloak session
* @param realm The realm * @param realm The realm
@ -707,13 +723,7 @@ public final class KeycloakModelUtils {
if (path == null) { if (path == null) {
return null; return null;
} }
if (path.startsWith(GROUP_PATH_SEPARATOR)) { String[] split = splitPath(path, escapeSlashesInGroupPath(session));
path = path.substring(1);
}
if (path.endsWith(GROUP_PATH_SEPARATOR)) {
path = path.substring(0, path.length() - 1);
}
String[] split = path.split(GROUP_PATH_SEPARATOR);
if (split.length == 0) return null; if (split.length == 0) return null;
return getGroupModel(session.groups(), realm, null, split, 0); return getGroupModel(session.groups(), realm, null, split, 0);
} }
@ -752,22 +762,76 @@ public final class KeycloakModelUtils {
return null; return null;
} }
private static void buildGroupPath(StringBuilder sb, String groupName, GroupModel parent) { /**
if (parent != null) { * Splits a group path than can be escaped for slashes.
buildGroupPath(sb, parent.getName(), parent.getParent()); * @param path The group path
* @param escapedSlashes true if slashes are escaped in the path
* @return
*/
public static String[] splitPath(String path, boolean escapedSlashes) {
if (path == null) {
return null;
} }
sb.append(GROUP_PATH_SEPARATOR).append(groupName); if (path.startsWith(GROUP_PATH_SEPARATOR)) {
path = path.substring(1);
}
if (path.endsWith(GROUP_PATH_SEPARATOR)) {
path = path.substring(0, path.length() - 1);
}
// just split by slashed that are not escaped
return escapedSlashes
? Arrays.stream(path.split("(?<!" + Pattern.quote(GROUP_PATH_ESCAPE) + ")" + Pattern.quote(GROUP_PATH_SEPARATOR)))
.map(KeycloakModelUtils::unescapeGroupNameForPath)
.toArray(String[]::new)
: path.split(GROUP_PATH_SEPARATOR);
}
/**
* Escapes the slash in the name if found. "group/slash" returns "group\/slash".
* @param groupName
* @return
*/
private static String escapeGroupNameForPath(String groupName) {
return groupName.replace(GROUP_PATH_SEPARATOR, GROUP_PATH_ESCAPE + GROUP_PATH_SEPARATOR);
}
/**
* Unescape the escaped slashes in name. "group\/slash" returns "group/slash".
* @param groupName
* @return
*/
private static String unescapeGroupNameForPath(String groupName) {
return groupName.replace(GROUP_PATH_ESCAPE + GROUP_PATH_SEPARATOR, GROUP_PATH_SEPARATOR);
}
public static String buildGroupPath(boolean escapeSlashes, String... names) {
StringBuilder sb = new StringBuilder();
sb.append(GROUP_PATH_SEPARATOR);
for (int i = 0; i < names.length; i++) {
sb.append(escapeSlashes? escapeGroupNameForPath(names[i]) : names[i]);
if (i < names.length - 1) {
sb.append(GROUP_PATH_SEPARATOR);
}
}
return sb.toString();
}
private static void buildGroupPath(StringBuilder sb, String groupName, GroupModel parent, boolean escapeSlashes) {
if (parent != null) {
buildGroupPath(sb, parent.getName(), parent.getParent(), escapeSlashes);
}
sb.append(GROUP_PATH_SEPARATOR).append(escapeSlashes? escapeGroupNameForPath(groupName) : groupName);
} }
public static String buildGroupPath(GroupModel group) { public static String buildGroupPath(GroupModel group) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
buildGroupPath(sb, group.getName(), group.getParent()); buildGroupPath(sb, group.getName(), group.getParent(), group.escapeSlashesInGroupPath());
return sb.toString(); return sb.toString();
} }
public static String buildGroupPath(GroupModel group, GroupModel otherParentGroup) { public static String buildGroupPath(GroupModel group, GroupModel otherParentGroup) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
buildGroupPath(sb, group.getName(), otherParentGroup); buildGroupPath(sb, group.getName(), otherParentGroup, group.escapeSlashesInGroupPath());
return sb.toString(); return sb.toString();
} }

View file

@ -19,9 +19,13 @@ package org.keycloak.models;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.arrayWithSize; import static org.hamcrest.Matchers.arrayWithSize;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.Test; import org.junit.Test;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
@ -63,6 +67,38 @@ public class KeycloakModelUtilsTest {
assertParsedRoleQualifier(clientIdAndRoleName, "my.client.id", "role-name"); assertParsedRoleQualifier(clientIdAndRoleName, "my.client.id", "role-name");
} }
@Test
public void testSplitEscapedPath() {
assertArrayEquals(new String[]{"parent", "child"}, KeycloakModelUtils.splitPath("/parent/child", true));
assertArrayEquals(new String[]{"parent/slash", "child"}, KeycloakModelUtils.splitPath("/parent~/slash/child", true));
assertArrayEquals(new String[]{"parent/slash", "child/slash"}, KeycloakModelUtils.splitPath("/parent~/slash/child~/slash", true));
assertArrayEquals(new String[]{"parent~/slash", "child/slash"}, KeycloakModelUtils.splitPath("/parent~~/slash/child~/slash", true));
assertArrayEquals(new String[]{"parent", "child"}, KeycloakModelUtils.splitPath("/parent/child", false));
assertArrayEquals(new String[]{"parent~", "slash", "child"}, KeycloakModelUtils.splitPath("/parent~/slash/child", false));
assertArrayEquals(new String[]{"parent~", "slash", "child~", "slash"}, KeycloakModelUtils.splitPath("/parent~/slash/child~/slash", false));
assertArrayEquals(new String[]{"parent~~", "slash", "child~", "slash"}, KeycloakModelUtils.splitPath("/parent~~/slash/child~/slash", false));
}
@Test
public void testBuildGroupPath() {
GroupAdapterTest.escapeSlashes = true;
GroupModel group = new GroupAdapterTest("child", new GroupAdapterTest("parent", null));
assertEquals("/parent/child", KeycloakModelUtils.buildGroupPath(group));
group = new GroupAdapterTest("child/slash", new GroupAdapterTest("parent/slash", null));
assertEquals("/parent~/slash/child~/slash", KeycloakModelUtils.buildGroupPath(group));
group = new GroupAdapterTest("child/slash", new GroupAdapterTest("parent~/slash", null));
assertEquals("/parent~~/slash/child~/slash", KeycloakModelUtils.buildGroupPath(group));
GroupAdapterTest.escapeSlashes = false;
group = new GroupAdapterTest("child", new GroupAdapterTest("parent", null));
assertEquals("/parent/child", KeycloakModelUtils.buildGroupPath(group));
group = new GroupAdapterTest("child/slash", new GroupAdapterTest("parent/slash", null));
assertEquals("/parent/slash/child/slash", KeycloakModelUtils.buildGroupPath(group));
group = new GroupAdapterTest("child/slash", new GroupAdapterTest("parent~/slash", null));
assertEquals("/parent~/slash/child/slash", KeycloakModelUtils.buildGroupPath(group));
}
private static void assertParsedRoleQualifier(String[] clientIdAndRoleName, String expectedClientId, private static void assertParsedRoleQualifier(String[] clientIdAndRoleName, String expectedClientId,
String expectedRoleName) { String expectedRoleName) {
@ -74,4 +110,118 @@ public class KeycloakModelUtilsTest {
assertEquals(expectedRoleName, roleName); assertEquals(expectedRoleName, roleName);
} }
private static class GroupAdapterTest implements GroupModel {
static boolean escapeSlashes = false;
private String name;
private GroupModel parent;
public GroupAdapterTest(String name, GroupModel parent) {
this.name = name;
this.parent = parent;
}
@Override
public String getId() {
return null;
}
@Override
public String getName() {
return name;
}
@Override
public void setName(String name) {
this.name = name;
}
@Override
public void setSingleAttribute(String name, String value) {
}
@Override
public void setAttribute(String name, List<String> values) {
}
@Override
public void removeAttribute(String name) {
}
@Override
public String getFirstAttribute(String name) {
return null;
}
@Override
public Stream<String> getAttributeStream(String name) {
return null;
}
@Override
public Map<String, List<String>> getAttributes() {return null;
}
@Override
public GroupModel getParent() {
return parent;
}
@Override
public String getParentId() {
return parent.getId();
}
@Override
public Stream<GroupModel> getSubGroupsStream() {
return Stream.empty();
}
@Override
public void setParent(GroupModel group) {
this.parent = group;
}
@Override
public void addChild(GroupModel subGroup) {
}
@Override
public void removeChild(GroupModel subGroup) {
}
@Override
public Stream<RoleModel> getRealmRoleMappingsStream() {
return Stream.empty();
}
@Override
public Stream<RoleModel> getClientRoleMappingsStream(ClientModel app) {
return Stream.empty();
}
@Override
public boolean hasRole(RoleModel role) {
return false;
}
@Override
public void grantRole(RoleModel role) {
}
@Override
public Stream<RoleModel> getRoleMappingsStream() {
return Stream.empty();
}
@Override
public void deleteRoleMapping(RoleModel role) {
}
@Override
public boolean escapeSlashesInGroupPath() {
return escapeSlashes;
}
}
} }

View file

@ -173,4 +173,8 @@ public interface GroupModel extends RoleMapperModel {
* @param subGroup * @param subGroup
*/ */
void removeChild(GroupModel subGroup); void removeChild(GroupModel subGroup);
default boolean escapeSlashesInGroupPath() {
return GroupProvider.DEFAULT_ESCAPE_SLASHES;
}
} }

View file

@ -30,6 +30,8 @@ import java.util.stream.Stream;
*/ */
public interface GroupProvider extends Provider, GroupLookupProvider { public interface GroupProvider extends Provider, GroupLookupProvider {
static boolean DEFAULT_ESCAPE_SLASHES = false;
/** /**
* Returns groups for the given realm. * Returns groups for the given realm.
* *

View file

@ -1084,8 +1084,7 @@ public class RealmAdminResource {
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.REALMS_ADMIN) @Tag(name = KeycloakOpenAPI.Admin.Tags.REALMS_ADMIN)
@Operation() @Operation()
public GroupRepresentation getGroupByPath(@PathParam("path") List<PathSegment> pathSegments) { public GroupRepresentation getGroupByPath(@PathParam("path") String path) {
String[] path = pathSegments.stream().map(PathSegment::getPath).toArray(String[]::new);
GroupModel found = KeycloakModelUtils.findGroupByPath(session, realm, path); GroupModel found = KeycloakModelUtils.findGroupByPath(session, realm, path);
if (found == null) { if (found == null) {
throw new NotFoundException("Group path does not exist"); throw new NotFoundException("Group path does not exist");

View file

@ -17,11 +17,16 @@
package org.keycloak.testsuite.admin.group; package org.keycloak.testsuite.admin.group;
import jakarta.ws.rs.core.Response;
import java.util.*;
import org.hamcrest.MatcherAssert; import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.keycloak.admin.client.resource.ProtocolMappersResource;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.models.GroupProvider;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.ProtocolMapperUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper; import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper;
@ -30,11 +35,12 @@ import org.keycloak.protocol.oidc.mappers.UserAttributeMapper;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import java.util.*; import org.keycloak.testsuite.util.ProtocolMapperUtil;
/** /**
* @author <a href="mailto:mstrukel@redhat.com">Marko Strukelj</a> * @author <a href="mailto:mstrukel@redhat.com">Marko Strukelj</a>
@ -130,4 +136,41 @@ public class GroupMappersTest extends AbstractGroupTest {
Assert.assertEquals("true", token.getOtherClaims().get("level2Attribute")); Assert.assertEquals("true", token.getOtherClaims().get("level2Attribute"));
} }
} }
@Test
public void testGroupMappersWithSlash() throws Exception {
RealmResource realm = adminClient.realms().realm("test");
GroupRepresentation topGroup = realm.getGroupByPath("/topGroup");
Assert.assertNotNull(topGroup);
GroupRepresentation childSlash = new GroupRepresentation();
childSlash.setName("child/slash");
try (Response response = realm.groups().group(topGroup.getId()).subGroup(childSlash)) {
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
childSlash.setId(ApiUtil.getCreatedId(response));
}
List<UserRepresentation> users = realm.users().search("level2GroupUser", true);
Assert.assertEquals(1, users.size());
UserRepresentation user = users.iterator().next();
realm.users().get(user.getId()).joinGroup(childSlash.getId());
ProtocolMappersResource protocolMappers = ApiUtil.findClientResourceByClientId(realm, "test-app").getProtocolMappers();
ProtocolMapperRepresentation groupsMapper = ProtocolMapperUtil.getMapperByNameAndProtocol(
protocolMappers, OIDCLoginProtocol.LOGIN_PROTOCOL, "groups");
groupsMapper.getConfig().put("full.path", Boolean.TRUE.toString());
protocolMappers.update(groupsMapper.getId(), groupsMapper);
try {
AccessToken token = login(user.getUsername(), "test-app", "password", user.getId());
Assert.assertNotNull(token.getOtherClaims().get("groups"));
Map<String, Collection<String>> groups = (Map<String, Collection<String>>) token.getOtherClaims().get("groups");
MatcherAssert.assertThat(groups.get("groups"), Matchers.containsInAnyOrder(
KeycloakModelUtils.buildGroupPath(GroupProvider.DEFAULT_ESCAPE_SLASHES, "topGroup", "level2group"),
KeycloakModelUtils.buildGroupPath(GroupProvider.DEFAULT_ESCAPE_SLASHES, "topGroup", "child/slash")));
} finally {
realm.users().get(user.getId()).leaveGroup(childSlash.getId());
realm.groups().group(childSlash.getId()).remove();
groupsMapper.getConfig().remove("full.path");
protocolMappers.update(groupsMapper.getId(), groupsMapper);
}
}
} }

View file

@ -31,6 +31,7 @@ import org.keycloak.admin.client.resource.UsersResource;
import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType; import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.CredentialRepresentation;
@ -87,6 +88,7 @@ import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import org.keycloak.models.GroupProvider;
import static org.keycloak.testsuite.Assert.assertNames; import static org.keycloak.testsuite.Assert.assertNames;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
@ -1370,14 +1372,13 @@ public class GroupTest extends AbstractGroupTest {
assertTrue(searchResultGroups.isEmpty()); assertTrue(searchResultGroups.isEmpty());
} }
@Test public void testParentAndChildGroup(String parentName, String childName) {
public void testGroupsWithSpaces() {
RealmResource realm = adminClient.realms().realm("test"); RealmResource realm = adminClient.realms().realm("test");
GroupRepresentation parentGroup = new GroupRepresentation(); GroupRepresentation parentGroup = new GroupRepresentation();
parentGroup.setName("parent space"); parentGroup.setName(parentName);
parentGroup = createGroup(realm, parentGroup); parentGroup = createGroup(realm, parentGroup);
GroupRepresentation childGroup = new GroupRepresentation(); GroupRepresentation childGroup = new GroupRepresentation();
childGroup.setName("child space"); childGroup.setName(childName);
try (Response response = realm.groups().group(parentGroup.getId()).subGroup(childGroup)) { try (Response response = realm.groups().group(parentGroup.getId()).subGroup(childGroup)) {
assertEquals(201, response.getStatus()); // created status assertEquals(201, response.getStatus()); // created status
childGroup.setId(ApiUtil.getCreatedId(response)); childGroup.setId(ApiUtil.getCreatedId(response));
@ -1385,20 +1386,28 @@ public class GroupTest extends AbstractGroupTest {
assertAdminEvents.assertEvent(testRealmId, OperationType.CREATE, assertAdminEvents.assertEvent(testRealmId, OperationType.CREATE,
AdminEventPaths.groupSubgroupsPath(parentGroup.getId()), childGroup, ResourceType.GROUP); AdminEventPaths.groupSubgroupsPath(parentGroup.getId()), childGroup, ResourceType.GROUP);
List<GroupRepresentation> groupsFound = realm.groups().groups("parent space", true, 0, 1, true); List<GroupRepresentation> groupsFound = realm.groups().groups(parentGroup.getName(), true, 0, 1, true);
Assert.assertEquals(1, groupsFound.size()); Assert.assertEquals(1, groupsFound.size());
Assert.assertEquals(parentGroup.getId(), groupsFound.iterator().next().getId()); Assert.assertEquals(parentGroup.getId(), groupsFound.iterator().next().getId());
Assert.assertEquals(0, groupsFound.iterator().next().getSubGroups().size()); Assert.assertEquals(0, groupsFound.iterator().next().getSubGroups().size());
groupsFound = realm.groups().groups("child space", true, 0, 1, true); parentGroup = groupsFound.iterator().next();
Assert.assertEquals(KeycloakModelUtils.buildGroupPath(GroupProvider.DEFAULT_ESCAPE_SLASHES, parentName),
parentGroup.getPath());
groupsFound = realm.groups().groups(childGroup.getName(), true, 0, 1, true);
Assert.assertEquals(1, groupsFound.size()); Assert.assertEquals(1, groupsFound.size());
Assert.assertEquals(parentGroup.getId(), groupsFound.iterator().next().getId()); Assert.assertEquals(parentGroup.getId(), groupsFound.iterator().next().getId());
Assert.assertEquals(1, groupsFound.iterator().next().getSubGroups().size()); Assert.assertEquals(1, groupsFound.iterator().next().getSubGroups().size());
Assert.assertEquals(childGroup.getId(), groupsFound.iterator().next().getSubGroups().iterator().next().getId()); Assert.assertEquals(childGroup.getId(), groupsFound.iterator().next().getSubGroups().iterator().next().getId());
childGroup = groupsFound.iterator().next().getSubGroups().iterator().next();
Assert.assertEquals(KeycloakModelUtils.normalizeGroupPath(
KeycloakModelUtils.buildGroupPath(GroupProvider.DEFAULT_ESCAPE_SLASHES, parentName, childName)),
childGroup.getPath());
GroupRepresentation groupFound = realm.getGroupByPath(parentGroup.getName()); GroupRepresentation groupFound = realm.getGroupByPath(parentGroup.getPath());
Assert.assertNotNull(groupFound); Assert.assertNotNull(groupFound);
Assert.assertEquals(parentGroup.getId(), groupFound.getId()); Assert.assertEquals(parentGroup.getId(), groupFound.getId());
groupFound = realm.getGroupByPath("/" + parentGroup.getName() + "/" + childGroup.getName()); groupFound = realm.getGroupByPath(childGroup.getPath());
Assert.assertNotNull(groupFound); Assert.assertNotNull(groupFound);
Assert.assertEquals(childGroup.getId(), groupFound.getId()); Assert.assertEquals(childGroup.getId(), groupFound.getId());
@ -1408,6 +1417,16 @@ public class GroupTest extends AbstractGroupTest {
assertAdminEvents.assertEvent(testRealmId, OperationType.DELETE, AdminEventPaths.groupPath(parentGroup.getId()), ResourceType.GROUP); assertAdminEvents.assertEvent(testRealmId, OperationType.DELETE, AdminEventPaths.groupPath(parentGroup.getId()), ResourceType.GROUP);
} }
@Test
public void testGroupsWithSpaces() {
testParentAndChildGroup("parent space", "child space");
}
@Test
public void testGroupsWithSlashes() {
testParentAndChildGroup("parent/slash", "child/slash");
}
/** /**
* Assert that when you create/move/update a group name, the response is not Http 409 Conflict and the message does not * Assert that when you create/move/update a group name, the response is not Http 409 Conflict and the message does not
* correspond to the returned user-friendly message in such cases * correspond to the returned user-friendly message in such cases