Skip to content

Commit 623e786

Browse files
committed
feat(authorization): Implement UserService
Add the `KeycloakUserService` class that implements the interface by delegating to a `KeycloakClient`. Parts of the implementation could be taken over from the old `Authorization` component. Signed-off-by: Oliver Heger <[email protected]>
1 parent 7fabca3 commit 623e786

File tree

3 files changed

+253
-0
lines changed

3 files changed

+253
-0
lines changed

components/authorization/backend/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ group = "org.eclipse.apoapsis.ortserver.components.authorization"
2727
dependencies {
2828
api(projects.components.authorization.apiModel)
2929
api(projects.model)
30+
api(projects.clients.keycloak)
3031

3132
api(ktorLibs.server.auth)
3233
api(ktorLibs.server.auth.jwt)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.eclipse.apoapsis.ortserver.components.authorization.service
21+
22+
import kotlinx.coroutines.Dispatchers
23+
import kotlinx.coroutines.async
24+
import kotlinx.coroutines.withContext
25+
26+
import org.eclipse.apoapsis.ortserver.clients.keycloak.KeycloakClient
27+
import org.eclipse.apoapsis.ortserver.clients.keycloak.User as KeycloakUser
28+
import org.eclipse.apoapsis.ortserver.clients.keycloak.UserName
29+
import org.eclipse.apoapsis.ortserver.model.User
30+
31+
/**
32+
* An implementation of the [UserService] interface that uses Keycloak as the backend user management system. As unique
33+
* IDs for users, it user the _username_ attribute in Keycloak.
34+
*/
35+
class KeycloakUserService(
36+
/** The client for interacting with Keycloak. */
37+
private val keycloakClient: KeycloakClient
38+
) : UserService {
39+
override suspend fun createUser(
40+
username: String,
41+
firstName: String?,
42+
lastName: String?,
43+
email: String?,
44+
password: String?,
45+
temporary: Boolean
46+
) {
47+
keycloakClient.createUser(
48+
username = UserName(username),
49+
firstName = firstName,
50+
lastName = lastName,
51+
email = email,
52+
password = password,
53+
temporary = temporary
54+
)
55+
}
56+
57+
override suspend fun deleteUser(username: String) {
58+
val userId = keycloakClient.getUser(UserName(username)).id
59+
keycloakClient.deleteUser(userId)
60+
}
61+
62+
override suspend fun getUsers(): Set<User> =
63+
keycloakClient.getUsers().mapTo(mutableSetOf()) { it.toOrtUser() }
64+
65+
override suspend fun getUserById(id: String): User =
66+
keycloakClient.getUser(UserName(id)).toOrtUser()
67+
68+
override suspend fun getUsersById(ids: Set<String>): Set<User> = withContext(Dispatchers.IO) {
69+
ids.map { async { getUserById(it) } }
70+
.mapTo(mutableSetOf()) { it.await() }
71+
}
72+
}
73+
74+
/**
75+
* Convert this [KeycloakUser] to a [User] in the ORT Server data model.
76+
*/
77+
private fun KeycloakUser.toOrtUser(): User =
78+
User(
79+
username = this.username.value,
80+
firstName = this.firstName,
81+
lastName = this.lastName,
82+
email = this.email
83+
)
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.eclipse.apoapsis.ortserver.components.authorization.service
21+
22+
import io.kotest.core.spec.style.WordSpec
23+
import io.kotest.matchers.collections.shouldContainExactly
24+
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
25+
import io.kotest.matchers.shouldBe
26+
27+
import io.mockk.coEvery
28+
import io.mockk.coVerify
29+
import io.mockk.just
30+
import io.mockk.mockk
31+
import io.mockk.runs
32+
33+
import org.eclipse.apoapsis.ortserver.clients.keycloak.KeycloakClient
34+
import org.eclipse.apoapsis.ortserver.clients.keycloak.User as KeycloakUser
35+
import org.eclipse.apoapsis.ortserver.clients.keycloak.UserId
36+
import org.eclipse.apoapsis.ortserver.clients.keycloak.UserName
37+
import org.eclipse.apoapsis.ortserver.model.User
38+
39+
class KeycloakUserServiceTest : WordSpec({
40+
"createUser" should {
41+
"create a user" {
42+
val username = "test-user"
43+
val firstName = "Test"
44+
val lastName = "User"
45+
val email = "[email protected]"
46+
val password = "secure-password"
47+
val temporary = false
48+
49+
val client = mockk<KeycloakClient> {
50+
coEvery { createUser(any(), any(), any(), any(), any(), any()) } just runs
51+
}
52+
53+
val service = KeycloakUserService(client)
54+
service.createUser(
55+
username = username,
56+
firstName = firstName,
57+
lastName = lastName,
58+
email = email,
59+
password = password,
60+
temporary = temporary
61+
)
62+
63+
coVerify {
64+
client.createUser(
65+
username = UserName(username),
66+
firstName = firstName,
67+
lastName = lastName,
68+
email = email,
69+
password = password,
70+
temporary = temporary
71+
)
72+
}
73+
}
74+
}
75+
76+
"deleteUser" should {
77+
"delete a user" {
78+
val keycloakUser = createKeycloakUser(1)
79+
80+
val client = mockk<KeycloakClient> {
81+
coEvery { getUser(keycloakUser.username) } returns keycloakUser
82+
coEvery { deleteUser(any()) } just runs
83+
}
84+
85+
val service = KeycloakUserService(client)
86+
service.deleteUser(keycloakUser.username.value)
87+
88+
coVerify {
89+
client.deleteUser(keycloakUser.id)
90+
}
91+
}
92+
}
93+
94+
"getUsers" should {
95+
"return a set of all users" {
96+
val userCount = 16
97+
val keycloakUsers = (1..userCount).mapTo(mutableSetOf(), ::createKeycloakUser)
98+
val expectedUsers = (1..userCount).mapTo(mutableSetOf(), ::createUser)
99+
100+
val client = mockk<KeycloakClient> {
101+
coEvery { getUsers() } returns keycloakUsers
102+
}
103+
104+
val service = KeycloakUserService(client)
105+
val users = service.getUsers()
106+
107+
users shouldContainExactlyInAnyOrder expectedUsers
108+
}
109+
}
110+
111+
"getUserById" should {
112+
"retrieve a user by its Keycloak username" {
113+
val keycloakUser = createKeycloakUser(42)
114+
val expectedUser = createUser(42)
115+
116+
val client = mockk<KeycloakClient> {
117+
coEvery { getUser(keycloakUser.username) } returns keycloakUser
118+
}
119+
120+
val service = KeycloakUserService(client)
121+
val user = service.getUserById(keycloakUser.username.value)
122+
123+
user shouldBe expectedUser
124+
}
125+
}
126+
127+
"getUsersById" should {
128+
"retrieve multiple users by their Keycloak usernames" {
129+
val userCount = 8
130+
val keycloakUsers = (1..userCount).mapTo(mutableSetOf(), ::createKeycloakUser)
131+
val expectedUsers = (1..userCount).mapTo(mutableSetOf(), ::createUser)
132+
val userIds = keycloakUsers.mapTo(mutableSetOf()) { it.username.value }
133+
134+
val client = mockk<KeycloakClient> {
135+
keycloakUsers.forEach {
136+
coEvery { getUser(it.username) } returns it
137+
}
138+
}
139+
140+
val service = KeycloakUserService(client)
141+
val users = service.getUsersById(userIds)
142+
143+
users shouldContainExactly expectedUsers
144+
}
145+
}
146+
})
147+
148+
/**
149+
* Generate a test user in Keycloak with properties derived from the given [index].
150+
*/
151+
private fun createKeycloakUser(index: Int): KeycloakUser =
152+
KeycloakUser(
153+
id = UserId("id-$index"),
154+
username = UserName("user$index"),
155+
firstName = "First$index",
156+
lastName = "Last$index",
157+
email = "user$index@example.com"
158+
)
159+
160+
/**
161+
* Generate a test user in the ORT Server model with properties derived from the given [index].
162+
*/
163+
private fun createUser(index: Int): User =
164+
User(
165+
username = "user$index",
166+
firstName = "First$index",
167+
lastName = "Last$index",
168+
email = "user$index@example.com"
169+
)

0 commit comments

Comments
 (0)