diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClient.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClient.java index 82bad90..a5deb2b 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClient.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClient.java @@ -130,7 +130,22 @@ public interface IDMClient * the processor handling the loaded roles * @return the number of processed roles */ - int processRoles(int offset, int roleBatchSize, Consumer roleProcessor); + int processRealmRoles(int offset, int roleBatchSize, Consumer roleProcessor); + + /** + * Loads and processes a batch of realm roles from Keycloak using an externally specified processor. + * + * @param search + * a search term to filter roles + * @param offset + * the index of the first role to retrieve + * @param roleBatchSize + * the number of roles to load in one batch + * @param roleProcessor + * the processor handling the loaded roles + * @return the number of processed roles + */ + int processRealmRoles(String search, int offset, int roleBatchSize, Consumer roleProcessor); /** * Loads and processes a batch of client roles from Keycloak using an externally specified processor. @@ -145,5 +160,22 @@ public interface IDMClient * the processor handling the loaded roles * @return the number of processed roles */ - int processRoles(String clientId, int offset, int roleBatchSize, Consumer roleProcessor); + int processClientRoles(String clientId, int offset, int roleBatchSize, Consumer roleProcessor); + + /** + * Loads and processes a batch of client roles from Keycloak using an externally specified processor. + * + * @param clientId + * the {@link ClientRepresentation#getId() (technical) ID} of a client from which to process defined roles + * @param search + * a search term to filter roles + * @param offset + * the index of the first role to retrieve + * @param roleBatchSize + * the number of roles to load in one batch + * @param roleProcessor + * the processor handling the loaded roles + * @return the number of processed roles + */ + int processClientRoles(String clientId, String search, int offset, int roleBatchSize, Consumer roleProcessor); } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java index f8226af..82f982f 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java @@ -310,7 +310,7 @@ public class IDMClientImpl implements InitializingBean, IDMClient * {@inheritDoc} */ @Override - public int processRoles(final int offset, final int userBatchSize, final Consumer roleProcessor) + public int processRealmRoles(final int offset, final int userBatchSize, final Consumer roleProcessor) { ParameterCheck.mandatory("roleProcessor", roleProcessor); @@ -335,7 +335,35 @@ public class IDMClientImpl implements InitializingBean, IDMClient * {@inheritDoc} */ @Override - public int processRoles(final String clientId, final int offset, final int userBatchSize, + public int processRealmRoles(final String search, final int offset, final int userBatchSize, + final Consumer roleProcessor) + { + ParameterCheck.mandatory("roleProcessor", roleProcessor); + ParameterCheck.mandatoryString("search", search); + + if (offset < 0) + { + throw new IllegalArgumentException("offset must be a non-negative integer"); + } + if (userBatchSize <= 0) + { + throw new IllegalArgumentException("userBatchSize must be a positive integer"); + } + + final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/roles") + .queryParam("first", offset).queryParam("max", userBatchSize).queryParam("search", search) + .build(this.deployment.getRealm()); + + final int processedRoles = this.processEntityBatch(uri, roleProcessor, RoleRepresentation.class); + return processedRoles; + } + + /** + * + * {@inheritDoc} + */ + @Override + public int processClientRoles(final String clientId, final int offset, final int userBatchSize, final Consumer roleProcessor) { ParameterCheck.mandatoryString("clientId", clientId); @@ -358,6 +386,35 @@ public class IDMClientImpl implements InitializingBean, IDMClient return processedRoles; } + /** + * + * {@inheritDoc} + */ + @Override + public int processClientRoles(final String clientId, final String search, final int offset, final int userBatchSize, + final Consumer roleProcessor) + { + ParameterCheck.mandatoryString("clientId", clientId); + ParameterCheck.mandatoryString("search", search); + ParameterCheck.mandatory("roleProcessor", roleProcessor); + + if (offset < 0) + { + throw new IllegalArgumentException("offset must be a non-negative integer"); + } + if (userBatchSize <= 0) + { + throw new IllegalArgumentException("userBatchSize must be a positive integer"); + } + + final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()) + .path("/admin/realms/{realm}/clients/{clientId}/roles").queryParam("first", offset).queryParam("max", userBatchSize) + .queryParam("search", search).build(this.deployment.getRealm(), clientId); + + final int processedRoles = this.processEntityBatch(uri, roleProcessor, RoleRepresentation.class); + return processedRoles; + } + /** * Loads and processes a batch of generic entities from Keycloak. * diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/AggregateRoleNameMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/AggregateRoleNameMapper.java index e4ce1a1..a131e0e 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/AggregateRoleNameMapper.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/AggregateRoleNameMapper.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.Locale; import java.util.Optional; +import org.alfresco.util.ParameterCheck; import org.alfresco.util.PropertyCheck; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,6 +74,7 @@ public class AggregateRoleNameMapper implements InitializingBean, RoleNameMapper @Override public Optional mapRoleName(final String roleName) { + ParameterCheck.mandatoryString("roleName", roleName); LOGGER.debug("Mapping role {} using granular mappers {}", roleName, this.granularMappers); Optional mappedName = Optional.empty(); for (final RoleNameMapper mapper : this.granularMappers) @@ -87,4 +89,24 @@ public class AggregateRoleNameMapper implements InitializingBean, RoleNameMapper return mappedName; } + /** + * {@inheritDoc} + */ + @Override + public Optional mapAuthorityName(final String authorityName) + { + ParameterCheck.mandatoryString("authorityName", authorityName); + LOGGER.debug("Mapping authority name {} using granular mappers {}", authorityName, this.granularMappers); + Optional mappedName = Optional.empty(); + for (final RoleNameMapper mapper : this.granularMappers) + { + mappedName = mapper.mapAuthorityName(authorityName).map(name -> this.upperCaseRoles ? name.toLowerCase(Locale.ENGLISH) : name); + if (mappedName.isPresent()) + { + LOGGER.debug("Mapped authority name {} to {} using granular mapper {}", authorityName, mappedName, mapper); + break; + } + } + return mappedName; + } } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/NoOpRoleServiceImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/NoOpRoleServiceImpl.java index e355dfe..36dd878 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/NoOpRoleServiceImpl.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/NoOpRoleServiceImpl.java @@ -17,6 +17,7 @@ package de.acosix.alfresco.keycloak.repo.roles; import java.util.Collections; import java.util.List; +import java.util.Optional; /** * This no-op implementation class of a role service may be used as a default implemenation in a subsystem proxy to avoid failing if no @@ -87,4 +88,33 @@ public class NoOpRoleServiceImpl implements RoleService return Collections.emptyList(); } + /** + * + * {@inheritDoc} + */ + @Override + public boolean isMappedFromKeycloak(final String authorityName) + { + return false; + } + + /** + * + * {@inheritDoc} + */ + @Override + public Optional getRoleName(final String authorityName) + { + return Optional.empty(); + } + + /** + * + * {@inheritDoc} + */ + @Override + public Optional getClientFromRole(final String authorityName) + { + return Optional.empty(); + } } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/PatternRoleNameMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/PatternRoleNameMapper.java index 0077443..27a9673 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/PatternRoleNameMapper.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/PatternRoleNameMapper.java @@ -18,6 +18,7 @@ package de.acosix.alfresco.keycloak.repo.roles; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.regex.Pattern; import org.alfresco.util.ParameterCheck; import org.slf4j.Logger; @@ -35,6 +36,8 @@ public class PatternRoleNameMapper implements RoleNameMapper protected Map patternMappings; + protected Map patternInverseMappings; + protected boolean upperCaseRoles; /** @@ -46,6 +49,15 @@ public class PatternRoleNameMapper implements RoleNameMapper this.patternMappings = patternMappings; } + /** + * @param patternInverseMappings + * the patternInverseMappings to set + */ + public void setPatternInverseMappings(final Map patternInverseMappings) + { + this.patternInverseMappings = patternInverseMappings; + } + /** * @param upperCaseRoles * the upperCaseRoles to set @@ -75,7 +87,6 @@ public class PatternRoleNameMapper implements RoleNameMapper LOGGER.debug("Mapped role {} to {}", roleName, mappedName); return mappedName; }).map(name -> this.upperCaseRoles ? name.toUpperCase(Locale.ENGLISH) : name); - ; if (!result.isPresent()) { @@ -86,4 +97,32 @@ public class PatternRoleNameMapper implements RoleNameMapper return result; } + /** + * {@inheritDoc} + */ + @Override + public Optional mapAuthorityName(final String authorityName) + { + ParameterCheck.mandatoryString("authorityName", authorityName); + + Optional result = Optional.empty(); + + if (this.patternInverseMappings != null) + { + final Optional matchingPattern = this.patternMappings.keySet().stream().filter(pattern -> Pattern + .compile(pattern, this.upperCaseRoles ? Pattern.CASE_INSENSITIVE : 0).matcher(authorityName).matches()).findFirst(); + + result = matchingPattern.map(pattern -> { + final String replacement = this.patternMappings.get(pattern); + LOGGER.debug("Authority name {} matches inverse mapping pattern {} - applying replacement pattern {}", authorityName, + pattern, replacement); + final String mappedName = Pattern.compile(pattern, this.upperCaseRoles ? Pattern.CASE_INSENSITIVE : 0) + .matcher(authorityName).replaceAll(replacement); + LOGGER.debug("Mapped authority name {} to {}", authorityName, mappedName); + return mappedName; + }).map(name -> this.upperCaseRoles ? name.toLowerCase(Locale.ENGLISH) : name); + } + + return result; + } } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/PrefixAttachingRoleNameMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/PrefixAttachingRoleNameMapper.java index e05748d..ad4fe7d 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/PrefixAttachingRoleNameMapper.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/PrefixAttachingRoleNameMapper.java @@ -70,10 +70,30 @@ public class PrefixAttachingRoleNameMapper implements RoleNameMapper final String mappedName = this.prefix + roleName; LOGGER.debug("Mapped role {} to {} using prefix attachment", roleName, mappedName); result = Optional.of(mappedName).map(name -> this.upperCaseRoles ? name.toUpperCase(Locale.ENGLISH) : name); - ; } return result; } + @Override + public Optional mapAuthorityName(final String authorityName) + { + ParameterCheck.mandatoryString("authorityName", authorityName); + + Optional result = Optional.empty(); + + if (this.prefix != null) + { + final String ciAuthorityName = authorityName.toLowerCase(Locale.ENGLISH); + final String ciPrefix = this.prefix.toLowerCase(Locale.ENGLISH); + if (ciAuthorityName.startsWith(ciPrefix)) + { + final String mappedName = authorityName.substring(this.prefix.length()); + LOGGER.debug("Mapped authority name {} to {} using prefix removal", authorityName, mappedName); + result = Optional.of(mappedName); + } + } + + return result; + } } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleNameMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleNameMapper.java index 1ce6779..04cb7cd 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleNameMapper.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleNameMapper.java @@ -37,4 +37,14 @@ public interface RoleNameMapper * operation */ Optional mapRoleName(String roleName); + + /** + * Maps the name of an Alfresco authority to the name of a Keycloak role. This operation should act like the inverse of the + * {@link #mapRoleName(String) original inbound mapping}. + * + * @param authorityName + * the Alfresco authority name + * @return the name of the Keycloak role + */ + Optional mapAuthorityName(String authorityName); } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleService.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleService.java index a7547b1..cadaaab 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleService.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleService.java @@ -16,6 +16,7 @@ package de.acosix.alfresco.keycloak.repo.roles; import java.util.List; +import java.util.Optional; /** * Instances of this interface allow for lookup / retrieval of Keycloak roles. @@ -88,4 +89,31 @@ public interface RoleService */ List findRoles(String resourceName, String shortNameFilter); + /** + * Checks whether the specified authority name is a role mapped from Keycloak. + * + * @param authorityName + * the Alfresco authority name to check + * @return {@code true} if the authority name matches any expected patterns of roles mapped from Keycloak, {@code false} otherwise + */ + boolean isMappedFromKeycloak(String authorityName); + + /** + * Retrieves the name of the original Keycloak role from which the specified authority name was mapped from within Keycloak. + * + * @param authorityName + * the Alfresco authority name to process + * @return the name of the Keycloak role from which the authority name was mapped unless the role was not mapped from Keycloak + */ + Optional getRoleName(String authorityName); + + /** + * Retrieves the name of the client the specified authority name was mapped from within Keycloak. + * + * @param authorityName + * the Alfresco authority name to process + * @return the name of the client which defines the role unless the role is either not mapped from Keycloak or mapped from the realm + * scope + */ + Optional getClientFromRole(String authorityName); } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleServiceImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleServiceImpl.java index 6ec074d..786193d 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleServiceImpl.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/RoleServiceImpl.java @@ -18,11 +18,16 @@ package de.acosix.alfresco.keycloak.repo.roles; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BinaryOperator; import java.util.function.Consumer; +import java.util.function.UnaryOperator; import java.util.regex.Pattern; import org.alfresco.service.cmr.security.AuthorityType; @@ -36,7 +41,7 @@ import org.springframework.beans.factory.InitializingBean; import de.acosix.alfresco.keycloak.repo.client.IDMClient; -public class RoleServiceImpl implements InitializingBean, RoleService +public class RoleServiceImpl implements RoleService, InitializingBean { private static final Logger LOGGER = LoggerFactory.getLogger(RoleServiceImpl.class); @@ -280,6 +285,134 @@ public class RoleServiceImpl implements InitializingBean, RoleService return this.doFindRoles(resourceName, shortNameFilter); } + /** + * + * {@inheritDoc} + */ + @Override + public boolean isMappedFromKeycloak(final String authorityName) + { + ParameterCheck.mandatoryString("authorityName", authorityName); + + Optional role = Optional.empty(); + + if (this.processRealmRoles) + { + role = this.realmRoleNameMapper.mapAuthorityName(authorityName); + } + if (this.processResourceRoles) + { + final Iterator resourceIterator = this.resourceRoleNameMapper.keySet().iterator(); + while (!role.isPresent() && resourceIterator.hasNext()) + { + final RoleNameMapper roleNameMapper = this.resourceRoleNameMapper.get(resourceIterator.next()); + role = roleNameMapper.mapAuthorityName(authorityName); + } + } + return role.isPresent(); + } + + /** + * + * {@inheritDoc} + */ + @Override + public Optional getRoleName(final String authorityName) + { + ParameterCheck.mandatoryString("authorityName", authorityName); + + Optional role = Optional.empty(); + + if (this.processRealmRoles) + { + final UnaryOperator realmRoleResolver = rn -> { + final Set matchingRoles = new HashSet<>(); + this.idmClient.processRealmRoles(rn, 0, Integer.MAX_VALUE, roleResult -> { + if (roleResult.getName().equalsIgnoreCase(rn)) + { + matchingRoles.add(roleResult.getName()); + } + }); + + String matchingRole = null; + if (matchingRoles.size() == 1) + { + matchingRole = matchingRoles.iterator().next(); + } + else + { + LOGGER.warn("Failed to match apparent Keycloak realm role {} to unique role via admin API", rn); + } + return matchingRole; + }; + role = this.realmRoleNameMapper.mapAuthorityName(authorityName).map(realmRoleResolver); + } + if (this.processResourceRoles) + { + final BinaryOperator clientRoleResolver = (client, rn) -> { + final Set matchingRoles = new HashSet<>(); + this.idmClient.processClientRoles(client, rn, 0, Integer.MAX_VALUE, roleResult -> { + if (roleResult.getName().equalsIgnoreCase(rn)) + { + matchingRoles.add(roleResult.getName()); + } + }); + + String matchingRole = null; + if (matchingRoles.size() == 1) + { + matchingRole = matchingRoles.iterator().next(); + } + else + { + LOGGER.warn("Failed to match apparent Keycloak role {} from client {} to unique role via admin API", rn, client); + } + return matchingRole; + }; + final Iterator resourceIterator = this.resourceRoleNameMapper.keySet().iterator(); + while (!role.isPresent() && resourceIterator.hasNext()) + { + final String resource = resourceIterator.next(); + final RoleNameMapper roleNameMapper = this.resourceRoleNameMapper.get(resource); + role = roleNameMapper.mapAuthorityName(authorityName).map(rn -> clientRoleResolver.apply(resource, rn)); + } + } + return role; + } + + /** + * + * {@inheritDoc} + */ + @Override + public Optional getClientFromRole(final String authorityName) + { + ParameterCheck.mandatoryString("authorityName", authorityName); + Optional client = Optional.empty(); + Optional role = Optional.empty(); + + if (this.processRealmRoles) + { + role = this.realmRoleNameMapper.mapAuthorityName(authorityName); + } + if (!role.isPresent() && this.processResourceRoles) + { + final Iterator resourceIterator = this.resourceRoleNameMapper.keySet().iterator(); + while (!role.isPresent() && resourceIterator.hasNext()) + { + final String resource = resourceIterator.next(); + final RoleNameMapper roleNameMapper = this.resourceRoleNameMapper.get(resource); + role = roleNameMapper.mapAuthorityName(authorityName); + if (role.isPresent()) + { + client = Optional.of(resource); + } + } + } + + return client; + } + protected List doFindRoles(final String shortNameFilter, final boolean realmOnly) { final List roles; @@ -480,11 +613,11 @@ public class RoleServiceImpl implements InitializingBean, RoleService if (clientId != null) { - this.idmClient.processRoles(clientId, 0, Integer.MAX_VALUE, processor); + this.idmClient.processClientRoles(clientId, 0, Integer.MAX_VALUE, processor); } else { - this.idmClient.processRoles(0, Integer.MAX_VALUE, processor); + this.idmClient.processRealmRoles(0, Integer.MAX_VALUE, processor); } return results; @@ -508,7 +641,6 @@ public class RoleServiceImpl implements InitializingBean, RoleService { LOGGER.debug("Excluding role {} as it maps to group authority name {}", role.getName(), r); } - ; return allowed; }).map(r -> new Role(r, role.getName(), role.getDescription())); diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/ScriptRoleService.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/ScriptRoleService.java index e9d0bc6..c975e42 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/ScriptRoleService.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/ScriptRoleService.java @@ -145,6 +145,31 @@ public class ScriptRoleService extends BaseScopableProcessorExtension implements return roleArray; } + /** + * Checks whether the specified authority name is a role mapped from Keycloak. + * + * @param authorityName + * the Alfresco authority name to check + * @return {@code true} if the authority name matches any expected patterns of roles mapped from Keycloak, {@code false} otherwise + */ + public boolean isMappedFromKeycloak(final String authorityName) + { + return this.roleService.isMappedFromKeycloak(authorityName); + } + + /** + * Retrieves the name of the client the specified authority name was mapped from within Keycloak. + * + * @param authorityName + * the Alfresco authority name to process + * @return the name of the client which defines the role unless the role is either not mapped from Keycloak or mapped from the realm + * scope + */ + public String getClientFromRole(final String authorityName) + { + return this.roleService.getClientFromRole(authorityName).orElse(null); + } + protected Scriptable makeRoleArray(final List roles) { final Scriptable sitesArray = Context.getCurrentContext().newArray(this.getScope(), roles.toArray(new Object[0])); diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/StaticRoleNameMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/StaticRoleNameMapper.java index 1abd1e3..a05af4e 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/StaticRoleNameMapper.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/roles/StaticRoleNameMapper.java @@ -17,6 +17,7 @@ package de.acosix.alfresco.keycloak.repo.roles; import java.util.Locale; import java.util.Map; +import java.util.Map.Entry; import java.util.Optional; import org.alfresco.util.ParameterCheck; @@ -82,4 +83,34 @@ public class StaticRoleNameMapper implements RoleNameMapper return result; } + /** + * {@inheritDoc} + */ + @Override + public Optional mapAuthorityName(final String authorityName) + { + ParameterCheck.mandatoryString("authorityName", authorityName); + + Optional result = Optional.empty(); + + if (this.nameMappings != null) + { + for (final Entry entry : this.nameMappings.entrySet()) + { + if (entry.getValue().equals(authorityName) || (this.upperCaseRoles && entry.getValue().equalsIgnoreCase(authorityName))) + { + final String mappedName = entry.getKey(); + LOGGER.debug("Mapped authority name {} to {} using static mapping", authorityName, mappedName); + result = Optional.of(mappedName); + break; + } + } + if (!result.isPresent()) + { + LOGGER.debug("No static mapping applies to authority name {}", authorityName); + } + } + + return result; + } }