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