Skip to content

Commit 6fa9376

Browse files
committed
feat(authorization): Implement migration to new rights structures
Extend `KeycloakAuthorizationService` by 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 2f683e0 commit 6fa9376

File tree

13 files changed

+577
-10
lines changed

13 files changed

+577
-10
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

components/authorization-keycloak/backend/src/main/kotlin/service/AuthorizationService.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,11 @@ interface AuthorizationService {
128128
* Return a [Set] with the names of all roles assigned to the user with the given [userId].
129129
*/
130130
suspend fun getUserRoleNames(userId: String): Set<String>
131+
132+
/**
133+
* Perform a one-time migration of roles stored in Keycloak to the database. The migration happens if and only if
134+
* the table with role assignments is empty. It is then populated with data corresponding to the current set of
135+
* groups existing in Keycloak. The return value indicates whether a migration was performed.
136+
*/
137+
suspend fun migrateRolesToDb(): Boolean
131138
}

components/authorization-keycloak/backend/src/main/kotlin/service/KeycloakAuthorizationService.kt

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import org.eclipse.apoapsis.ortserver.clients.keycloak.KeycloakClient
2727
import org.eclipse.apoapsis.ortserver.clients.keycloak.RoleName
2828
import org.eclipse.apoapsis.ortserver.clients.keycloak.UserId
2929
import org.eclipse.apoapsis.ortserver.clients.keycloak.UserName
30+
import org.eclipse.apoapsis.ortserver.components.authorization.db.RoleAssignmentsTable
3031
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.OrganizationPermission
3132
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.ProductPermission
3233
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission
@@ -35,8 +36,20 @@ import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Pr
3536
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.RepositoryRole
3637
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Role
3738
import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Superuser
39+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationRole as DbOrganizationRole
40+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductRole as DbProductRole
41+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryRole as DbRepositoryRole
42+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role as DbRole
43+
import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService as DbAuthorizationService
3844
import org.eclipse.apoapsis.ortserver.dao.dbQuery
45+
import org.eclipse.apoapsis.ortserver.dao.repositories.organization.OrganizationsTable
46+
import org.eclipse.apoapsis.ortserver.dao.repositories.product.ProductsTable
47+
import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable
48+
import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
3949
import org.eclipse.apoapsis.ortserver.model.HierarchyId
50+
import org.eclipse.apoapsis.ortserver.model.OrganizationId
51+
import org.eclipse.apoapsis.ortserver.model.ProductId
52+
import org.eclipse.apoapsis.ortserver.model.RepositoryId
4053
import org.eclipse.apoapsis.ortserver.model.repositories.OrganizationRepository
4154
import org.eclipse.apoapsis.ortserver.model.repositories.ProductRepository
4255
import org.eclipse.apoapsis.ortserver.model.repositories.RepositoryRepository
@@ -53,7 +66,7 @@ internal const val ROLE_DESCRIPTION = "This role is auto-generated, do not edit
5366
/**
5467
* An implementation of [AuthorizationService], based on [Keycloak](https://www.keycloak.org/).
5568
*/
56-
@Suppress("TooManyFunctions")
69+
@Suppress("TooManyFunctions", "LargeClass")
5770
class KeycloakAuthorizationService(
5871
private val keycloakClient: KeycloakClient,
5972
private val db: Database,
@@ -65,7 +78,13 @@ class KeycloakAuthorizationService(
6578
* A prefix for Keycloak group names, to be used when multiple instances of ORT Server share the same Keycloak
6679
* realm.
6780
*/
68-
private val keycloakGroupPrefix: String
81+
private val keycloakGroupPrefix: String,
82+
83+
/**
84+
* The reworked authorization service that stores authorization data in the database. This is used for the
85+
* migration functionality.
86+
*/
87+
private val dbAuthorizationService: DbAuthorizationService
6988
) : AuthorizationService {
7089
override suspend fun createOrganizationPermissions(organizationId: Long) {
7190
OrganizationPermission.getRolesForOrganization(organizationId).forEach { roleName ->
@@ -780,4 +799,165 @@ class KeycloakAuthorizationService(
780799
return keycloakClient.getUserClientRoles(UserId(userId))
781800
.mapTo(mutableSetOf()) { role -> role.name.value }
782801
}
802+
803+
override suspend fun migrateRolesToDb(): Boolean {
804+
if (!canMigrate()) return false
805+
806+
logger.warn("Starting migration of Keycloak roles to database-based roles.")
807+
808+
val organizationIds = db.dbQuery {
809+
OrganizationsTable.select(OrganizationsTable.id)
810+
.map { it[OrganizationsTable.id].value }
811+
}
812+
813+
logger.info("Migrating {} organizations.", organizationIds.size)
814+
organizationIds.forEach { organizationId ->
815+
migrateOrganizationRolesToDb(organizationId)
816+
}
817+
818+
logger.info("Migrating superusers")
819+
migrateUsersInGroupToDb(
820+
GroupName(keycloakGroupPrefix + Superuser.GROUP_NAME),
821+
DbOrganizationRole.ADMIN,
822+
CompoundHierarchyId.WILDCARD
823+
)
824+
825+
return true
826+
}
827+
828+
/**
829+
* Migrate the access rights for the organization with the given [organizationId] to the new database-based roles.
830+
* This includes the migration of all products and repositories belonging to the organization.
831+
*/
832+
private suspend fun migrateOrganizationRolesToDb(organizationId: Long) {
833+
logger.info("Migrating roles for organization '{}'.", organizationId)
834+
val organizationHierarchyId = CompoundHierarchyId.forOrganization(OrganizationId(organizationId))
835+
836+
migrateElementRolesToDb(
837+
oldRoles = OrganizationRole.entries,
838+
newRoles = DbOrganizationRole.entries,
839+
id = OrganizationId(organizationId),
840+
newHierarchyID = organizationHierarchyId
841+
)
842+
843+
val productIds = db.dbQuery {
844+
ProductsTable.select(ProductsTable.id)
845+
.where { ProductsTable.organizationId eq organizationId }
846+
.map { it[ProductsTable.id].value }
847+
}
848+
849+
logger.info("Migrating {} products for organization '{}'.", productIds.size, organizationId)
850+
productIds.forEach { productId ->
851+
val productHierarchyId = CompoundHierarchyId.forProduct(
852+
OrganizationId(organizationId),
853+
ProductId(productId)
854+
)
855+
migrateProductRolesToDb(productHierarchyId)
856+
}
857+
}
858+
859+
/**
860+
* Migrate the access rights for the product with the given [productHierarchyId] to the new database-based roles.
861+
* This includes the migration of all repositories belonging to the product.
862+
*/
863+
private suspend fun migrateProductRolesToDb(productHierarchyId: CompoundHierarchyId) {
864+
val productId = requireNotNull(productHierarchyId.productId)
865+
logger.info("Migrating roles for product '{}'.", productId)
866+
867+
migrateElementRolesToDb(
868+
oldRoles = ProductRole.entries,
869+
newRoles = DbProductRole.entries,
870+
id = productId,
871+
productHierarchyId
872+
)
873+
874+
val repositoryIds = db.dbQuery {
875+
RepositoriesTable.select(RepositoriesTable.id)
876+
.where { RepositoriesTable.productId eq productId.value }
877+
.map { it[RepositoriesTable.id].value }
878+
}
879+
880+
logger.info("Migrating {} repositories for product '{}'.", repositoryIds.size, productId)
881+
repositoryIds.forEach { repositoryId ->
882+
val repositoryHierarchyId = CompoundHierarchyId.forRepository(
883+
requireNotNull(productHierarchyId.organizationId),
884+
productId,
885+
RepositoryId(repositoryId)
886+
)
887+
migrateRepositoryRolesToDb(repositoryHierarchyId)
888+
}
889+
}
890+
891+
/**
892+
* Migrate the access rights for the repository with the given [repositoryId] to the new database-based roles.
893+
*/
894+
private suspend fun migrateRepositoryRolesToDb(repositoryId: CompoundHierarchyId) {
895+
logger.info("Migrating roles for repository '{}'.", repositoryId.repositoryId)
896+
897+
migrateElementRolesToDb(
898+
oldRoles = RepositoryRole.entries,
899+
newRoles = DbRepositoryRole.entries,
900+
id = requireNotNull(repositoryId.repositoryId),
901+
repositoryId
902+
)
903+
}
904+
905+
/**
906+
* Migrate all users in the given [oldRoles] to the corresponding [newRoles] for the hierarchy element with the
907+
* given [id] and [newHierarchyID].
908+
*/
909+
private suspend fun <ID : HierarchyId> migrateElementRolesToDb(
910+
oldRoles: Collection<Role<*, ID>>,
911+
newRoles: Collection<DbRole>,
912+
id: ID,
913+
newHierarchyID: CompoundHierarchyId
914+
) {
915+
oldRoles.zip(newRoles).forEach { (oldRole, newRole) ->
916+
migrateUsersForRoleToDb(oldRole, newRole, id, newHierarchyID)
917+
}
918+
}
919+
920+
/**
921+
* Migrate all users assigned to the given [oldRole] for the hierarchy element with the given [id] to the new
922+
* [newRole] in the database, using the provided [newHierarchyID].
923+
*/
924+
private suspend fun <ID : HierarchyId> migrateUsersForRoleToDb(
925+
oldRole: Role<*, ID>,
926+
newRole: DbRole,
927+
id: ID,
928+
newHierarchyID: CompoundHierarchyId
929+
) {
930+
val groupName = GroupName(keycloakGroupPrefix + oldRole.groupName(id))
931+
migrateUsersInGroupToDb(groupName, newRole, newHierarchyID)
932+
}
933+
934+
/**
935+
* Migrate all users in the Keycloak group with the given [groupName] (which represents a role) to the given
936+
* [newRole] for the hierarchy element with the given [newHierarchyID].
937+
*/
938+
private suspend fun migrateUsersInGroupToDb(
939+
groupName: GroupName,
940+
newRole: DbRole,
941+
newHierarchyID: CompoundHierarchyId
942+
) {
943+
runCatching {
944+
keycloakClient.getGroupMembers(groupName).forEach { user ->
945+
dbAuthorizationService.assignRole(
946+
userId = user.username.value,
947+
role = newRole,
948+
compoundHierarchyId = newHierarchyID
949+
)
950+
}
951+
}.onFailure { exception ->
952+
logger.error("Failed to load users in group '${groupName.value}' during migration.", exception)
953+
}
954+
}
955+
956+
/**
957+
* Return a flag whether the migration of access rights to the new database structures is possible. This is the
958+
* case
959+
*/
960+
private suspend fun canMigrate(): Boolean = db.dbQuery {
961+
RoleAssignmentsTable.select(RoleAssignmentsTable.id).count() == 0L
962+
}
783963
}

components/authorization-keycloak/backend/src/test/kotlin/service/KeycloakAuthorizationServiceTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ class KeycloakAuthorizationServiceTest : WordSpec({
113113
organizationRepository,
114114
productRepository,
115115
repositoryRepository,
116-
keycloakGroupPrefix
116+
keycloakGroupPrefix,
117+
mockk()
117118
).apply {
118119
if (createRolesForHierarchy) {
119120
createOrganizationPermissions(organizationId)

0 commit comments

Comments
 (0)