Skip to content

Commit

Permalink
Merge pull request #14 from alexanderwolz/add-subgroups-support
Browse files Browse the repository at this point in the history
Add subgroups support
  • Loading branch information
alexanderwolz authored Feb 25, 2024
2 parents 894ab2c + 9167b52 commit decf852
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 16 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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.<br> 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.<br>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.<br>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.<br>If ```username``` is set, users will be granted full access to the namespace if it matches their username (lowercase check).<br><br>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 ```,```). |
| ```REGISTRY_GROUP_PREFIX``` | any String | Custom group prefix. Will default to ```registry-```. Comparisons will be checked with lowercase String representation. |


## 🔒 Keycloak Setup
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ plugins {
}

group = 'de.alexanderwolz'
version = '1.5.0'
version = '1.5.1'

repositories {
mavenCentral()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,7 +18,8 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper(

companion object {

internal const val 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"
Expand All @@ -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()

Expand Down Expand Up @@ -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)
}

Expand All @@ -173,8 +176,11 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper(
}

internal fun getUserNamespacesFromGroups(user: UserModel): Collection<String> {
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(
Expand Down Expand Up @@ -247,6 +253,10 @@ class KeycloakGroupsAndRolesToDockerScopeMapper : AbstractDockerScopeMapper(
} ?: AUDIENCE_ADMIN
}

private fun getGroupPrefixFromEnv(): String {
return getEnvVariable(KEY_REGISTRY_GROUP_PREFIX) ?: DEFAULT_REGISTRY_GROUP_PREFIX
}

private fun getNamespaceScopeFromEnv(): Set<String> {
return getEnvVariable(KEY_REGISTRY_NAMESPACE_SCOPE)?.let { scopeString ->
val scopes = scopeString.split(",").map { it.lowercase() }.filter {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,90 @@ 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 {

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<GroupModel>()
}
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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.GROUP_PREFIX}$NAMESPACE"
const val GROUP_NAMESPACE_OTHER = "${KeycloakGroupsAndRolesToDockerScopeMapper.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)
Expand All @@ -86,16 +87,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()

Expand Down

0 comments on commit decf852

Please sign in to comment.