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:
parent
67e4015f67
commit
6d74e6b289
15 changed files with 380 additions and 34 deletions
|
@ -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:
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue