From 60387d5810e7f1a431221cfa364b0793588d2ecb Mon Sep 17 00:00:00 2001 From: Alexander Wolz Date: Sun, 25 Feb 2024 09:06:09 +0100 Subject: [PATCH 1/2] added support for subgroups (level 1) and custom group prefix name --- README.md | 5 +- build.gradle | 2 +- ...ycloakGroupsAndRolesToDockerScopeMapper.kt | 18 ++++- ...akGroupsAndRolesToDockerScopeMapperTest.kt | 78 ++++++++++++++++++- .../testsuite/AbstractScopeMapperTestSuite.kt | 10 +-- 5 files changed, 97 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ddc05e8..ffa7b9e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Docker v2 - Groups and Role Mapper for Keycloak 23.x ![GitHub release (latest by date)](https://img.shields.io/github/v/release/alexanderwolz/keycloak-docker-group-role-mapper) -![GitHub](https://img.shields.io/badge/keycloak-23.0.1-orange) +![GitHub](https://img.shields.io/badge/keycloak-23.0.7-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-626-informational) +![GitHub](https://img.shields.io/badge/test_cases-632-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) @@ -35,6 +35,7 @@ This mapper supports following environment variables (either set on server or in |---------------------------------|------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ```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 ```,```). | +| ```GROUP_PREFIX``` | any String | Custom group prefix. Will default to ```registry-```. Comparisons will be checked with lowercase String representation. | ## 🔒 Keycloak Setup diff --git a/build.gradle b/build.gradle index 79aeb6f..4ef8f8e 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'de.alexanderwolz' -version = '1.5.0' +version = '1.5.1' repositories { mavenCentral() 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 aeea29c..e476acb 100644 --- a/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt +++ b/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt @@ -3,6 +3,7 @@ package de.alexanderwolz.keycloak.docker.mapping import org.keycloak.models.* import org.keycloak.protocol.docker.mapper.DockerAuthV2AttributeMapper import org.keycloak.representations.docker.DockerResponseToken +import java.util.stream.Stream // reference: https://www.baeldung.com/keycloak-custom-protocol-mapper // see also https://www.keycloak.org/docs-api/21.1.1/javadocs/org/keycloak/protocol/ProtocolMapper.html @@ -17,7 +18,8 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper( companion object { - internal const val GROUP_PREFIX = "registry-" + internal const val KEY_GROUP_PREFIX = "GROUP_PREFIX" + internal const val DEFAULT_GROUP_PREFIX = "registry-" //anybody with access to namespace repo is considered 'user' private const val ROLE_USER = "user" @@ -38,6 +40,7 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper( internal val NAMESPACE_SCOPE_DEFAULT = setOf(NAMESPACE_SCOPE_GROUP) } + internal var groupPrefix = getGroupPrefixFromEnv() internal var catalogAudience = getCatalogAudienceFromEnv() internal var namespaceScope = getNamespaceScopeFromEnv() @@ -164,7 +167,7 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper( if (namespacesFromGroups.contains(namespace)) { return handleNamespaceRepositoryAccess(responseToken, accessItem, clientRoleNames, user) } - val reason = "Missing namespace group '$GROUP_PREFIX$namespace' - check groups" + val reason = "Missing namespace group '$groupPrefix$namespace' - check groups" return deny(responseToken, accessItem, user, reason) } @@ -173,8 +176,11 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper( } internal fun getUserNamespacesFromGroups(user: UserModel): Collection { - return user.groupsStream.filter { it.name.startsWith(GROUP_PREFIX) } - .map { it.name.lowercase().replace(GROUP_PREFIX, "") }.toList() + val allSubGroups = user.groupsStream.flatMap { it.subGroupsStream } + val allGroups = Stream.concat(user.groupsStream, allSubGroups) + val filteredGroups = allGroups.filter { it.name.lowercase().startsWith(groupPrefix) } + val namespaces = filteredGroups.map { it.name.lowercase().replace(groupPrefix, "") } + return namespaces.toList() } private fun handleNamespaceRepositoryAccess( @@ -247,6 +253,10 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper( } ?: AUDIENCE_ADMIN } + private fun getGroupPrefixFromEnv():String{ + return getEnvVariable(KEY_GROUP_PREFIX) ?: DEFAULT_GROUP_PREFIX + } + private fun getNamespaceScopeFromEnv(): Set { return getEnvVariable(KEY_REGISTRY_NAMESPACE_SCOPE)?.let { scopeString -> val scopes = scopeString.split(",").map { it.lowercase() }.filter { diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapperTest.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapperTest.kt index 0a7b375..c0fd1fe 100644 --- a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapperTest.kt +++ b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapperTest.kt @@ -9,6 +9,7 @@ import org.keycloak.models.GroupModel import org.keycloak.models.UserModel import org.mockito.Mockito import org.mockito.kotlin.given +import java.util.stream.Stream import kotlin.test.assertEquals internal class KeycloakGroupsAndRolesToDockerScopeMapperTest { @@ -16,13 +17,82 @@ internal class KeycloakGroupsAndRolesToDockerScopeMapperTest { private val mapper = KeycloakGroupsAndRolesToDockerScopeMapper() @Test - fun testGetUserNamespacesFromGroups() { + fun testGetUserNamespacesFromEmptyGroups() { val user = Mockito.mock(UserModel::class.java) - val groupNames = setOf("${KeycloakGroupsAndRolesToDockerScopeMapper.GROUP_PREFIX}company", "otherGroup") + given(user.groupsStream).willAnswer { + Stream.empty() + } + val actualGroupNames = mapper.getUserNamespacesFromGroups(user) + assertEquals(0, actualGroupNames.size) + } + + @Test + fun testGetUserNamespacesFromGroupsAndSubgroups() { + val user = Mockito.mock(UserModel::class.java) + val groupWithPrefixSubgroup = "parentWithPrefixSubgroup" + val groupNames = setOf("${mapper.groupPrefix}company", "otherGroup", groupWithPrefixSubgroup) + val subgroupNames = setOf("${mapper.groupPrefix}subgroup", "otherSubgroup") + given(user.groupsStream).willAnswer { + groupNames.map { groupName -> + Mockito.mock(GroupModel::class.java).also { parentGroup -> + given(parentGroup.name).willReturn(groupName) + if (groupName == groupWithPrefixSubgroup) { + given(parentGroup.subGroupsStream).willAnswer { + subgroupNames.map { subgroupName -> + Mockito.mock(GroupModel::class.java).also { childGroup -> + given(childGroup.name).willReturn(subgroupName) + } + }.stream() + } + } + } + }.stream() + } + val expectedGroupNames = setOf("company", "subgroup") + val actualGroupNames = mapper.getUserNamespacesFromGroups(user) + assertEquals(expectedGroupNames.sorted(), actualGroupNames.sorted()) + } + + @Test + fun testGetUserNamespacesFromSubgroupsOnly() { + val user = Mockito.mock(UserModel::class.java) + val groupWithPrefixSubgroup = "parentWithPrefixSubgroup" + val groupNames = setOf("otherGroup1", "otherGroup2", groupWithPrefixSubgroup) + val subgroupNames = setOf("${mapper.groupPrefix}subgroup1", "${mapper.groupPrefix}subgroup2", "otherSubgroup") + given(user.groupsStream).willAnswer { + groupNames.map { groupName -> + Mockito.mock(GroupModel::class.java).also { parentGroup -> + given(parentGroup.name).willReturn(groupName) + if (groupName == groupWithPrefixSubgroup) { + given(parentGroup.subGroupsStream).willAnswer { + subgroupNames.map { subgroupName -> + Mockito.mock(GroupModel::class.java).also { childGroup -> + given(childGroup.name).willReturn(subgroupName) + } + }.stream() + } + } + } + }.stream() + } + val expectedGroupNames = setOf("subgroup1", "subgroup2") + val actualGroupNames = mapper.getUserNamespacesFromGroups(user) + assertEquals(expectedGroupNames.sorted(), actualGroupNames.sorted()) + } + + @Test + fun testGetUserNamespacesFromCustomGroupPrefix() { + val customPrefix = "_MY-GROUP-PREFIX_".lowercase() + mapper.groupPrefix = customPrefix + assertEquals(mapper.groupPrefix, customPrefix) + + val user = Mockito.mock(UserModel::class.java) + val groupNames = setOf("${customPrefix}company", "otherGroup") + given(user.groupsStream).willAnswer { groupNames.map { groupName -> - Mockito.mock(GroupModel::class.java).also { - given(it.name).willReturn(groupName) + Mockito.mock(GroupModel::class.java).also { parentGroup -> + given(parentGroup.name).willReturn(groupName) } }.stream() } diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AbstractScopeMapperTestSuite.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AbstractScopeMapperTestSuite.kt index 5d6e2fb..254c022 100644 --- a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AbstractScopeMapperTestSuite.kt +++ b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AbstractScopeMapperTestSuite.kt @@ -66,8 +66,8 @@ abstract class AbstractScopeMapperTestSuite { const val SCOPE_REPO_PLUGIN_NAMESPACE_DOMAIN_DELETE = "repository(plugin):$NAMESPACE_DOMAIN/$IMAGE:delete" const val SCOPE_REPO_PLUGIN_NAMESPACE_DOMAIN_PULL_PUSH = "repository(plugin):$NAMESPACE_DOMAIN/$IMAGE:pull,push" - const val GROUP_NAMESPACE = "${KeycloakGroupsAndRolesToDockerScopeMapper.GROUP_PREFIX}$NAMESPACE" - const val GROUP_NAMESPACE_OTHER = "${KeycloakGroupsAndRolesToDockerScopeMapper.GROUP_PREFIX}otherNamespace" + const val GROUP_NAMESPACE = "${KeycloakGroupsAndRolesToDockerScopeMapper.DEFAULT_GROUP_PREFIX}$NAMESPACE" + const val GROUP_NAMESPACE_OTHER = "${KeycloakGroupsAndRolesToDockerScopeMapper.DEFAULT_GROUP_PREFIX}otherNamespace" } private val logger = Logger.getLogger(javaClass) @@ -86,16 +86,16 @@ abstract class AbstractScopeMapperTestSuite { private lateinit var clientSession: AuthenticatedClientSessionModel @BeforeTest - private fun logCurrentTestMethodName(info: TestInfo) { + fun logCurrentTestMethodName(info: TestInfo) { logger.info("** TEST: ${info.displayName.split("$").first()}") } @AfterTest - private fun logEmptyLine() = logger.info("") + fun logEmptyLine(): Unit = logger.info("") @Test @BeforeEach - private fun setUp() { + fun setUp() { mapper = KeycloakGroupsAndRolesToDockerScopeMapper() From 9167b5274aa4ab3dc3430e836ba7f87f548e711b Mon Sep 17 00:00:00 2001 From: Alexander Wolz Date: Sun, 25 Feb 2024 09:13:13 +0100 Subject: [PATCH 2/2] renamed custom group prefix key --- README.md | 2 +- .../mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt | 8 ++++---- .../mapping/testsuite/AbstractScopeMapperTestSuite.kt | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ffa7b9e..d9f315d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This mapper supports following environment variables (either set on server or in |---------------------------------|------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ```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 ```,```). | -| ```GROUP_PREFIX``` | any String | Custom group prefix. Will default to ```registry-```. Comparisons will be checked with lowercase String representation. | +| ```REGISTRY_GROUP_PREFIX``` | any String | Custom group prefix. Will default to ```registry-```. Comparisons will be checked with lowercase String representation. | ## 🔒 Keycloak Setup 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 e476acb..094e107 100644 --- a/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt +++ b/src/main/kotlin/de/alexanderwolz/keycloak/docker/mapping/KeycloakGroupsAndRolesToDockerScopeMapper.kt @@ -18,8 +18,8 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper( companion object { - internal const val KEY_GROUP_PREFIX = "GROUP_PREFIX" - internal const val DEFAULT_GROUP_PREFIX = "registry-" + internal const val KEY_REGISTRY_GROUP_PREFIX = "REGISTRY_GROUP_PREFIX" + internal const val DEFAULT_REGISTRY_GROUP_PREFIX = "registry-" //anybody with access to namespace repo is considered 'user' private const val ROLE_USER = "user" @@ -253,8 +253,8 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper( } ?: AUDIENCE_ADMIN } - private fun getGroupPrefixFromEnv():String{ - return getEnvVariable(KEY_GROUP_PREFIX) ?: DEFAULT_GROUP_PREFIX + private fun getGroupPrefixFromEnv(): String { + return getEnvVariable(KEY_REGISTRY_GROUP_PREFIX) ?: DEFAULT_REGISTRY_GROUP_PREFIX } private fun getNamespaceScopeFromEnv(): Set { diff --git a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AbstractScopeMapperTestSuite.kt b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AbstractScopeMapperTestSuite.kt index 254c022..cf2f5ef 100644 --- a/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AbstractScopeMapperTestSuite.kt +++ b/src/test/kotlin/de/alexanderwolz/keycloak/docker/mapping/testsuite/AbstractScopeMapperTestSuite.kt @@ -66,8 +66,9 @@ abstract class AbstractScopeMapperTestSuite { const val SCOPE_REPO_PLUGIN_NAMESPACE_DOMAIN_DELETE = "repository(plugin):$NAMESPACE_DOMAIN/$IMAGE:delete" const val SCOPE_REPO_PLUGIN_NAMESPACE_DOMAIN_PULL_PUSH = "repository(plugin):$NAMESPACE_DOMAIN/$IMAGE:pull,push" - const val GROUP_NAMESPACE = "${KeycloakGroupsAndRolesToDockerScopeMapper.DEFAULT_GROUP_PREFIX}$NAMESPACE" - const val GROUP_NAMESPACE_OTHER = "${KeycloakGroupsAndRolesToDockerScopeMapper.DEFAULT_GROUP_PREFIX}otherNamespace" + private const val GROUP_PREFIX = KeycloakGroupsAndRolesToDockerScopeMapper.DEFAULT_REGISTRY_GROUP_PREFIX + const val GROUP_NAMESPACE = "${GROUP_PREFIX}$NAMESPACE" + const val GROUP_NAMESPACE_OTHER = "${GROUP_PREFIX}otherNamespace" } private val logger = Logger.getLogger(javaClass)