Skip to content

Commit be5428f

Browse files
committed
feat(authorization): Implement migration to new rights structures
Create a new `RolesToDbMigration` class offering a function that can create structures in the database that correspond to the roles and permissions currently kept in Keycloak. The function is going to be called on startup of ORT Server to perform a one-time migration. Signed-off-by: Oliver Heger <[email protected]>
1 parent 95e0e57 commit be5428f

File tree

3 files changed

+593
-0
lines changed

3 files changed

+593
-0
lines changed

components/authorization-keycloak/backend/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ group = "org.eclipse.apoapsis.ortserver.components.authorization.keycloak"
2626

2727
dependencies {
2828
api(projects.clients.keycloak)
29+
api(projects.components.authorization.backend)
2930
api(projects.components.authorizationKeycloak.apiModel)
3031
api(projects.model)
3132

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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.keycloak.migration
21+
22+
import org.eclipse.apoapsis.ortserver.clients.keycloak.GroupName
23+
import org.eclipse.apoapsis.ortserver.clients.keycloak.KeycloakClient
24+
import org.eclipse.apoapsis.ortserver.components.authorization.db.RoleAssignmentsTable
25+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.OrganizationRole
26+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.ProductRole
27+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.RepositoryRole
28+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Role
29+
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Superuser
30+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationRole as DbOrganizationRole
31+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductRole as DbProductRole
32+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryRole as DbRepositoryRole
33+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role as DbRole
34+
import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService
35+
import org.eclipse.apoapsis.ortserver.dao.dbQuery
36+
import org.eclipse.apoapsis.ortserver.dao.repositories.organization.OrganizationsTable
37+
import org.eclipse.apoapsis.ortserver.dao.repositories.product.ProductsTable
38+
import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable
39+
import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
40+
import org.eclipse.apoapsis.ortserver.model.HierarchyId
41+
import org.eclipse.apoapsis.ortserver.model.OrganizationId
42+
import org.eclipse.apoapsis.ortserver.model.ProductId
43+
import org.eclipse.apoapsis.ortserver.model.RepositoryId
44+
import org.jetbrains.exposed.sql.Database
45+
import org.slf4j.LoggerFactory
46+
47+
private val logger = LoggerFactory.getLogger(RolesToDbMigration::class.java)
48+
49+
/**
50+
* A class implementing a migration that moves roles management to the database.
51+
*
52+
* The migration iterates over the existing organizations, products, and repositories and the associating groups in
53+
* Keycloak that represent the user roles on these entities. It then creates corresponding role assignment entries in
54+
* the database. The migration needs to be executed once to set up the data structures for the new authorization
55+
* component.
56+
*/
57+
class RolesToDbMigration(
58+
private val keycloakClient: KeycloakClient,
59+
private val db: Database,
60+
61+
/**
62+
* A prefix for Keycloak group names, to be used when multiple instances of ORT Server share the same Keycloak
63+
* realm.
64+
*/
65+
private val keycloakGroupPrefix: String,
66+
67+
/**
68+
* The reworked authorization service that stores authorization data in the database. This is used for the
69+
* migration functionality.
70+
*/
71+
private val authorizationService: AuthorizationService
72+
73+
) {
74+
/**
75+
* Perform a one-time migration of roles stored in Keycloak to the database. The migration happens if and only if
76+
* the table with role assignments is empty. It is then populated with data corresponding to the current set of
77+
* groups existing in Keycloak. The return value indicates whether a migration was performed.
78+
*/
79+
suspend fun migrateRolesToDb(): Boolean {
80+
if (!canMigrate()) return false
81+
82+
logger.warn(
83+
"Starting migration of Keycloak roles to database-based roles using group prefix '{}'.",
84+
keycloakGroupPrefix
85+
)
86+
87+
val organizationIds = db.dbQuery {
88+
OrganizationsTable.select(OrganizationsTable.id)
89+
.map { it[OrganizationsTable.id].value }
90+
}
91+
92+
logger.info("Migrating {} organizations.", organizationIds.size)
93+
organizationIds.forEach { organizationId ->
94+
migrateOrganizationRolesToDb(organizationId)
95+
}
96+
97+
logger.info("Migrating superusers")
98+
migrateUsersInGroupToDb(
99+
GroupName(keycloakGroupPrefix + Superuser.GROUP_NAME),
100+
DbOrganizationRole.ADMIN,
101+
CompoundHierarchyId.WILDCARD
102+
)
103+
104+
return true
105+
}
106+
107+
/**
108+
* Migrate the access rights for the organization with the given [organizationId] to the new database-based roles.
109+
* This includes the migration of all products and repositories belonging to the organization.
110+
*/
111+
private suspend fun migrateOrganizationRolesToDb(organizationId: Long) {
112+
logger.info("Migrating roles for organization '{}'.", organizationId)
113+
val organizationHierarchyId = CompoundHierarchyId.forOrganization(OrganizationId(organizationId))
114+
115+
migrateElementRolesToDb(
116+
oldRoles = OrganizationRole.entries,
117+
newRoles = DbOrganizationRole.entries,
118+
id = OrganizationId(organizationId),
119+
newHierarchyID = organizationHierarchyId
120+
)
121+
122+
val productIds = db.dbQuery {
123+
ProductsTable.select(ProductsTable.id)
124+
.where { ProductsTable.organizationId eq organizationId }
125+
.map { it[ProductsTable.id].value }
126+
}
127+
128+
logger.info("Migrating {} products for organization '{}'.", productIds.size, organizationId)
129+
productIds.forEach { productId ->
130+
val productHierarchyId = CompoundHierarchyId.forProduct(
131+
OrganizationId(organizationId),
132+
ProductId(productId)
133+
)
134+
migrateProductRolesToDb(productHierarchyId)
135+
}
136+
}
137+
138+
/**
139+
* Migrate the access rights for the product with the given [productHierarchyId] to the new database-based roles.
140+
* This includes the migration of all repositories belonging to the product.
141+
*/
142+
private suspend fun migrateProductRolesToDb(productHierarchyId: CompoundHierarchyId) {
143+
val productId = requireNotNull(productHierarchyId.productId)
144+
logger.info("Migrating roles for product '{}'.", productId)
145+
146+
migrateElementRolesToDb(
147+
oldRoles = ProductRole.entries,
148+
newRoles = DbProductRole.entries,
149+
id = productId,
150+
productHierarchyId
151+
)
152+
153+
val repositoryIds = db.dbQuery {
154+
RepositoriesTable.select(RepositoriesTable.id)
155+
.where { RepositoriesTable.productId eq productId.value }
156+
.map { it[RepositoriesTable.id].value }
157+
}
158+
159+
logger.info("Migrating {} repositories for product '{}'.", repositoryIds.size, productId)
160+
repositoryIds.forEach { repositoryId ->
161+
val repositoryHierarchyId = CompoundHierarchyId.forRepository(
162+
requireNotNull(productHierarchyId.organizationId),
163+
productId,
164+
RepositoryId(repositoryId)
165+
)
166+
migrateRepositoryRolesToDb(repositoryHierarchyId)
167+
}
168+
}
169+
170+
/**
171+
* Migrate the access rights for the repository with the given [repositoryId] to the new database-based roles.
172+
*/
173+
private suspend fun migrateRepositoryRolesToDb(repositoryId: CompoundHierarchyId) {
174+
logger.info("Migrating roles for repository '{}'.", repositoryId.repositoryId)
175+
176+
migrateElementRolesToDb(
177+
oldRoles = RepositoryRole.entries,
178+
newRoles = DbRepositoryRole.entries,
179+
id = requireNotNull(repositoryId.repositoryId),
180+
repositoryId
181+
)
182+
}
183+
184+
/**
185+
* Migrate all users in the given [oldRoles] to the corresponding [newRoles] for the hierarchy element with the
186+
* given [id] and [newHierarchyID].
187+
*/
188+
private suspend fun <ID : HierarchyId> migrateElementRolesToDb(
189+
oldRoles: Collection<Role<*, ID>>,
190+
newRoles: Collection<DbRole>,
191+
id: ID,
192+
newHierarchyID: CompoundHierarchyId
193+
) {
194+
oldRoles.zip(newRoles).forEach { (oldRole, newRole) ->
195+
migrateUsersForRoleToDb(oldRole, newRole, id, newHierarchyID)
196+
}
197+
}
198+
199+
/**
200+
* Migrate all users assigned to the given [oldRole] for the hierarchy element with the given [id] to the new
201+
* [newRole] in the database, using the provided [newHierarchyID].
202+
*/
203+
private suspend fun <ID : HierarchyId> migrateUsersForRoleToDb(
204+
oldRole: Role<*, ID>,
205+
newRole: DbRole,
206+
id: ID,
207+
newHierarchyID: CompoundHierarchyId
208+
) {
209+
val groupName = GroupName(keycloakGroupPrefix + oldRole.groupName(id))
210+
migrateUsersInGroupToDb(groupName, newRole, newHierarchyID)
211+
}
212+
213+
/**
214+
* Migrate all users in the Keycloak group with the given [groupName] (which represents a role) to the given
215+
* [newRole] for the hierarchy element with the given [newHierarchyID].
216+
*/
217+
private suspend fun migrateUsersInGroupToDb(
218+
groupName: GroupName,
219+
newRole: DbRole,
220+
newHierarchyID: CompoundHierarchyId
221+
) {
222+
runCatching {
223+
keycloakClient.getGroupMembers(groupName).forEach { user ->
224+
authorizationService.assignRole(
225+
userId = user.username.value,
226+
role = newRole,
227+
compoundHierarchyId = newHierarchyID
228+
)
229+
}
230+
}.onFailure { exception ->
231+
logger.error("Failed to load users in group '${groupName.value}' during migration.", exception)
232+
}
233+
}
234+
235+
/**
236+
* Return a flag whether the migration of access rights to the new database structures is possible. This is the
237+
* case
238+
*/
239+
private suspend fun canMigrate(): Boolean = db.dbQuery {
240+
RoleAssignmentsTable.select(RoleAssignmentsTable.id).count() == 0L
241+
}
242+
}

0 commit comments

Comments
 (0)