|
| 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