diff --git a/README.md b/README.md index 7721263..adbe06f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![GitHub](https://img.shields.io/badge/keycloak-21.1.1-orange) ![GitHub](https://img.shields.io/badge/registry-2.8.2-orange) ![GitHub](https://img.shields.io/github/license/alexanderwolz/keycloak-docker-group-role-mapper) -![GitHub](https://img.shields.io/badge/test_cases-604-informational) +![GitHub](https://img.shields.io/badge/test_cases-624-informational) ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/alexanderwolz/keycloak-docker-group-role-mapper) ![GitHub all releases](https://img.shields.io/github/downloads/alexanderwolz/keycloak-docker-group-role-mapper/total?color=informational) @@ -31,10 +31,10 @@ See also Keycloak [Dockerfile](https://github.com/alexanderwolz/keycloak-docker- ## ⚙️ Configuration This mapper supports following environment variables (either set on server or in docker container): -| Variable Name | Values | Description | -|---------------------------------|------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| ```REGISTRY_CATALOG_AUDIENCE``` | ```editor```, ```user``` | Will allow editors or users to access *registry:catalog:** scope. That would be of interest to users who want to access UI frontends.
No scope is set by default, so only admins are allowed to access registry scope. | -| ```REGISTRY_NAMESPACE_SCOPE``` | ```group```, ```domain```, ```sld```, ```username``` | If ```username``` is set, users will be granted full access to the namespace if it matches their username (lowercase check).
If ```domain``` is set, users are checked against their email domain and will be granted access to the repository (e.g. *company.com/image*) according to their roles.
If ```sld``` is set, users are checked against their email second level domain (sld) and will be granted access to the repository (e.g. *company/image*) according to their roles.
If ```group``` is set, users are checked for group membership and will be granted access to the repository according to their roles.
Namespace scope ```group``` is set by default or if value is empty or no value matches ```username```, ```domain```, ```sld``` or ```group```.
All values can be concatenated with ```,```. | +| Variable Name | Values | Description | +|---------------------------------|------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ```REGISTRY_CATALOG_AUDIENCE``` | ```editor```, ```user``` | Will allow editors or users to access *registry:catalog:** scope. That would be of interest to users who want to access UI frontends.
No scope is set by default, so only admins are allowed to access registry scope. | +| ```REGISTRY_NAMESPACE_SCOPE``` | ```group```, ```domain```, ```sld```, ```username``` | If ```group``` is set, users are checked for group membership and will be granted access to the repository according to their roles.
If ```domain``` is set, users are checked against their email domain and will be granted access to the repository (e.g. *company.com/image*) according to their roles.
If ```sld``` is set, users are checked against their email second level domain (sld) and will be granted access to the repository (e.g. *company/image*) according to their roles.
If ```username``` is set, users will be granted full access to the namespace if it matches their username (lowercase check).

Namespace scope ```group``` is set by default or if value is empty or no value matches ```group```, ```domain```, ```sld``` or ```username``` (all values can be concatenated with ```,```). | ## 🔒 Keycloak Setup diff --git a/build.gradle b/build.gradle index 1eb24f4..142e66c 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'de.alexanderwolz' -version = '1.3.0' +version = '1.3.1' repositories { mavenCentral() diff --git a/examples/keycloak-with-mapper/Dockerfile b/examples/keycloak-with-mapper/Dockerfile index 015779e..6d96abf 100644 --- a/examples/keycloak-with-mapper/Dockerfile +++ b/examples/keycloak-with-mapper/Dockerfile @@ -1,6 +1,6 @@ ARG ALPINE_VERSION="3.18.0" ARG KEYCLOAK_VERSION="21.1.1" -ARG MAPPER_VERSION="1.3.0" +ARG MAPPER_VERSION="1.3.1" ARG MAPPER_LIB="keycloak-docker-group-role-mapper-${MAPPER_VERSION}.jar" # stage 1: mapper build diff --git a/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/AbstractDockerScopeMapper.kt b/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/AbstractDockerScopeMapper.kt new file mode 100644 index 0000000..b1f9652 --- /dev/null +++ b/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/AbstractDockerScopeMapper.kt @@ -0,0 +1,187 @@ +package de.alexanderwolz.keycloak.docker.mapping + +import org.jboss.logging.Logger +import org.keycloak.models.AuthenticatedClientSessionModel +import org.keycloak.models.ClientModel +import org.keycloak.models.UserModel +import org.keycloak.protocol.docker.DockerAuthV2Protocol +import org.keycloak.protocol.docker.mapper.DockerAuthV2ProtocolMapper +import org.keycloak.representations.docker.DockerAccess +import org.keycloak.representations.docker.DockerResponseToken + +abstract class AbstractDockerScopeMapper( + private val id: String, + private val displayType: String, + private val helpText: String +) : DockerAuthV2ProtocolMapper() { + + companion object { + const val NAME_CATALOG = "catalog" + + const val ACTION_PULL = "pull" + const val ACTION_PUSH = "push" + const val ACTION_DELETE = "delete" + const val ACTION_ALL = "*" + val ACTION_ALL_SUBSTITUTE = listOf(ACTION_PULL, ACTION_PUSH, ACTION_DELETE) + + //see also https://docs.docker.com/registry/spec/auth/scope/ + const val ACCESS_TYPE_REGISTRY = "registry" + const val ACCESS_TYPE_REPOSITORY = "repository" + const val ACCESS_TYPE_REPOSITORY_PLUGIN = "repository(plugin)" + } + + internal val logger: Logger = Logger.getLogger(javaClass.simpleName) + + override fun getId(): String { + return id + } + + override fun getDisplayType(): String { + return displayType + } + + override fun getHelpText(): String { + return helpText + } + + internal fun getScopesFromSession(clientSession: AuthenticatedClientSessionModel): Collection { + val scopeString = clientSession.getNote(DockerAuthV2Protocol.SCOPE_PARAM) + if (logger.isDebugEnabled && (scopeString == null || scopeString.isEmpty())) { + logger.debug("Session does not contain a scope, ignoring further access check") + } + return scopeString?.split(" ") ?: emptySet() + } + + internal fun parseScopeIntoAccessItem(scope: String): DockerScopeAccess? { + return try { + val accessItem = DockerScopeAccess(scope) + if (logger.isTraceEnabled) { + logger.trace("Parsed scope '$scope' into: $accessItem") + } + accessItem + } catch (e: Exception) { + logger.warn("Could not parse scope '$scope' into access object", e) + null + } + } + + internal fun getClientRoleNames(user: UserModel, client: ClientModel): Collection { + return user.getClientRoleMappingsStream(client).map { it.name }.toList() + } + + internal fun getDomainFromEmail(email: String): String? { + val parts = email.split("@") + if (parts.size == 2) { + val domain = parts.last() + if (domain.isNotEmpty()) { + return domain + } + } + return null //no valid domain + } + + internal fun getSecondLevelDomainFromEmail(email: String): String? { + val domain = getDomainFromEmail(email) ?: return null + val parts = domain.split(".") + if (parts.size > 1) { + val sld = parts[parts.size - 2] + if (sld.isNotEmpty()) { + return sld + } + } + return null + } + + internal fun isUsernameRepository(namespace: String, username: String): Boolean { + return namespace == username.lowercase() + } + + internal fun isDomainRepository(namespace: String, email: String): Boolean { + return namespace == getDomainFromEmail(email) + } + + internal fun isSecondLevelDomainRepository(namespace: String, email: String): Boolean { + return namespace == getSecondLevelDomainFromEmail(email) + } + + internal fun hasAllPrivileges(actions: Collection, requestedActions: Collection): Boolean { + return isSubstituteWithActionAll(actions, requestedActions) || actions.containsAll(requestedActions) + } + + internal fun isSubstituteWithActionAll( + actions: Collection, requestedActions: Collection + ): Boolean { + return requestedActions.size == 1 && requestedActions.first() == ACTION_ALL && actions.containsAll( + ACTION_ALL_SUBSTITUTE + ) + } + + internal fun getNamespaceFromRepositoryName(repositoryName: String): String? { + val parts = repositoryName.split("/") + if (parts.size == 2) { + return parts[0].lowercase() + } + return null + } + + internal fun substituteRequestedActions(requestedActions: Collection): List { + // replaces '*' by pull, push and delete + return HashSet(requestedActions).also { actions -> + if (actions.contains(ACTION_ALL)) { + actions.remove(ACTION_ALL) + actions.addAll(ACTION_ALL_SUBSTITUTE) + } + }.toList() + } + + internal fun allowAll( + responseToken: DockerResponseToken, + accessItem: DockerScopeAccess, + user: UserModel, + reason: String + ): DockerResponseToken { + if (logger.isDebugEnabled) { + logger.debug("Granting access for user '${user.username}' on scope '${accessItem.scope}': $reason") + } + responseToken.accessItems.add(accessItem) + return responseToken + } + + internal fun allowWithActions( + responseToken: DockerResponseToken, + accessItem: DockerScopeAccess, + allowedActions: List, + user: UserModel, + reason: String + ): DockerResponseToken { + if (logger.isDebugEnabled) { + logger.debug("Granting access for user '${user.username}' on scope '${accessItem.scope}': $reason") + } + accessItem.actions = allowedActions + responseToken.accessItems.add(accessItem) + return responseToken + } + + internal fun deny( + responseToken: DockerResponseToken, + accessItem: DockerScopeAccess, + user: UserModel, + reason: String + ): DockerResponseToken { + logger.warn("Access denied for user '${user.username}' on scope '${accessItem.scope}': $reason") + return responseToken + } + + internal fun getEnvVariable(key: String): String? { + return try { + System.getenv()[key] + } catch (e: Exception) { + logger.error("Could not access System Environment", e) + null + } + } + + // cache plain scope into DockerAccess class + internal class DockerScopeAccess(val scope: String) : DockerAccess(scope) + +} \ No newline at end of file diff --git a/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt b/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt index 27057e6..aeea29c 100644 --- a/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt +++ b/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt @@ -1,11 +1,7 @@ package de.alexanderwolz.keycloak.docker.mapping -import de.alexanderwolz.keycloak.docker.utils.MapperUtils -import org.jboss.logging.Logger import org.keycloak.models.* -import org.keycloak.protocol.docker.DockerAuthV2Protocol import org.keycloak.protocol.docker.mapper.DockerAuthV2AttributeMapper -import org.keycloak.protocol.docker.mapper.DockerAuthV2ProtocolMapper import org.keycloak.representations.docker.DockerResponseToken // reference: https://www.baeldung.com/keycloak-custom-protocol-mapper @@ -13,25 +9,38 @@ import org.keycloak.representations.docker.DockerResponseToken // see also https://www.keycloak.org/docs-api/21.1.1/javadocs/org/keycloak/protocol/docker/mapper/DockerAuthV2ProtocolMapper.html // see also https://docs.docker.com/registry/spec/auth/token/ -class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), DockerAuthV2AttributeMapper { +class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper( + "docker-v2-allow-by-groups-and-roles-mapper", + "Allow by Groups and Roles", + "Maps Docker v2 scopes by user roles and groups" +), DockerAuthV2AttributeMapper { - private val logger = Logger.getLogger(javaClass.simpleName) + companion object { - internal var catalogAudience = getCatalogAudienceFromEnv() - internal var namespaceScope = getNamespaceScopeFromEnv() + internal const val GROUP_PREFIX = "registry-" - override fun getId(): String { - return PROVIDER_ID - } + //anybody with access to namespace repo is considered 'user' + private const val ROLE_USER = "user" + internal const val ROLE_EDITOR = "editor" + internal const val ROLE_ADMIN = "admin" - override fun getDisplayType(): String { - return DISPLAY_TYPE - } + //can be 'user' or 'editor' or both separated by ',' + internal const val KEY_REGISTRY_CATALOG_AUDIENCE = "REGISTRY_CATALOG_AUDIENCE" + internal const val AUDIENCE_USER = ROLE_USER + internal const val AUDIENCE_EDITOR = ROLE_EDITOR + internal const val AUDIENCE_ADMIN = ROLE_ADMIN - override fun getHelpText(): String { - return HELP_TEXT + internal const val KEY_REGISTRY_NAMESPACE_SCOPE = "REGISTRY_NAMESPACE_SCOPE" + internal const val NAMESPACE_SCOPE_USERNAME = "username" + internal const val NAMESPACE_SCOPE_GROUP = "group" + internal const val NAMESPACE_SCOPE_DOMAIN = "domain" + internal const val NAMESPACE_SCOPE_SLD = "sld" + internal val NAMESPACE_SCOPE_DEFAULT = setOf(NAMESPACE_SCOPE_GROUP) } + internal var catalogAudience = getCatalogAudienceFromEnv() + internal var namespaceScope = getNamespaceScopeFromEnv() + override fun appliesTo(responseToken: DockerResponseToken?): Boolean { return true } @@ -44,24 +53,28 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), clientSession: AuthenticatedClientSessionModel ): DockerResponseToken { - val scope = getScopeFromSession(clientSession) ?: return responseToken //no scope, no worries + val accessItems = getScopesFromSession(clientSession).map { scope -> + parseScopeIntoAccessItem(scope) ?: return responseToken //could not parse scope + } - val accessItem = parseScopeIntoAccessItem(scope) ?: return responseToken //could not parse scope + if (accessItems.isEmpty()) { + return responseToken // no action items given in scope + } - if (accessItem.actions.isEmpty()) { + if (accessItems.first().actions.isEmpty()) { return responseToken // no actions given in scope } - val clientRoleNames = MapperUtils.getClientRoleNames(userSession.user, clientSession.client) + val clientRoleNames = getClientRoleNames(userSession.user, clientSession.client) - return handleScopeAccess(responseToken, accessItem, userSession.user, clientRoleNames) + return handleScopeAccess(responseToken, accessItems.first(), clientRoleNames, userSession.user) } private fun handleScopeAccess( responseToken: DockerResponseToken, - accessItem: DockerAccess, + accessItem: DockerScopeAccess, + clientRoleNames: Collection, user: UserModel, - clientRoleNames: Collection ): DockerResponseToken { //admins @@ -90,7 +103,7 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), private fun handleRegistryAccess( responseToken: DockerResponseToken, clientRoleNames: Collection, - accessItem: DockerAccess, + accessItem: DockerScopeAccess, user: UserModel ): DockerResponseToken { if (accessItem.name == NAME_CATALOG) { @@ -111,88 +124,25 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), } private fun isAllowedToAccessRegistryCatalogScope(clientRoleNames: Collection): Boolean { - return catalogAudience == AUDIENCE_USER - || (catalogAudience == AUDIENCE_EDITOR && clientRoleNames.contains(ROLE_EDITOR)) - } - - private fun getScopeFromSession(clientSession: AuthenticatedClientSessionModel): String? { - val scope = clientSession.getNote(DockerAuthV2Protocol.SCOPE_PARAM) - if (logger.isDebugEnabled && scope == null) { - logger.debug("Session does not contain a scope, ignoring further access check") - } - return scope - } - - private fun parseScopeIntoAccessItem(scope: String): DockerAccess? { - return try { - val accessItem = DockerAccess(scope) - if (logger.isTraceEnabled) { - logger.trace("Parsed scope '$scope' into: $accessItem") - } - accessItem - } catch (e: Exception) { - logger.warn("Could not parse scope '$scope' into access object", e) - null - } - } - - - private fun allowAll( - responseToken: DockerResponseToken, - accessItem: DockerAccess, - user: UserModel, - reason: String - ): DockerResponseToken { - if (logger.isDebugEnabled) { - logger.debug("Granting access for user '${user.username}' on scope '${accessItem.scope}': $reason") - } - responseToken.accessItems.add(accessItem) - return responseToken - } - - private fun allowWithActions( - responseToken: DockerResponseToken, - accessItem: DockerAccess, - allowedActions: List, - user: UserModel, - reason: String - ): DockerResponseToken { - if (logger.isDebugEnabled) { - logger.debug("Granting access for user '${user.username}' on scope '${accessItem.scope}': $reason") - } - accessItem.actions = allowedActions - responseToken.accessItems.add(accessItem) - return responseToken - } - - private fun deny( - responseToken: DockerResponseToken, - accessItem: DockerAccess, - user: UserModel, - reason: String = "" - ): DockerResponseToken { - var message = "Access denied for user '${user.username}' on scope '${accessItem.scope}'" - if (reason.isNotEmpty()) { - message += ": $reason" - } - logger.warn(message) - return responseToken + return catalogAudience == AUDIENCE_USER || (catalogAudience == AUDIENCE_EDITOR && clientRoleNames.contains( + ROLE_EDITOR + )) } private fun handleRepositoryAccess( responseToken: DockerResponseToken, clientRoleNames: Collection, - accessItem: DockerAccess, + accessItem: DockerScopeAccess, user: UserModel ): DockerResponseToken { - val namespace = MapperUtils.getNamespaceFromRepositoryName(accessItem.name) ?: return deny( + val namespace = getNamespaceFromRepositoryName(accessItem.name) ?: return deny( responseToken, accessItem, user, "Role '$ROLE_ADMIN' needed to access default namespace repositories" ) if (namespaceScope.contains(NAMESPACE_SCOPE_USERNAME) && isUsernameRepository(namespace, user.username)) { //user's own repository, will have all access - val allowedActions = MapperUtils.substituteRequestedActions(accessItem.actions) + val allowedActions = substituteRequestedActions(accessItem.actions) return allowWithActions(responseToken, accessItem, allowedActions, user, "Accessing user's own namespace") } @@ -205,7 +155,7 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), } if (namespaceScope.contains(NAMESPACE_SCOPE_GROUP)) { - val namespacesFromGroups = MapperUtils.getUserNamespacesFromGroups(user).also { + val namespacesFromGroups = getUserNamespacesFromGroups(user).also { if (it.isEmpty()) { val reason = "User does not belong to any namespace - check groups" return deny(responseToken, accessItem, user, reason) @@ -222,22 +172,27 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), return deny(responseToken, accessItem, user, reason) } + internal fun getUserNamespacesFromGroups(user: UserModel): Collection { + return user.groupsStream.filter { it.name.startsWith(GROUP_PREFIX) } + .map { it.name.lowercase().replace(GROUP_PREFIX, "") }.toList() + } + private fun handleNamespaceRepositoryAccess( responseToken: DockerResponseToken, - accessItem: DockerAccess, + accessItem: DockerScopeAccess, clientRoleNames: Collection, user: UserModel ): DockerResponseToken { - val requestedActions = accessItem.actions - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) + val requestedActions = substituteRequestedActions(accessItem.actions) + val allowedActions = filterAllowedActions(requestedActions, clientRoleNames) if (allowedActions.isEmpty()) { - val reason = "Missing privileges for actions [${requestedActions.joinToString()}] - check client roles" + val reason = "Missing privileges for actions [${accessItem.actions.joinToString()}] - check client roles" return deny(responseToken, accessItem, user, reason) } - if (MapperUtils.hasAllPrivileges(allowedActions, requestedActions)) { + if (hasAllPrivileges(allowedActions, requestedActions)) { val reason = "User has privilege on all actions" return allowWithActions(responseToken, accessItem, allowedActions, user, reason) } @@ -246,30 +201,41 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), return allowWithActions(responseToken, accessItem, allowedActions, user, reason) } - private fun isUsernameRepository(namespace: String, username: String): Boolean { - return namespace == username.lowercase() - } + internal fun filterAllowedActions( + requestedActions: Collection, + clientRoleNames: Collection, + ): List { + val allowedActions = ArrayList() + val shallAddPrivilegedActions = clientRoleNames.contains(ROLE_EDITOR) || clientRoleNames.contains(ROLE_ADMIN) + requestedActions.forEach { action -> + + if (ACTION_PULL == action) { + //all users in namespace group can pull images (read only by default) + allowedActions.add(action) + } - private fun isDomainRepository(namespace: String, email: String): Boolean { - return namespace == MapperUtils.getDomainFromEmail(email) - } + if (ACTION_PUSH == action && shallAddPrivilegedActions) { + allowedActions.add(action) + } - private fun isSecondLevelDomainRepository(namespace: String, email: String): Boolean { - return namespace == MapperUtils.getSecondLevelDomainFromEmail(email) - } + if (ACTION_DELETE == action && shallAddPrivilegedActions) { + allowedActions.add(action) + } - private fun getEnv(key: String): String? { - return try { - System.getenv()[key] - } catch (e: Exception) { - logger.error("Could not access System Environment", e) - null + if (ACTION_ALL == action && shallAddPrivilegedActions) { + allowedActions.add(action) + } + + if (ACTION_ALL == action && !shallAddPrivilegedActions && !allowedActions.contains(ACTION_PULL)) { + //not substituted, add pull for unprivileged user (read only by default) + allowedActions.add(ACTION_PULL) + } } + return allowedActions } - private fun getCatalogAudienceFromEnv(): String { - return getEnv(KEY_REGISTRY_CATALOG_AUDIENCE)?.let { + return getEnvVariable(KEY_REGISTRY_CATALOG_AUDIENCE)?.let { val audienceString = it.lowercase() if (audienceString == AUDIENCE_USER) { return@let AUDIENCE_USER @@ -282,14 +248,10 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), } private fun getNamespaceScopeFromEnv(): Set { - return getEnv(KEY_REGISTRY_NAMESPACE_SCOPE)?.let { scopeString -> - val scopes = scopeString.split(",").map { it.lowercase() } - .filter { - it == NAMESPACE_SCOPE_GROUP - || it == NAMESPACE_SCOPE_USERNAME - || it == NAMESPACE_SCOPE_DOMAIN - || it == NAMESPACE_SCOPE_SLD - } + return getEnvVariable(KEY_REGISTRY_NAMESPACE_SCOPE)?.let { scopeString -> + val scopes = scopeString.split(",").map { it.lowercase() }.filter { + it == NAMESPACE_SCOPE_GROUP || it == NAMESPACE_SCOPE_USERNAME || it == NAMESPACE_SCOPE_DOMAIN || it == NAMESPACE_SCOPE_SLD + } if (scopes.isEmpty()) { logger.warn("Empty or unsupported config values for \$$KEY_REGISTRY_NAMESPACE_SCOPE: $scopeString") logger.warn("Resetting \$$KEY_REGISTRY_NAMESPACE_SCOPE to default: $NAMESPACE_SCOPE_DEFAULT") @@ -299,45 +261,4 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : DockerAuthV2ProtocolMapper(), } ?: NAMESPACE_SCOPE_DEFAULT } - companion object { - private const val PROVIDER_ID = "docker-v2-allow-by-groups-and-roles-mapper" - private const val DISPLAY_TYPE = "Allow by Groups and Roles" - private const val HELP_TEXT = "Maps Docker v2 scopes by user roles and groups" - - internal const val GROUP_PREFIX = "registry-" - - //anybody with access to namespace repo is considered 'user' - private const val ROLE_USER = "user" - internal const val ROLE_EDITOR = "editor" - internal const val ROLE_ADMIN = "admin" - - //can be 'user' or 'editor' or both separated by ',' - internal const val KEY_REGISTRY_CATALOG_AUDIENCE = "REGISTRY_CATALOG_AUDIENCE" - internal const val AUDIENCE_USER = ROLE_USER - internal const val AUDIENCE_EDITOR = ROLE_EDITOR - internal const val AUDIENCE_ADMIN = ROLE_ADMIN - - internal const val KEY_REGISTRY_NAMESPACE_SCOPE = "REGISTRY_NAMESPACE_SCOPE" - internal const val NAMESPACE_SCOPE_USERNAME = "username" - internal const val NAMESPACE_SCOPE_GROUP = "group" - internal const val NAMESPACE_SCOPE_DOMAIN = "domain" - internal const val NAMESPACE_SCOPE_SLD = "sld" - internal val NAMESPACE_SCOPE_DEFAULT = setOf(NAMESPACE_SCOPE_GROUP) - - //see also https://docs.docker.com/registry/spec/auth/scope/ - private const val ACCESS_TYPE_REGISTRY = "registry" - private const val ACCESS_TYPE_REPOSITORY = "repository" - private const val ACCESS_TYPE_REPOSITORY_PLUGIN = "repository(plugin)" - - private const val NAME_CATALOG = "catalog" - - internal const val ACTION_PULL = "pull" - internal const val ACTION_PUSH = "push" - internal const val ACTION_DELETE = "delete" - internal const val ACTION_ALL = "*" - internal val ACTION_ALL_SUBSTITUTE = listOf(ACTION_PULL, ACTION_PUSH, ACTION_DELETE) - } - - // cache plain scope into DockerAccess class - private class DockerAccess(val scope: String) : org.keycloak.representations.docker.DockerAccess(scope) } \ No newline at end of file diff --git a/src/main/kotlin/de/alexanderwolz/keycloak/docker/utils/MapperUtils.kt b/src/main/kotlin/de/alexanderwolz/keycloak/docker/utils/MapperUtils.kt deleted file mode 100644 index 945aa7a..0000000 --- a/src/main/kotlin/de/alexanderwolz/keycloak/docker/utils/MapperUtils.kt +++ /dev/null @@ -1,95 +0,0 @@ -package de.alexanderwolz.keycloak.docker.utils - -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_ALL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_ALL_SUBSTITUTE -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_DELETE -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PULL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PUSH -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.GROUP_PREFIX -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ROLE_EDITOR -import org.keycloak.models.ClientModel -import org.keycloak.models.UserModel - -class MapperUtils { - - companion object { - - fun getClientRoleNames(user: UserModel, client: ClientModel): Collection { - return user.getClientRoleMappingsStream(client).map { it.name }.toList() - } - - fun getUserNamespacesFromGroups(user: UserModel): Collection { - return user.groupsStream.filter { it.name.startsWith(GROUP_PREFIX) } - .map { it.name.lowercase().replace(GROUP_PREFIX, "") }.toList() - } - - fun hasAllPrivileges(actions: Collection, requestedActions: Collection): Boolean { - return isSubstituteWithAll(actions, requestedActions) || actions.containsAll(requestedActions) - } - - private fun isSubstituteWithAll( - actions: Collection, requestedActions: Collection - ): Boolean { - return requestedActions.size == 1 && requestedActions.first() == ACTION_ALL && actions.containsAll( - ACTION_ALL_SUBSTITUTE - ) - } - - fun getNamespaceFromRepositoryName(repositoryName: String): String? { - val parts = repositoryName.split("/") - if (parts.size == 2) { - return parts[0].lowercase() - } - return null - } - - fun getDomainFromEmail(email: String): String? { - val parts = email.split("@") - if (parts.size == 2) { - return parts[1].lowercase() - } - return null //no valid domain - } - - fun getSecondLevelDomainFromEmail(email: String): String? { - val domain = getDomainFromEmail(email) ?: return null - val parts = domain.split(".") - if (parts.size > 1) { - return parts[parts.size - 2] - } - return null - } - - // replaces '*' by pull, push and delete - fun substituteRequestedActions(requestedActions: Collection): List { - return HashSet(requestedActions).also { actions -> - if (actions.contains(ACTION_ALL)) { - actions.remove(ACTION_ALL) - actions.addAll(ACTION_ALL_SUBSTITUTE) - } - }.toList() - } - - fun filterAllowedActions( - requestedActions: Collection, - clientRoleNames: Collection, - ): List { - val allowedActions = ArrayList() - val shallAddPrivilegedActions = clientRoleNames.contains(ROLE_EDITOR) - substituteRequestedActions(requestedActions).forEach { action -> - if (ACTION_PUSH == action && shallAddPrivilegedActions) { - allowedActions.add(action) - } - if (ACTION_DELETE == action && shallAddPrivilegedActions) { - allowedActions.add(action) - } - if (ACTION_PULL == action) { - //all users in namespace group can pull images (read only by default) - allowedActions.add(action) - } - } - return allowedActions - } - - } -} \ No newline at end of file diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/AbstractDockerScopeMapperTest.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/AbstractDockerScopeMapperTest.kt new file mode 100644 index 0000000..4697f6b --- /dev/null +++ b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/AbstractDockerScopeMapperTest.kt @@ -0,0 +1,255 @@ +package de.alexanderwolz.keycloak.docker.mapping + +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_ALL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_ALL_SUBSTITUTE +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_DELETE +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PULL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PUSH +import org.junit.jupiter.api.Test +import org.keycloak.models.AuthenticatedClientSessionModel +import org.keycloak.models.ClientModel +import org.keycloak.models.RoleModel +import org.keycloak.models.UserModel +import org.keycloak.protocol.docker.DockerAuthV2Protocol +import org.keycloak.representations.docker.DockerResponseToken +import org.mockito.Mockito +import org.mockito.kotlin.given +import kotlin.test.* + +internal class AbstractDockerScopeMapperTest { + + private val mapper = object : AbstractDockerScopeMapper("id", "type", "text") {} + + @Test + fun testGetLogger() { + assertNotNull(mapper.logger) + } + + @Test + fun testGetScopeFromSession() { + val expectedScope = "registry:catalog:*" + val clientSession = Mockito.mock(AuthenticatedClientSessionModel::class.java) + given(clientSession.getNote(DockerAuthV2Protocol.SCOPE_PARAM)).willReturn(expectedScope) + val scopes = mapper.getScopesFromSession(clientSession) + assertEquals(1, scopes.size) + assertEquals(expectedScope, scopes.first()) + } + + @Test + fun testGetScopeFromSessionWithTwoScopes() { + val expectedScopes = setOf("registry:catalog:*", "repository:image:pull") + val scopeString = expectedScopes.joinToString(" ") + val clientSession = Mockito.mock(AuthenticatedClientSessionModel::class.java) + given(clientSession.getNote(DockerAuthV2Protocol.SCOPE_PARAM)).willReturn(scopeString) + val scopes = mapper.getScopesFromSession(clientSession) + assertEquals(2, scopes.size) + assertEquals(expectedScopes.sorted(), scopes.sorted()) + } + + @Test + fun testGetScopeFromSessionWithNull() { + val clientSession = Mockito.mock(AuthenticatedClientSessionModel::class.java) + given(clientSession.getNote(DockerAuthV2Protocol.SCOPE_PARAM)).willReturn(null) + val scopes = mapper.getScopesFromSession(clientSession) + assertEquals(0, scopes.size) + } + + @Test + fun testParseScopeIntoAccessItem() { + val accessItem = mapper.parseScopeIntoAccessItem("registry:catalog:*") + assertNotNull(accessItem) + assertEquals("registry", accessItem.type) + assertEquals("catalog", accessItem.name) + assertEquals(1, accessItem.actions.size) + assertEquals("*", accessItem.actions.first()) + } + + @Test + fun testParseScopeIntoAccessItemWithWrongSyntax() { + val accessItem = mapper.parseScopeIntoAccessItem("registry:catalog*") + assertNull(accessItem) + } + + @Test + fun testGetClientRoleNames() { + val client = Mockito.mock(ClientModel::class.java) + given(client.clientId).willReturn("client") + val user = Mockito.mock(UserModel::class.java) + + val expectedRoleNames = setOf(KeycloakGroupsAndRolesToDockerScopeMapper.ROLE_EDITOR, "otherRoleWithCamelCase") + given(user.getClientRoleMappingsStream(client)).willAnswer { + expectedRoleNames.map { roleName -> + Mockito.mock(RoleModel::class.java).also { + given(it.name).willReturn(roleName) + } + }.stream() + } + val roleNames = mapper.getClientRoleNames(user, client) + assertEquals(expectedRoleNames.sorted(), roleNames.sorted()) + } + + @Test + fun testGetDomainFromEmailW() { + val domain = mapper.getDomainFromEmail("john.doe@company.com") + assertEquals("company.com", domain) + } + + @Test + fun testGetDomainFromEmailWithWrongString() { + val domain = mapper.getDomainFromEmail("john.doe@") + assertNull(domain) + } + + @Test + fun testGetSecondLevelDomainFromEmail() { + val sld = mapper.getSecondLevelDomainFromEmail("john.doe@company.com") + assertEquals("company", sld) + } + + @Test + fun testGetSecondLevelDomainFromEmailWithSubdomain() { + val sld = mapper.getSecondLevelDomainFromEmail("john.doe@mail.company.com") + assertEquals("company", sld) + } + + @Test + fun testGetSecondLevelDomainFromEmailWithInvalidEmail() { + val sld = mapper.getSecondLevelDomainFromEmail("john.doe") + assertNull(sld) + } + + @Test + fun testIsUsernameRepository() { + assertTrue(mapper.isUsernameRepository("username", "username")) + } + + @Test + fun testIsUsernameRepositoryDifferentValues() { + assertFalse(mapper.isUsernameRepository("username", "johnny")) + } + + @Test + fun testIsUsernameRepositoryWithCamelCase() { + assertTrue(mapper.isUsernameRepository("username", "userName")) + } + + @Test + fun testIsDomainRepository() { + assertTrue(mapper.isDomainRepository("company.com", "johnny@company.com")) + } + + @Test + fun testIsDomainRepositoryWithOtherEmail() { + assertFalse(mapper.isDomainRepository("company.com", "johnny@company.net")) + } + + @Test + fun testIsSecondLevelDomainRepository() { + assertTrue(mapper.isSecondLevelDomainRepository("company", "johnny@company.com")) + } + + @Test + fun testIsSecondLevelDomainRepositoryWithOtherTld() { + assertTrue(mapper.isSecondLevelDomainRepository("company", "johnny@company.net")) + } + + @Test + fun testIsSecondLevelDomainRepositoryWithOtherEmail() { + assertFalse(mapper.isSecondLevelDomainRepository("company", "johnny@example.org")) + } + + @Test + fun testHasAllPrivileges() { + val requestedActions = setOf(ACTION_PULL, ACTION_DELETE) + val actions = setOf(ACTION_DELETE, ACTION_PULL) + val hasAllPrivileges = mapper.hasAllPrivileges(actions, requestedActions) + assertTrue(hasAllPrivileges) + } + + @Test + fun testHasAllPrivilegesWithSubstitute() { + val requestedActions = setOf(ACTION_ALL) + val actions = ACTION_ALL_SUBSTITUTE + val hasAllPrivileges = mapper.hasAllPrivileges(actions, requestedActions) + assertTrue(hasAllPrivileges) + } + + @Test + fun testHasAllPrivilegesFails() { + val requestedActions = setOf(ACTION_PUSH) + val actions = setOf(ACTION_PULL) + val hasAllPrivileges = mapper.hasAllPrivileges(actions, requestedActions) + assertFalse(hasAllPrivileges) + } + + + @Test + fun testIsSubstituteWithActionAll() { + assertTrue(mapper.isSubstituteWithActionAll(ACTION_ALL_SUBSTITUTE, setOf(ACTION_ALL))) + } + + @Test + fun testGetNamespaceFromRepositoryName() { + val namespace = mapper.getNamespaceFromRepositoryName("company/image") + assertEquals("company", namespace) + } + + @Test + fun testGetNamespaceFromRepositoryNameWithoutNamespace() { + val namespace = mapper.getNamespaceFromRepositoryName("image") + assertNull(namespace) + } + + @Test + fun testGetNamespaceFromRepositoryNameWithWrongSyntax() { + val namespace = mapper.getNamespaceFromRepositoryName("company/sub/image") + assertNull(namespace) + } + + @Test + fun testSubstituteRequestedActionsWithAll() { + val requestedActions = listOf(ACTION_ALL) + val expectedActions = setOf("pull", "push", "delete") + val actions = mapper.substituteRequestedActions(requestedActions) + assertEquals(expectedActions.sorted(), actions.sorted()) + } + + @Test + fun testSubstituteRequestedActionsWithAllAndPush() { + val requestedActions = listOf(ACTION_ALL, ACTION_PULL) + val expectedActions = setOf("pull", "push", "delete") + val actions = mapper.substituteRequestedActions(requestedActions) + assertEquals(expectedActions.sorted(), actions.sorted()) + } + + @Test + fun testAllowAll() { + val user = Mockito.mock(UserModel::class.java) + given(user.username).willReturn("Johnny") + val expectedAccessItem = AbstractDockerScopeMapper.DockerScopeAccess("registry:catalog:*") + val token = mapper.allowAll(DockerResponseToken(), expectedAccessItem, user, "Reason") + assertEquals(1, token.accessItems.size) + assertEquals(expectedAccessItem, token.accessItems.first()) + } + + @Test + fun testAllowWithActions() { + val user = Mockito.mock(UserModel::class.java) + given(user.username).willReturn("Johnny") + val expectedAccessItem = AbstractDockerScopeMapper.DockerScopeAccess("registry:catalog:*") + val expectedActions = listOf(ACTION_DELETE) + val token = mapper.allowWithActions(DockerResponseToken(), expectedAccessItem, expectedActions, user, "Reason") + assertEquals(1, token.accessItems.size) + assertEquals(expectedAccessItem, token.accessItems.first()) + assertEquals(expectedActions.sorted(), token.accessItems.first().actions.sorted()) + } + + @Test + fun testDeny() { + val user = Mockito.mock(UserModel::class.java) + given(user.username).willReturn("Johnny") + val expectedAccessItem = AbstractDockerScopeMapper.DockerScopeAccess("registry:catalog:*") + val token = mapper.deny(DockerResponseToken(), expectedAccessItem, user, "Reason") + assertEquals(0, token.accessItems.size) + } +} \ No newline at end of file diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapperTest.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapperTest.kt new file mode 100644 index 0000000..0a7b375 --- /dev/null +++ b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapperTest.kt @@ -0,0 +1,142 @@ +package de.alexanderwolz.keycloak.docker.mapping + +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_ALL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_DELETE +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PULL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PUSH +import org.junit.jupiter.api.Test +import org.keycloak.models.GroupModel +import org.keycloak.models.UserModel +import org.mockito.Mockito +import org.mockito.kotlin.given +import kotlin.test.assertEquals + +internal class KeycloakGroupsAndRolesToDockerScopeMapperTest { + + private val mapper = KeycloakGroupsAndRolesToDockerScopeMapper() + + @Test + fun testGetUserNamespacesFromGroups() { + val user = Mockito.mock(UserModel::class.java) + val groupNames = setOf("${KeycloakGroupsAndRolesToDockerScopeMapper.GROUP_PREFIX}company", "otherGroup") + given(user.groupsStream).willAnswer { + groupNames.map { groupName -> + Mockito.mock(GroupModel::class.java).also { + given(it.name).willReturn(groupName) + } + }.stream() + } + val expectedGroupNames = setOf("company") + val actualGroupNames = mapper.getUserNamespacesFromGroups(user) + assertEquals(expectedGroupNames.sorted(), actualGroupNames.sorted()) + } + + @Test + fun testFilterAllowedActionsAllForUser() { + val requestedActions = setOf(ACTION_ALL) + val clientRoleNames = emptySet() + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_PULL) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsPullForUser() { + val requestedActions = setOf(ACTION_PULL) + val clientRoleNames = emptySet() + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_PULL) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsPushForUser() { + val requestedActions = setOf(ACTION_PUSH) + val clientRoleNames = emptySet() + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = emptySet() + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsDeleteForUser() { + val requestedActions = setOf(ACTION_DELETE) + val clientRoleNames = emptySet() + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = emptySet() + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsPullPushForUser() { + val requestedActions = setOf(ACTION_PULL, ACTION_PUSH) + val clientRoleNames = emptySet() + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_PULL) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsPullPushDeleteForUser() { + val requestedActions = setOf(ACTION_PULL, ACTION_PUSH, ACTION_DELETE) + val clientRoleNames = emptySet() + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_PULL) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsAllForEditor() { + val requestedActions = setOf(ACTION_ALL) + val clientRoleNames = setOf(KeycloakGroupsAndRolesToDockerScopeMapper.ROLE_EDITOR) + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_ALL) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsPullForEditor() { + val requestedActions = setOf(ACTION_PULL) + val clientRoleNames = setOf(KeycloakGroupsAndRolesToDockerScopeMapper.ROLE_EDITOR) + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_PULL) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsPushForEditor() { + val requestedActions = setOf(ACTION_PUSH) + val clientRoleNames = setOf(KeycloakGroupsAndRolesToDockerScopeMapper.ROLE_EDITOR) + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_PUSH) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsDeleteForEditor() { + val requestedActions = setOf(ACTION_DELETE) + val clientRoleNames = setOf(KeycloakGroupsAndRolesToDockerScopeMapper.ROLE_EDITOR) + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_DELETE) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsPullPushForEditor() { + val requestedActions = setOf(ACTION_PULL, ACTION_PUSH) + val clientRoleNames = setOf(KeycloakGroupsAndRolesToDockerScopeMapper.ROLE_EDITOR) + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_PULL, ACTION_PUSH) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + + @Test + fun testFilterAllowedActionsPullPushDeleteForEditor() { + val requestedActions = setOf(ACTION_PULL, ACTION_PUSH, ACTION_DELETE) + val clientRoleNames = setOf(KeycloakGroupsAndRolesToDockerScopeMapper.ROLE_EDITOR) + val allowedActions = mapper.filterAllowedActions(requestedActions, clientRoleNames) + val expectedActions = setOf(ACTION_PULL, ACTION_PUSH, ACTION_DELETE) + assertEquals(expectedActions.sorted(), allowedActions.sorted()) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/MapperUtilsTest.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/MapperUtilsTest.kt deleted file mode 100644 index bd222ae..0000000 --- a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/MapperUtilsTest.kt +++ /dev/null @@ -1,239 +0,0 @@ -package de.alexanderwolz.keycloak.docker.mapping - -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_ALL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_ALL_SUBSTITUTE -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_DELETE -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PULL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PUSH -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.GROUP_PREFIX -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ROLE_EDITOR -import de.alexanderwolz.keycloak.docker.utils.MapperUtils -import org.junit.jupiter.api.Test -import org.keycloak.models.ClientModel -import org.keycloak.models.GroupModel -import org.keycloak.models.RoleModel -import org.keycloak.models.UserModel -import org.mockito.Mockito -import org.mockito.kotlin.given -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class MapperUtilsTest { - - @Test - internal fun test_get_client_role_names() { - val client = Mockito.mock(ClientModel::class.java) - given(client.clientId).willReturn("client") - val user = Mockito.mock(UserModel::class.java) - - val expectedRoleNames = setOf(ROLE_EDITOR, "otherRoleWithCamelCase") - given(user.getClientRoleMappingsStream(client)).willAnswer { - expectedRoleNames.map { roleName -> - Mockito.mock(RoleModel::class.java).also { - given(it.name).willReturn(roleName) - } - }.stream() - } - val roleNames = MapperUtils.getClientRoleNames(user, client) - assertEquals(expectedRoleNames.sorted(), roleNames.sorted()) - } - - @Test - internal fun test_get_user_namespaces_from_groups() { - val user = Mockito.mock(UserModel::class.java) - - val groupNames = setOf("${GROUP_PREFIX}company", "otherGroup") - given(user.groupsStream).willAnswer { - groupNames.map { groupName -> - Mockito.mock(GroupModel::class.java).also { - given(it.name).willReturn(groupName) - } - }.stream() - } - - val expectedGroupNames = setOf("company") - val actualGroupNames = MapperUtils.getUserNamespacesFromGroups(user) - assertEquals(expectedGroupNames.sorted(), actualGroupNames.sorted()) - } - - @Test - internal fun test_has_all_privileges_with_substitute() { - val requestedActions = setOf(ACTION_ALL) - val actions = ACTION_ALL_SUBSTITUTE - val hasAllPrivileges = MapperUtils.hasAllPrivileges(actions, requestedActions) - assertTrue(hasAllPrivileges) - } - - @Test - internal fun test_has_all_privileges() { - val requestedActions = setOf(ACTION_PULL, ACTION_DELETE) - val actions = setOf(ACTION_DELETE, ACTION_PULL) - val hasAllPrivileges = MapperUtils.hasAllPrivileges(actions, requestedActions) - assertTrue(hasAllPrivileges) - } - - @Test - internal fun test_has_not_all_privileges() { - val requestedActions = setOf(ACTION_PUSH) - val actions = setOf(ACTION_PULL) - val hasAllPrivileges = MapperUtils.hasAllPrivileges(actions, requestedActions) - assertFalse(hasAllPrivileges) - } - - @Test - internal fun test_get_namespace_from_repository_name_returns_namespace() { - val namespace = MapperUtils.getNamespaceFromRepositoryName("company/image") - assertEquals("company", namespace) - } - - @Test - internal fun test_get_namespace_from_repository_name_returns_null() { - val namespace = MapperUtils.getNamespaceFromRepositoryName("image") - assertNull(namespace) - } - - @Test - internal fun test_get_namespace_from_repository_name_returns_null_on_wrong_syntax() { - val namespace = MapperUtils.getNamespaceFromRepositoryName("some/other/string") - assertNull(namespace) - } - - @Test - internal fun test_is_email() { - val domain = MapperUtils.getDomainFromEmail("john.doe@company.com") - assertEquals("company.com", domain) - } - - @Test - internal fun test_is_not_email() { - val domain = MapperUtils.getDomainFromEmail("john.doe") - assertNull(domain) - } - - @Test - internal fun test_second_level_domain() { - val sld = MapperUtils.getSecondLevelDomainFromEmail("john.doe@company.com") - assertEquals("company", sld) - } - - @Test - internal fun test_second_level_domain_with_subdomain() { - val sld = MapperUtils.getSecondLevelDomainFromEmail("john.doe@mail.company.com") - assertEquals("company", sld) - } - - @Test - internal fun test_second_level_domain_with_invalid_email() { - val sld = MapperUtils.getSecondLevelDomainFromEmail("john.doe") - assertNull(sld) - } - - @Test - internal fun substitute_actions_with_scope_all() { - val requestedActions = listOf(ACTION_ALL) - val expectedActions = setOf("pull", "push", "delete") - val actions = MapperUtils.substituteRequestedActions(requestedActions) - assertEquals(expectedActions.sorted(), actions.sorted()) - } - - @Test - internal fun substitute_actions_with_scope_all_and_pull() { - val requestedActions = listOf(ACTION_ALL, ACTION_PULL) - val expectedActions = setOf("pull", "push", "delete") - val actions = MapperUtils.substituteRequestedActions(requestedActions) - assertEquals(expectedActions.sorted(), actions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_all_for_user() { - val requestedActions = setOf(ACTION_ALL) - val clientRoleNames = emptySet() - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = setOf(ACTION_PULL) - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_pull_for_user() { - val requestedActions = setOf(ACTION_PULL) - val clientRoleNames = emptySet() - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = setOf(ACTION_PULL) - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_push_for_user() { - val requestedActions = setOf(ACTION_PUSH) - val clientRoleNames = emptySet() - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = emptySet() - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_delete_for_user() { - val requestedActions = setOf(ACTION_DELETE) - val clientRoleNames = emptySet() - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = emptySet() - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_pull_push_for_user() { - val requestedActions = setOf(ACTION_PULL, ACTION_PUSH) - val clientRoleNames = emptySet() - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = setOf(ACTION_PULL) - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_all_for_editor() { - val requestedActions = setOf(ACTION_ALL) - val clientRoleNames = setOf(ROLE_EDITOR) - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = ACTION_ALL_SUBSTITUTE - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_pull_for_editor() { - val requestedActions = setOf(ACTION_PULL) - val clientRoleNames = setOf(ROLE_EDITOR) - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = setOf(ACTION_PULL) - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_push_for_editor() { - val requestedActions = setOf(ACTION_PUSH) - val clientRoleNames = setOf(ROLE_EDITOR) - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = setOf(ACTION_PUSH) - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_delete_for_editor() { - val requestedActions = setOf(ACTION_DELETE) - val clientRoleNames = setOf(ROLE_EDITOR) - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = setOf(ACTION_DELETE) - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - - @Test - internal fun test_filter_allowed_actions_with_action_pull_push_for_editor() { - val requestedActions = setOf(ACTION_PULL, ACTION_PUSH) - val clientRoleNames = setOf(ROLE_EDITOR) - val allowedActions = MapperUtils.filterAllowedActions(requestedActions, clientRoleNames) - val expectedActions = setOf(ACTION_PULL, ACTION_PUSH) - assertEquals(expectedActions.sorted(), allowedActions.sorted()) - } - -} \ No newline at end of file diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AdminTestSuite.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AdminTestSuite.kt index f351f94..2392ee3 100644 --- a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AdminTestSuite.kt +++ b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AdminTestSuite.kt @@ -1,9 +1,9 @@ package de.alexanderwolz.keycloak.docker.mapping.testsuite -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_ALL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_DELETE -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PULL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PUSH +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_ALL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_DELETE +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PULL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PUSH import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.AUDIENCE_EDITOR import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.AUDIENCE_USER import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.NAMESPACE_SCOPE_DOMAIN diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/EditorTestSuite.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/EditorTestSuite.kt index 34edc90..f7ccdfc 100644 --- a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/EditorTestSuite.kt +++ b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/EditorTestSuite.kt @@ -1,9 +1,9 @@ package de.alexanderwolz.keycloak.docker.mapping.testsuite -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_ALL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_DELETE -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PULL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PUSH +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_ALL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_DELETE +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PULL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PUSH import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.AUDIENCE_EDITOR import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.AUDIENCE_USER import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.NAMESPACE_SCOPE_DOMAIN diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/UserTestSuite.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/UserTestSuite.kt index bbf6eed..f60c4de 100644 --- a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/UserTestSuite.kt +++ b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/UserTestSuite.kt @@ -1,9 +1,9 @@ package de.alexanderwolz.keycloak.docker.mapping.testsuite -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_ALL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_DELETE -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PULL -import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.ACTION_PUSH +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_ALL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_DELETE +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PULL +import de.alexanderwolz.keycloak.docker.mapping.AbstractDockerScopeMapper.Companion.ACTION_PUSH import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.AUDIENCE_EDITOR import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.AUDIENCE_USER import de.alexanderwolz.keycloak.docker.mapping.KeycloakGroupsAndRolesToDockerScopeMapper.Companion.NAMESPACE_SCOPE_DOMAIN