diff --git a/components/authorization-keycloak/backend/build.gradle.kts b/components/authorization-keycloak/backend/build.gradle.kts index b0aa818b44..3adf4ff904 100644 --- a/components/authorization-keycloak/backend/build.gradle.kts +++ b/components/authorization-keycloak/backend/build.gradle.kts @@ -26,6 +26,7 @@ group = "org.eclipse.apoapsis.ortserver.components.authorization.keycloak" dependencies { api(projects.clients.keycloak) + api(projects.components.authorization.backend) api(projects.components.authorizationKeycloak.apiModel) api(projects.model) diff --git a/components/authorization-keycloak/backend/src/main/kotlin/service/AuthorizationService.kt b/components/authorization-keycloak/backend/src/main/kotlin/service/AuthorizationService.kt index be173ee4ee..b82da07c82 100644 --- a/components/authorization-keycloak/backend/src/main/kotlin/service/AuthorizationService.kt +++ b/components/authorization-keycloak/backend/src/main/kotlin/service/AuthorizationService.kt @@ -128,4 +128,11 @@ interface AuthorizationService { * Return a [Set] with the names of all roles assigned to the user with the given [userId]. */ suspend fun getUserRoleNames(userId: String): Set + + /** + * Perform a one-time migration of roles stored in Keycloak to the database. The migration happens if and only if + * the table with role assignments is empty. It is then populated with data corresponding to the current set of + * groups existing in Keycloak. The return value indicates whether a migration was performed. + */ + suspend fun migrateRolesToDb(): Boolean } diff --git a/components/authorization-keycloak/backend/src/main/kotlin/service/KeycloakAuthorizationService.kt b/components/authorization-keycloak/backend/src/main/kotlin/service/KeycloakAuthorizationService.kt index 96f13527b3..6115e42ea4 100644 --- a/components/authorization-keycloak/backend/src/main/kotlin/service/KeycloakAuthorizationService.kt +++ b/components/authorization-keycloak/backend/src/main/kotlin/service/KeycloakAuthorizationService.kt @@ -27,6 +27,7 @@ import org.eclipse.apoapsis.ortserver.clients.keycloak.KeycloakClient import org.eclipse.apoapsis.ortserver.clients.keycloak.RoleName import org.eclipse.apoapsis.ortserver.clients.keycloak.UserId import org.eclipse.apoapsis.ortserver.clients.keycloak.UserName +import org.eclipse.apoapsis.ortserver.components.authorization.db.RoleAssignmentsTable import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.OrganizationPermission import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.ProductPermission import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission @@ -35,8 +36,20 @@ import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Pr import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.RepositoryRole import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Role import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Superuser +import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationRole as DbOrganizationRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductRole as DbProductRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryRole as DbRepositoryRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role as DbRole +import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService as DbAuthorizationService import org.eclipse.apoapsis.ortserver.dao.dbQuery +import org.eclipse.apoapsis.ortserver.dao.repositories.organization.OrganizationsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.product.ProductsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId import org.eclipse.apoapsis.ortserver.model.HierarchyId +import org.eclipse.apoapsis.ortserver.model.OrganizationId +import org.eclipse.apoapsis.ortserver.model.ProductId +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.repositories.OrganizationRepository import org.eclipse.apoapsis.ortserver.model.repositories.ProductRepository 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 /** * An implementation of [AuthorizationService], based on [Keycloak](https://www.keycloak.org/). */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class KeycloakAuthorizationService( private val keycloakClient: KeycloakClient, private val db: Database, @@ -65,7 +78,13 @@ class KeycloakAuthorizationService( * A prefix for Keycloak group names, to be used when multiple instances of ORT Server share the same Keycloak * realm. */ - private val keycloakGroupPrefix: String + private val keycloakGroupPrefix: String, + + /** + * The reworked authorization service that stores authorization data in the database. This is used for the + * migration functionality. + */ + private val dbAuthorizationService: DbAuthorizationService ) : AuthorizationService { override suspend fun createOrganizationPermissions(organizationId: Long) { OrganizationPermission.getRolesForOrganization(organizationId).forEach { roleName -> @@ -780,4 +799,165 @@ class KeycloakAuthorizationService( return keycloakClient.getUserClientRoles(UserId(userId)) .mapTo(mutableSetOf()) { role -> role.name.value } } + + override suspend fun migrateRolesToDb(): Boolean { + if (!canMigrate()) return false + + logger.warn("Starting migration of Keycloak roles to database-based roles.") + + val organizationIds = db.dbQuery { + OrganizationsTable.select(OrganizationsTable.id) + .map { it[OrganizationsTable.id].value } + } + + logger.info("Migrating {} organizations.", organizationIds.size) + organizationIds.forEach { organizationId -> + migrateOrganizationRolesToDb(organizationId) + } + + logger.info("Migrating superusers") + migrateUsersInGroupToDb( + GroupName(keycloakGroupPrefix + Superuser.GROUP_NAME), + DbOrganizationRole.ADMIN, + CompoundHierarchyId.WILDCARD + ) + + return true + } + + /** + * Migrate the access rights for the organization with the given [organizationId] to the new database-based roles. + * This includes the migration of all products and repositories belonging to the organization. + */ + private suspend fun migrateOrganizationRolesToDb(organizationId: Long) { + logger.info("Migrating roles for organization '{}'.", organizationId) + val organizationHierarchyId = CompoundHierarchyId.forOrganization(OrganizationId(organizationId)) + + migrateElementRolesToDb( + oldRoles = OrganizationRole.entries, + newRoles = DbOrganizationRole.entries, + id = OrganizationId(organizationId), + newHierarchyID = organizationHierarchyId + ) + + val productIds = db.dbQuery { + ProductsTable.select(ProductsTable.id) + .where { ProductsTable.organizationId eq organizationId } + .map { it[ProductsTable.id].value } + } + + logger.info("Migrating {} products for organization '{}'.", productIds.size, organizationId) + productIds.forEach { productId -> + val productHierarchyId = CompoundHierarchyId.forProduct( + OrganizationId(organizationId), + ProductId(productId) + ) + migrateProductRolesToDb(productHierarchyId) + } + } + + /** + * Migrate the access rights for the product with the given [productHierarchyId] to the new database-based roles. + * This includes the migration of all repositories belonging to the product. + */ + private suspend fun migrateProductRolesToDb(productHierarchyId: CompoundHierarchyId) { + val productId = requireNotNull(productHierarchyId.productId) + logger.info("Migrating roles for product '{}'.", productId) + + migrateElementRolesToDb( + oldRoles = ProductRole.entries, + newRoles = DbProductRole.entries, + id = productId, + productHierarchyId + ) + + val repositoryIds = db.dbQuery { + RepositoriesTable.select(RepositoriesTable.id) + .where { RepositoriesTable.productId eq productId.value } + .map { it[RepositoriesTable.id].value } + } + + logger.info("Migrating {} repositories for product '{}'.", repositoryIds.size, productId) + repositoryIds.forEach { repositoryId -> + val repositoryHierarchyId = CompoundHierarchyId.forRepository( + requireNotNull(productHierarchyId.organizationId), + productId, + RepositoryId(repositoryId) + ) + migrateRepositoryRolesToDb(repositoryHierarchyId) + } + } + + /** + * Migrate the access rights for the repository with the given [repositoryId] to the new database-based roles. + */ + private suspend fun migrateRepositoryRolesToDb(repositoryId: CompoundHierarchyId) { + logger.info("Migrating roles for repository '{}'.", repositoryId.repositoryId) + + migrateElementRolesToDb( + oldRoles = RepositoryRole.entries, + newRoles = DbRepositoryRole.entries, + id = requireNotNull(repositoryId.repositoryId), + repositoryId + ) + } + + /** + * Migrate all users in the given [oldRoles] to the corresponding [newRoles] for the hierarchy element with the + * given [id] and [newHierarchyID]. + */ + private suspend fun migrateElementRolesToDb( + oldRoles: Collection>, + newRoles: Collection, + id: ID, + newHierarchyID: CompoundHierarchyId + ) { + oldRoles.zip(newRoles).forEach { (oldRole, newRole) -> + migrateUsersForRoleToDb(oldRole, newRole, id, newHierarchyID) + } + } + + /** + * Migrate all users assigned to the given [oldRole] for the hierarchy element with the given [id] to the new + * [newRole] in the database, using the provided [newHierarchyID]. + */ + private suspend fun migrateUsersForRoleToDb( + oldRole: Role<*, ID>, + newRole: DbRole, + id: ID, + newHierarchyID: CompoundHierarchyId + ) { + val groupName = GroupName(keycloakGroupPrefix + oldRole.groupName(id)) + migrateUsersInGroupToDb(groupName, newRole, newHierarchyID) + } + + /** + * Migrate all users in the Keycloak group with the given [groupName] (which represents a role) to the given + * [newRole] for the hierarchy element with the given [newHierarchyID]. + */ + private suspend fun migrateUsersInGroupToDb( + groupName: GroupName, + newRole: DbRole, + newHierarchyID: CompoundHierarchyId + ) { + runCatching { + keycloakClient.getGroupMembers(groupName).forEach { user -> + dbAuthorizationService.assignRole( + userId = user.username.value, + role = newRole, + compoundHierarchyId = newHierarchyID + ) + } + }.onFailure { exception -> + logger.error("Failed to load users in group '${groupName.value}' during migration.", exception) + } + } + + /** + * Return a flag whether the migration of access rights to the new database structures is possible. This is the + * case + */ + private suspend fun canMigrate(): Boolean = db.dbQuery { + RoleAssignmentsTable.select(RoleAssignmentsTable.id).count() == 0L + } } diff --git a/components/authorization-keycloak/backend/src/test/kotlin/service/KeycloakAuthorizationServiceTest.kt b/components/authorization-keycloak/backend/src/test/kotlin/service/KeycloakAuthorizationServiceTest.kt index 40f7ea49fa..8f69f12ed2 100644 --- a/components/authorization-keycloak/backend/src/test/kotlin/service/KeycloakAuthorizationServiceTest.kt +++ b/components/authorization-keycloak/backend/src/test/kotlin/service/KeycloakAuthorizationServiceTest.kt @@ -113,7 +113,8 @@ class KeycloakAuthorizationServiceTest : WordSpec({ organizationRepository, productRepository, repositoryRepository, - keycloakGroupPrefix + keycloakGroupPrefix, + mockk() ).apply { if (createRolesForHierarchy) { createOrganizationPermissions(organizationId) diff --git a/components/authorization-keycloak/backend/src/test/kotlin/service/MigrateRolesToDbTest.kt b/components/authorization-keycloak/backend/src/test/kotlin/service/MigrateRolesToDbTest.kt new file mode 100644 index 0000000000..0aa69f2768 --- /dev/null +++ b/components/authorization-keycloak/backend/src/test/kotlin/service/MigrateRolesToDbTest.kt @@ -0,0 +1,356 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.authorization.keycloak.service + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.beEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldStartWith + +import io.mockk.coEvery +import io.mockk.mockk + +import org.eclipse.apoapsis.ortserver.clients.keycloak.GroupName +import org.eclipse.apoapsis.ortserver.clients.keycloak.KeycloakClient +import org.eclipse.apoapsis.ortserver.clients.keycloak.User +import org.eclipse.apoapsis.ortserver.clients.keycloak.UserId +import org.eclipse.apoapsis.ortserver.clients.keycloak.UserName +import org.eclipse.apoapsis.ortserver.components.authorization.db.RoleAssignmentsTable +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.OrganizationRole +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.ProductRole +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.RepositoryRole +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Superuser +import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationRole as DbOrganizationRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductRole as DbProductRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryRole as DbRepositoryRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role +import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService as DbAuthorizationService +import org.eclipse.apoapsis.ortserver.dao.dbQuery +import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId +import org.eclipse.apoapsis.ortserver.model.OrganizationId +import org.eclipse.apoapsis.ortserver.model.ProductId +import org.eclipse.apoapsis.ortserver.model.RepositoryId + +import org.jetbrains.exposed.sql.insert + +class MigrateRolesToDbTest : StringSpec() { + private val dbExtension = extension(DatabaseTestExtension()) + + /** + * Create a test instance of [KeycloakAuthorizationService] with the given dependencies. + */ + private fun createService( + keycloakClient: KeycloakClient, + dbAuthorizationService: DbAuthorizationService + ): KeycloakAuthorizationService = + KeycloakAuthorizationService( + keycloakClient = keycloakClient, + db = dbExtension.db, + organizationRepository = mockk(), + productRepository = mockk(), + repositoryRepository = mockk(), + keycloakGroupPrefix = GROUP_PREFIX, + dbAuthorizationService = dbAuthorizationService + ) + + init { + "role assignments for all hierarchy elements should be migrated correctly" { + val orgAdminUser = "OrgAdmin" + val productAdminUser = "ProductAdmin" + val user1 = "test-user1" + val user2 = "test-user2" + val user3 = "test-user3" + val repositoryId = CompoundHierarchyId.forRepository( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(dbExtension.fixtures.product.id), + RepositoryId(dbExtension.fixtures.repository.id) + ) + val productId = repositoryId.parent!! + val organizationId = productId.parent!! + val repository2 = dbExtension.fixtures.createRepository(url = "https://example.com/repo2.git") + val repository2Id = CompoundHierarchyId.forRepository( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(dbExtension.fixtures.product.id), + RepositoryId(repository2.id) + ) + val organization2 = dbExtension.fixtures.createOrganization("Org2") + val organization2Id = CompoundHierarchyId.forOrganization(OrganizationId(organization2.id)) + val product2 = dbExtension.fixtures.createProduct("Product2", organizationId = organization2.id) + val product2Id = CompoundHierarchyId.forProduct( + OrganizationId(organization2.id), + ProductId(product2.id) + ) + + val groupsWithUsers = mapOf( + OrganizationRole.ADMIN.groupName(dbExtension.fixtures.organization.id) to listOf(orgAdminUser), + OrganizationRole.WRITER.groupName(dbExtension.fixtures.organization.id) to listOf( + productAdminUser, + user1 + ), + OrganizationRole.READER.groupName(dbExtension.fixtures.organization.id) to listOf(user2), + ProductRole.ADMIN.groupName(dbExtension.fixtures.product.id) to listOf(productAdminUser), + ProductRole.WRITER.groupName(dbExtension.fixtures.product.id) to listOf(user1), + ProductRole.READER.groupName(dbExtension.fixtures.product.id) to listOf(user2), + RepositoryRole.ADMIN.groupName(dbExtension.fixtures.repository.id) to emptyList(), + RepositoryRole.WRITER.groupName(dbExtension.fixtures.repository.id) to listOf( + user1, + user2 + ), + RepositoryRole.READER.groupName(dbExtension.fixtures.repository.id) to emptyList(), + RepositoryRole.ADMIN.groupName(repository2.id) to emptyList(), + RepositoryRole.WRITER.groupName(repository2.id) to emptyList(), + RepositoryRole.READER.groupName(repository2.id) to listOf(user1, user2), + OrganizationRole.ADMIN.groupName(organization2.id) to listOf(orgAdminUser), + OrganizationRole.WRITER.groupName(organization2.id) to emptyList(), + OrganizationRole.READER.groupName(organization2.id) to listOf( + user3, + productAdminUser + ), + ProductRole.ADMIN.groupName(product2.id) to emptyList(), + ProductRole.WRITER.groupName(product2.id) to listOf(user3), + ProductRole.READER.groupName(product2.id) to emptyList(), + + Superuser.GROUP_NAME to emptyList() + ) + + val expectedAssignments = listOf( + RoleAssignment( + userId = orgAdminUser, + role = DbOrganizationRole.ADMIN, + hierarchyId = organizationId + ), + RoleAssignment( + userId = productAdminUser, + role = DbOrganizationRole.WRITER, + hierarchyId = organizationId + ), + RoleAssignment( + userId = user1, + role = DbOrganizationRole.WRITER, + hierarchyId = organizationId + ), + RoleAssignment( + userId = user2, + role = DbOrganizationRole.READER, + hierarchyId = organizationId + ), + RoleAssignment( + userId = productAdminUser, + role = DbProductRole.ADMIN, + hierarchyId = productId + ), + RoleAssignment( + userId = user1, + role = DbProductRole.WRITER, + hierarchyId = productId + ), + RoleAssignment( + userId = user2, + role = DbProductRole.READER, + hierarchyId = productId + ), + RoleAssignment( + userId = user1, + role = DbRepositoryRole.WRITER, + hierarchyId = repositoryId + ), + RoleAssignment( + userId = user2, + role = DbRepositoryRole.WRITER, + hierarchyId = repositoryId + ), + RoleAssignment( + userId = user1, + role = DbRepositoryRole.READER, + hierarchyId = repository2Id + ), + RoleAssignment( + userId = user2, + role = DbRepositoryRole.READER, + hierarchyId = repository2Id + ), + RoleAssignment( + userId = orgAdminUser, + role = DbOrganizationRole.ADMIN, + hierarchyId = organization2Id + ), + RoleAssignment( + userId = productAdminUser, + role = DbOrganizationRole.READER, + hierarchyId = organization2Id + ), + RoleAssignment( + userId = user3, + role = DbOrganizationRole.READER, + hierarchyId = organization2Id + ), + RoleAssignment( + userId = user3, + role = DbProductRole.WRITER, + hierarchyId = product2Id + ) + ) + + val keycloakClient = createKeycloakClientForGroups(groupsWithUsers) + val assignments = mutableListOf() + val dbAuthorizationService = createDbAuthorizationServiceMock(assignments) + + val authorizationService = createService( + keycloakClient = keycloakClient, + dbAuthorizationService = dbAuthorizationService + ) + authorizationService.migrateRolesToDb() shouldBe true + + assignments shouldContainExactlyInAnyOrder expectedAssignments + } + + "exceptions when querying Keycloak group members should be ignored" { + val user = "some-user" + val groupsWithUsers = mapOf( + OrganizationRole.WRITER.groupName(dbExtension.fixtures.organization.id) to listOf(user), + ) + + val keycloakClient = createKeycloakClientForGroups(groupsWithUsers) + val assignments = mutableListOf() + val dbAuthorizationService = createDbAuthorizationServiceMock(assignments) + + val authorizationService = createService( + keycloakClient = keycloakClient, + dbAuthorizationService = dbAuthorizationService + ) + authorizationService.migrateRolesToDb() shouldBe true + + assignments shouldHaveSize 1 + } + + "superuser role assignments should be migrated correctly" { + val superuser1 = "SuperMan" + val superuser2 = "BatMan" + + val groupsWithUsers = mapOf( + Superuser.GROUP_NAME to listOf(superuser1, superuser2) + ) + + val expectedAssignments = listOf( + RoleAssignment( + userId = superuser1, + role = DbOrganizationRole.ADMIN, + hierarchyId = CompoundHierarchyId.WILDCARD + ), + RoleAssignment( + userId = superuser2, + role = DbOrganizationRole.ADMIN, + hierarchyId = CompoundHierarchyId.WILDCARD + ) + ) + + val keycloakClient = createKeycloakClientForGroups(groupsWithUsers) + val assignments = mutableListOf() + val dbAuthorizationService = createDbAuthorizationServiceMock(assignments) + + val authorizationService = createService( + keycloakClient = keycloakClient, + dbAuthorizationService = dbAuthorizationService + ) + authorizationService.migrateRolesToDb() shouldBe true + + assignments shouldContainExactlyInAnyOrder expectedAssignments + } + + "migration should be skipped if the DB already contains role assignments" { + dbExtension.fixtures.repository.id // This forces the creation of hierarchy elements. + + dbExtension.db.dbQuery { + RoleAssignmentsTable.insert { + it[userId] = "some-user-id" + it[organizationRole] = "READER" + it[organizationId] = dbExtension.fixtures.organization.id + } + } + + val keycloakClient = createKeycloakClientForGroups(emptyMap()) + val assignments = mutableListOf() + val dbAuthorizationService = createDbAuthorizationServiceMock(assignments) + + val authorizationService = createService( + keycloakClient = keycloakClient, + dbAuthorizationService = dbAuthorizationService + ) + authorizationService.migrateRolesToDb() shouldBe false + + assignments should beEmpty() + } + } +} + +/** The prefix for group names in Keycloak. */ +private const val GROUP_PREFIX = "namespace_" + +/** + * Create a mock [KeycloakClient] that is prepared to answer requests for the members of groups. The known groups + * and their users are provided by the given [groupsWithUsers]. + */ +private fun createKeycloakClientForGroups(groupsWithUsers: Map>): KeycloakClient = + mockk { + coEvery { getGroupMembers(any()) } answers { + val groupName = firstArg() + groupName shouldStartWith GROUP_PREFIX + groupsWithUsers[groupName.removePrefix(GROUP_PREFIX)]?.mapTo(mutableSetOf()) { userId -> + User( + id = UserId("$userId-id"), + username = UserName(userId) + ) + } ?: throw IllegalArgumentException("Unknown group: $groupName") + } +} + +/** + * A data class to store the properties of a role assignment requested on the authorization service. + */ +private data class RoleAssignment( + /** The subject user ID. */ + val userId: String, + + /** The role that is assigned. */ + val role: Role, + + /** The ID of the hierarchy element. */ + val hierarchyId: CompoundHierarchyId +) + +/** + * Create a mock authorization service that is prepared to store all assignments passed to it in the given + * [assignments] list. + */ +private fun createDbAuthorizationServiceMock(assignments: MutableList): DbAuthorizationService = + mockk { + coEvery { assignRole(any(), any(), any()) } answers { + val assignment = RoleAssignment( + userId = firstArg(), + role = secondArg(), + hierarchyId = thirdArg() + ) + assignments += assignment + } + } diff --git a/components/authorization/backend/build.gradle.kts b/components/authorization/backend/build.gradle.kts index 7eb6e66e02..7d8e22214d 100644 --- a/components/authorization/backend/build.gradle.kts +++ b/components/authorization/backend/build.gradle.kts @@ -32,14 +32,21 @@ dependencies { api(ktorLibs.server.auth) api(ktorLibs.server.auth.jwt) api(ktorLibs.server.core) + api(libs.ktorOpenApi) implementation(projects.dao) + implementation(projects.shared.ktorUtils) implementation(libs.exposedCore) implementation(libs.exposedJdbc) testImplementation(testFixtures(projects.dao)) + testImplementation(ktorLibs.client.contentNegotiation) + testImplementation(ktorLibs.client.core) + testImplementation(ktorLibs.server.statusPages) + testImplementation(ktorLibs.server.testHost) + testImplementation(ktorLibs.utils) testImplementation(libs.kotestAssertionsCore) testImplementation(libs.kotestAssertionsKtor) testImplementation(libs.kotestRunnerJunit5) diff --git a/components/authorization/backend/src/main/kotlin/rights/EffectiveRole.kt b/components/authorization/backend/src/main/kotlin/rights/EffectiveRole.kt index 6a6de4dec0..63e6cfab7e 100644 --- a/components/authorization/backend/src/main/kotlin/rights/EffectiveRole.kt +++ b/components/authorization/backend/src/main/kotlin/rights/EffectiveRole.kt @@ -28,12 +28,35 @@ import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId * provided here, client code can check whether the user has the required permissions to perform the requested action. */ interface EffectiveRole { + companion object { + /** + * A special instance of [EffectiveRole] that does not contain any permissions and is not associated with any + * specific hierarchy element. + */ + val EMPTY: EffectiveRole = object : EffectiveRole { + override val elementId: CompoundHierarchyId = CompoundHierarchyId.WILDCARD + + override val isSuperuser: Boolean = false + + override fun hasOrganizationPermission(permission: OrganizationPermission): Boolean = false + + override fun hasProductPermission(permission: ProductPermission): Boolean = false + + override fun hasRepositoryPermission(permission: RepositoryPermission): Boolean = false + } + } + /** * The compound ID of the hierarchy element this effective role applies to. This object contains the aggregated * permissions of the current user for this element. */ val elementId: CompoundHierarchyId + /** + * A flag indicating whether the associated user has superuser rights. + */ + val isSuperuser: Boolean + /** * Check whether this effective role grants the given [permission] on the organization level. */ diff --git a/components/authorization/backend/src/main/kotlin/routes/AuthorizationChecker.kt b/components/authorization/backend/src/main/kotlin/routes/AuthorizationChecker.kt new file mode 100644 index 0000000000..490686cfb5 --- /dev/null +++ b/components/authorization/backend/src/main/kotlin/routes/AuthorizationChecker.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.authorization.routes + +import io.ktor.server.application.ApplicationCall + +import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationPermission +import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductPermission +import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId +import org.eclipse.apoapsis.ortserver.model.OrganizationId +import org.eclipse.apoapsis.ortserver.model.ProductId +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter + +/** + * An interface defining a mechanism to check for required permissions using an [AuthorizationService] instance. + * + * The idea behind this interface is that a concrete implementation is responsible for doing a concrete authorization + * check, such as testing for the presence of a specific permission on an element of the hierarchy. To do this, the + * instance needs to load the permissions on this element from the service and then test whether the affected + * permission is contained. + * + * Implementations of this interface can be passed into special routing functions that use them to perform + * authorization checks automatically. There are convenience functions to create default instances easily. + * + * In addition to the functions defined here, concrete implementations should provide a meaningful `toString()` + * implementation, since this is used to construct a routes selector internally. + */ +interface AuthorizationChecker { + /** + * Use the provided [service] to load the [EffectiveRole] of the user with the given [userId] for the current + * [call]. A typical implementation will figure out the ID of an element in the hierarchy (organization, product, + * or repository) based on current call parameters. Then it can invoke the [service] to query the permissions on + * this element. + */ + suspend fun loadEffectiveRole(service: AuthorizationService, userId: String, call: ApplicationCall): EffectiveRole + + /** + * Check whether the given [effectiveRole] contains the permission(s) required by this [AuthorizationChecker]. + * This function is called with the [EffectiveRole] that was loaded via [loadEffectiveRole]. + */ + fun checkAuthorization(effectiveRole: EffectiveRole): Boolean +} + +/** The name of the request parameter referring to the organization ID. */ +private const val ORGANIZATION_ID_PARAM = "organizationId" + +/** The name of the request parameter referring to the product ID. */ +private const val PRODUCT_ID_PARAM = "productId" + +/** The name of the request parameter referring to the repository ID. */ +private const val REPOSITORY_ID_PARAM = "repositoryId" + +/** + * Create an [AuthorizationChecker] that checks for the presence of the given organization-level [permission]. + */ +fun requirePermission(permission: OrganizationPermission): AuthorizationChecker = + object : AuthorizationChecker { + override suspend fun loadEffectiveRole( + service: AuthorizationService, + userId: String, + call: ApplicationCall + ): EffectiveRole = + service.getEffectiveRole(userId, OrganizationId(call.requireIdParameter(ORGANIZATION_ID_PARAM))) + + override fun checkAuthorization(effectiveRole: EffectiveRole): Boolean = + effectiveRole.hasOrganizationPermission(permission) + + override fun toString(): String = "RequireOrganizationPermission($permission)" + } + +/** + * Create an [AuthorizationChecker] that checks for the presence of the given product-level [permission]. + */ +fun requirePermission(permission: ProductPermission): AuthorizationChecker = + object : AuthorizationChecker { + override suspend fun loadEffectiveRole( + service: AuthorizationService, + userId: String, + call: ApplicationCall + ): EffectiveRole = + service.getEffectiveRole(userId, ProductId(call.requireIdParameter(PRODUCT_ID_PARAM))) + + override fun checkAuthorization(effectiveRole: EffectiveRole): Boolean = + effectiveRole.hasProductPermission(permission) + + override fun toString(): String = "RequireProductPermission($permission)" + } + +/** + * Create an [AuthorizationChecker] that checks for the presence of the given repository-level [permission]. + */ +fun requirePermission(permission: RepositoryPermission): AuthorizationChecker = + object : AuthorizationChecker { + override suspend fun loadEffectiveRole( + service: AuthorizationService, + userId: String, + call: ApplicationCall + ): EffectiveRole = + service.getEffectiveRole(userId, RepositoryId(call.requireIdParameter(REPOSITORY_ID_PARAM))) + + override fun checkAuthorization(effectiveRole: EffectiveRole): Boolean = + effectiveRole.hasRepositoryPermission(permission) + + override fun toString(): String = "RequireRepositoryPermission($permission)" + } + +/** + * Create an [AuthorizationChecker] that checks whether the user is a superuser. + */ +fun requireSuperuser(): AuthorizationChecker = + object : AuthorizationChecker { + override suspend fun loadEffectiveRole( + service: AuthorizationService, + userId: String, + call: ApplicationCall + ): EffectiveRole = + service.getEffectiveRole(userId, CompoundHierarchyId.WILDCARD) + + override fun checkAuthorization(effectiveRole: EffectiveRole): Boolean = + effectiveRole.isSuperuser + + override fun toString(): String = "RequireSuperuser" + } diff --git a/components/authorization/backend/src/main/kotlin/routes/AuthorizedRoutes.kt b/components/authorization/backend/src/main/kotlin/routes/AuthorizedRoutes.kt new file mode 100644 index 0000000000..b31ae7a923 --- /dev/null +++ b/components/authorization/backend/src/main/kotlin/routes/AuthorizedRoutes.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.authorization.routes + +import com.auth0.jwt.interfaces.Payload + +import io.github.smiley4.ktoropenapi.config.RouteConfig +import io.github.smiley4.ktoropenapi.delete +import io.github.smiley4.ktoropenapi.get +import io.github.smiley4.ktoropenapi.patch +import io.github.smiley4.ktoropenapi.post +import io.github.smiley4.ktoropenapi.put + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.principal +import io.ktor.server.routing.Route +import io.ktor.server.routing.RouteSelector +import io.ktor.server.routing.RouteSelectorEvaluation +import io.ktor.server.routing.RoutingContext +import io.ktor.server.routing.RoutingPipelineCall +import io.ktor.server.routing.RoutingResolveContext +import io.ktor.util.AttributeKey + +import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole +import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService + +/** + * Create an [OrtServerPrincipal] for this [ApplicationCall]. If an [AuthorizationChecker] is present in the current + * context, use it to an [EffectiveRole] and perform an authorization check. Result is *null* if this check fails. + */ +suspend fun ApplicationCall.createAuthorizedPrincipal( + authorizationService: AuthorizationService, + payload: Payload +): OrtServerPrincipal? = + (this as? RoutingPipelineCall)?.let { routingCall -> + val checker = routingCall.route.findAuthorizationChecker() + + val effectiveRole = checker?.loadEffectiveRole( + service = authorizationService, + userId = payload.getClaim("preferred_username").asString(), + call = this + ) ?: EffectiveRole.EMPTY + + OrtServerPrincipal.create(payload, effectiveRole) + } + +/** + * Create a new [Route] for HTTP GET requests that performs an automatic authorization check using the given [checker]. + */ +fun Route.get( + builder: RouteConfig.() -> Unit, + checker: AuthorizationChecker, + body: suspend RoutingContext.() -> Unit +): Route = documentedAuthorized(checker, body) { get(builder, it) } + +/** + * Create a new [Route] for HTTP POST requests that performs an automatic authorization check using the given [checker]. + */ +fun Route.post( + builder: RouteConfig.() -> Unit, + checker: AuthorizationChecker, + body: suspend RoutingContext.() -> Unit +): Route = documentedAuthorized(checker, body) { post(builder, it) } + +/** + * Create a new [Route] for HTTP PATCH requests that performs an automatic authorization check using the given + * [checker]. + */ +fun Route.patch( + builder: RouteConfig.() -> Unit, + checker: AuthorizationChecker, + body: suspend RoutingContext.() -> Unit +): Route = documentedAuthorized(checker, body) { patch(builder, it) } + +/** + * Create a new [Route] for HTTP PUT requests that performs an automatic authorization check using the given + * [checker]. + */ +fun Route.put( + builder: RouteConfig.() -> Unit, + checker: AuthorizationChecker, + body: suspend RoutingContext.() -> Unit +): Route = documentedAuthorized(checker, body) { put(builder, it) } + +/** + * Create a new [Route] for HTTP DELETE requests that performs an automatic authorization check using the given + * [checker]. + */ +fun Route.delete( + builder: RouteConfig.() -> Unit, + checker: AuthorizationChecker, + body: suspend RoutingContext.() -> Unit +): Route = documentedAuthorized(checker, body) { delete(builder, it) } + +/** + * Generic function to create a new [Route] that performs an automatic authorization check using the given [checker]. + * The content of the route is defined by the given original [body] and the [build] function. + */ +private fun Route.documentedAuthorized( + checker: AuthorizationChecker, + body: suspend RoutingContext.() -> Unit, + build: Route.(suspend RoutingContext.() -> Unit) -> Unit +): Route { + val authorizedRoute = createChild(authorizedRouteSelector(checker.toString())) + authorizedRoute.attributes.put(AuthorizationCheckerKey, checker) + + val authorizedBody: suspend RoutingContext.() -> Unit = { + val principal = call.principal() ?: throw AuthorizationException() + + if (!checker.checkAuthorization(principal.effectiveRole)) { + throw AuthorizationException() + } + + body() + } + + authorizedRoute.build(authorizedBody) + return authorizedRoute +} + +/** + * Create a [RouteSelector] for a new authorized [Route] whose string representation is derived from the given [tag]. + */ +private fun authorizedRouteSelector(tag: String): RouteSelector = + object : RouteSelector() { + override suspend fun evaluate( + context: RoutingResolveContext, + segmentIndex: Int + ): RouteSelectorEvaluation = RouteSelectorEvaluation.Transparent + + override fun toString(): String { + return "(authorized $tag)" + } + } + +/** + * Search for an [AuthorizationChecker] object in the context of the current [Route]. The checker has been defined + * using the routes DSL. It may be available in this route or in any of its parent routes. + */ +private fun Route.findAuthorizationChecker(): AuthorizationChecker? = + this.attributes.getOrNull(AuthorizationCheckerKey) + ?: parent?.findAuthorizationChecker() + +/** + * Constant for a key in the attributes of a [Route] to store an [AuthorizationChecker]. + */ +private val AuthorizationCheckerKey = AttributeKey("AuthorizationCheckerKey") + +/** + * An object defining constants for the names of supported authentication providers. + */ +object AuthenticationProviders { + /** + * The name of the authentication provider for authorization based on JWT tokens. + */ + const val TOKEN_PROVIDER = "token" +} + +/** + * An exception class to indicate a failed authorization check. Such exceptions are thrown by the route functions when + * the current user does not have the required permissions. They are mapped to HTTP 403 Forbidden responses. + */ +class AuthorizationException : RuntimeException() diff --git a/components/authorization/backend/src/main/kotlin/routes/OrtServerPrincipal.kt b/components/authorization/backend/src/main/kotlin/routes/OrtServerPrincipal.kt new file mode 100644 index 0000000000..62f80b37b7 --- /dev/null +++ b/components/authorization/backend/src/main/kotlin/routes/OrtServerPrincipal.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.authorization.routes + +import com.auth0.jwt.interfaces.Payload + +import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole + +/** + * A class storing information about the authenticated principal in the ORT Server. + */ +class OrtServerPrincipal( + /** The internal ID of the user. */ + val userId: String, + + /** The username of the principal.*/ + val username: String, + + /** The full name of the principal. */ + val fullName: String, + + /** The effective role computed for the principal. */ + val effectiveRole: EffectiveRole +) { + companion object { + /** Constant for the name of the claim containing the username. */ + private const val CLAIM_USERNAME = "preferred_username" + + /** Constant for the name of the claim containing the full name. */ + private const val CLAIM_FULL_NAME = "name" + + /** + * Create an [OrtServerPrincipal] from the given JWT [payload] and [effectiveRole]. + */ + fun create(payload: Payload, effectiveRole: EffectiveRole): OrtServerPrincipal = + OrtServerPrincipal( + userId = payload.subject, + username = payload.getClaim(CLAIM_USERNAME).asString(), + fullName = payload.getClaim(CLAIM_FULL_NAME).asString(), + effectiveRole = effectiveRole + ) + } +} diff --git a/components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt b/components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt index 8ba1ebe7f8..7dd95061ff 100644 --- a/components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt +++ b/components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt @@ -67,10 +67,20 @@ class DbAuthorizationService( userId: String, compoundHierarchyId: CompoundHierarchyId ): EffectiveRole { + val roleAssignments = loadAssignments(userId, compoundHierarchyId) + val permissions = roleAssignments.map { it.toHierarchyPermissions() } + .takeUnless { it.isEmpty() }?.reduce(::reducePermissions) ?: EMPTY_PERMISSIONS + val isSuperuser = roleAssignments.any { + it[RoleAssignmentsTable.organizationId] == null && + it[RoleAssignmentsTable.productId] == null && + it[RoleAssignmentsTable.repositoryId] == null && + it.extractRole() == OrganizationRole.ADMIN + } + return EffectiveRoleImpl( elementId = compoundHierarchyId, - permissions = loadAssignments(userId, compoundHierarchyId) - .takeUnless { it.isEmpty() }?.reduce(::reducePermissions) ?: EMPTY_PERMISSIONS + isSuperuser = isSuperuser, + permissions = permissions ) } @@ -85,6 +95,7 @@ class DbAuthorizationService( EffectiveRoleImpl( elementId = compoundHierarchyId, + isSuperuser = false, permissions = EMPTY_PERMISSIONS ) } else { @@ -200,7 +211,7 @@ class DbAuthorizationService( private suspend fun loadAssignments( userId: String, compoundHierarchyId: CompoundHierarchyId - ): List = db.dbQuery { + ): List = db.dbQuery { RoleAssignmentsTable.selectAll() .where { (RoleAssignmentsTable.userId eq userId) and ( @@ -208,7 +219,7 @@ class DbAuthorizationService( productWildcardCondition(compoundHierarchyId) or organizationWildcardCondition() ) - }.map { it.toHierarchyPermissions() } + }.toList() } /** @@ -265,6 +276,8 @@ private val EMPTY_PERMISSIONS = HierarchyPermissions( private class EffectiveRoleImpl( override val elementId: CompoundHierarchyId, + override val isSuperuser: Boolean, + /** The permissions granted on the different levels of the hierarchy. */ private val permissions: HierarchyPermissions ) : EffectiveRole { @@ -330,11 +343,12 @@ private fun reducePermissions( * assignments on higher levels in the same hierarchy. */ private fun SqlExpressionBuilder.repositoryCondition(hierarchyId: CompoundHierarchyId): Op = - (RoleAssignmentsTable.repositoryId eq hierarchyId.repositoryId?.value) or ( - (RoleAssignmentsTable.repositoryId eq null) and + ( + (RoleAssignmentsTable.repositoryId eq hierarchyId.repositoryId?.value) or + (RoleAssignmentsTable.repositoryId eq null) + ) and (RoleAssignmentsTable.productId eq hierarchyId.productId?.value) and (RoleAssignmentsTable.organizationId eq hierarchyId.organizationId?.value) - ) /** * Generate the SQL condition to match role assignments for the given [hierarchyId] for which no product ID is diff --git a/components/authorization/backend/src/test/kotlin/routes/AuthorizedRoutesTest.kt b/components/authorization/backend/src/test/kotlin/routes/AuthorizedRoutesTest.kt new file mode 100644 index 0000000000..cd43cba278 --- /dev/null +++ b/components/authorization/backend/src/test/kotlin/routes/AuthorizedRoutesTest.kt @@ -0,0 +1,521 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.authorization.routes + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm + +import io.github.smiley4.ktoropenapi.config.RouteConfig +import io.github.smiley4.ktoropenapi.get + +import io.kotest.core.spec.style.WordSpec +import io.kotest.inspectors.forAll +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.jwt.jwt +import io.ktor.server.auth.principal +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +import java.util.Date + +import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationPermission +import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductPermission +import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId +import org.eclipse.apoapsis.ortserver.model.OrganizationId +import org.eclipse.apoapsis.ortserver.model.ProductId +import org.eclipse.apoapsis.ortserver.model.RepositoryId + +class AuthorizedRoutesTest : WordSpec() { + /** + * Run a test with an authorized route using the given mock [service]. Set up routes using the provided + * [routeBuilder]. Then execute the given [test] function with a properly configured HTTP client. + */ + private fun runAuthorizationTest( + service: AuthorizationService, + routeBuilder: Route.() -> Unit, + test: suspend (HttpClient) -> Unit + ) { + testApplication { + application { + install(Authentication) { + jwt(AuthenticationProviders.TOKEN_PROVIDER) { + realm = JWT_REALM + verifier( + JWT + .require(Algorithm.HMAC256(JWT_SECRET)) + .withAudience(JWT_AUDIENCE) + .withIssuer(JWT_ISSUER) + .build() + ) + + validate { credential -> + createAuthorizedPrincipal(service, credential.payload) + } + } + } + + install(StatusPages) { + exception { call, _ -> + call.respond(HttpStatusCode.Forbidden) + } + } + + routing { + authenticate(AuthenticationProviders.TOKEN_PROVIDER, build = routeBuilder) + } + } + + val token = createToken() + client.config { + defaultRequest { + header("Authorization", "Bearer $token") + } + }.use { test(it) } + } + } + + init { + "authorized routes" should { + "support a route without permission requirements" { + runAuthorizationTest( + mockk(), + routeBuilder = { + route("test") { + get(testDocs) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + effectiveRole.elementId shouldBe CompoundHierarchyId.WILDCARD + OrganizationPermission.entries.forAll { permission -> + effectiveRole.hasOrganizationPermission(permission) shouldBe false + } + ProductPermission.entries.forAll { permission -> + effectiveRole.hasProductPermission(permission) shouldBe false + } + RepositoryPermission.entries.forAll { permission -> + effectiveRole.hasRepositoryPermission(permission) shouldBe false + } + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.get("test") + response.status shouldBe HttpStatusCode.OK + } + } + + "support GET with an organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(OrganizationPermission.WRITE_SECRETS) } returns true + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + get(testDocs, requirePermission(OrganizationPermission.WRITE_SECRETS)) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.get("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.OK + } + } + + "support POST with an organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(OrganizationPermission.MANAGE_GROUPS) } returns true + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + post(testDocs, requirePermission(OrganizationPermission.MANAGE_GROUPS)) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.post("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.OK + } + } + + "support PATCH with an organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(OrganizationPermission.CREATE_PRODUCT) } returns true + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + patch(testDocs, requirePermission(OrganizationPermission.CREATE_PRODUCT)) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.patch("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.OK + } + } + + "support PUT with an organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(OrganizationPermission.READ_PRODUCTS) } returns true + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + put(testDocs, requirePermission(OrganizationPermission.READ_PRODUCTS)) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.put("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.OK + } + } + + "support DELETE with an organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(OrganizationPermission.WRITE) } returns true + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + delete(testDocs, requirePermission(OrganizationPermission.WRITE)) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.delete("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.OK + } + } + + "support requests on product level" { + val effectiveRole = mockk { + every { hasProductPermission(ProductPermission.DELETE) } returns true + } + val service = mockk { + coEvery { + getEffectiveRole(USERNAME, ProductId(ID_PARAMETER)) + } returns effectiveRole + } + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{productId}") { + get(testDocs, requirePermission(ProductPermission.DELETE)) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.get("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.OK + + verify { + effectiveRole.hasProductPermission(ProductPermission.DELETE) + } + } + } + + "support requests on repository level" { + val effectiveRole = mockk { + every { hasRepositoryPermission(RepositoryPermission.READ) } returns true + } + val service = mockk { + coEvery { + getEffectiveRole(USERNAME, RepositoryId(ID_PARAMETER)) + } returns effectiveRole + } + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{repositoryId}") { + get(testDocs, requirePermission(RepositoryPermission.READ)) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.get("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.OK + + verify { + effectiveRole.hasRepositoryPermission(RepositoryPermission.READ) + } + } + } + + "support requests that require superuser rights" { + val effectiveRole = mockk { + every { isSuperuser } returns true + } + val service = mockk { + coEvery { + getEffectiveRole(USERNAME, CompoundHierarchyId.WILDCARD) + } returns effectiveRole + } + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + get(testDocs, requireSuperuser()) { + call.principal().shouldNotBeNull { + username shouldBe USERNAME + } + + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.get("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.OK + + verify { + effectiveRole.isSuperuser + } + } + } + } + + "failed authorization checks" should { + "return a 403 response for GET with insufficient organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(any()) } returns false + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + get(testDocs, requirePermission(OrganizationPermission.READ)) { + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.get("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.Forbidden + } + } + + "return a 403 response for POST with insufficient organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(any()) } returns false + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + post(testDocs, requirePermission(OrganizationPermission.CREATE_PRODUCT)) { + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.post("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.Forbidden + } + } + + "return a 403 response for PATCH with insufficient organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(any()) } returns false + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + patch(testDocs, requirePermission(OrganizationPermission.MANAGE_GROUPS)) { + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.patch("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.Forbidden + } + } + + "return a 403 response for PUT with insufficient organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(any()) } returns false + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + put(testDocs, requirePermission(OrganizationPermission.WRITE_SECRETS)) { + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.put("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.Forbidden + } + } + + "return a 403 response for DELETE with insufficient organization permission" { + val effectiveRole = mockk { + every { hasOrganizationPermission(any()) } returns false + } + val service = createServiceForOrganizationRole(effectiveRole) + + runAuthorizationTest( + service, + routeBuilder = { + route("test/{organizationId}") { + delete(testDocs, requirePermission(OrganizationPermission.DELETE)) { + call.respond(HttpStatusCode.OK) + } + } + } + ) { client -> + val response = client.delete("test/$ID_PARAMETER") + response.status shouldBe HttpStatusCode.Forbidden + } + } + } + } +} + +/** A documented route for the test endpoint. */ +private val testDocs: RouteConfig.() -> Unit = { + operationId = "test" +} + +private const val ID_PARAMETER = 42L +private const val USERNAME = "test-user" + +private const val JWT_SECRET = "secret" +private const val JWT_ISSUER = "http://0.0.0.0:8080/" +private const val JWT_AUDIENCE = "test-audience" +private const val JWT_REALM = "Access to 'test'" + +/** + * Create a token for the test user. + */ +private fun createToken(): String = + JWT.create() + .withIssuer(JWT_ISSUER) + .withAudience(JWT_AUDIENCE) + .withSubject("$USERNAME-ID") + .withClaim("preferred_username", USERNAME) + .withClaim("name", "$USERNAME-full-name") + .withExpiresAt(Date(System.currentTimeMillis() + 60000)) + .sign(Algorithm.HMAC256(JWT_SECRET)) + +/** + * Create a mock [AuthorizationService] that returns the given [effectiveRole] when asked for permissions of the test + * user in the test organization. + */ +private fun createServiceForOrganizationRole(effectiveRole: EffectiveRole): AuthorizationService = + mockk { + coEvery { + getEffectiveRole(USERNAME, OrganizationId(ID_PARAMETER)) + } returns effectiveRole +} diff --git a/components/authorization/backend/src/test/kotlin/routes/OrtServerPrincipalTest.kt b/components/authorization/backend/src/test/kotlin/routes/OrtServerPrincipalTest.kt new file mode 100644 index 0000000000..237d765372 --- /dev/null +++ b/components/authorization/backend/src/test/kotlin/routes/OrtServerPrincipalTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.authorization.routes + +import com.auth0.jwt.interfaces.Payload + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +import io.mockk.every +import io.mockk.mockk + +import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole + +class OrtServerPrincipalTest : StringSpec({ + "An instance should be created correctly from a JWT payload" { + val userId = "0x93847-973498-734987" + val username = "jdoe" + val fullName = "John Doe" + val payload = mockk { + every { subject } returns userId + every { getClaim("preferred_username").asString() } returns username + every { getClaim("name").asString() } returns fullName + } + val effectiveRole = mockk() + + val principal = OrtServerPrincipal.create(payload, effectiveRole) + + principal.userId shouldBe userId + principal.username shouldBe username + principal.fullName shouldBe fullName + principal.effectiveRole shouldBe effectiveRole + } +}) diff --git a/components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt b/components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt index 783381cc48..56f0796b1f 100644 --- a/components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt +++ b/components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt @@ -278,7 +278,26 @@ class DbAuthorizationServiceTest : WordSpec() { val effectiveRole = service.getEffectiveRole(USER_ID, repositoryCompoundId) - checkPermissions(effectiveRole, OrganizationRole.ADMIN) + checkPermissions(effectiveRole, OrganizationRole.ADMIN, expectedSuperuser = true) + } + + "allow querying super users only" { + val normalUser = "normal-user" + createAssignment( + organizationRole = OrganizationRole.ADMIN + ) + createAssignment( + userId = normalUser, + organizationId = dbExtension.fixtures.organization.id, + organizationRole = OrganizationRole.READER + ) + val service = createService() + + val effectiveRoleNormal = service.getEffectiveRole(normalUser, CompoundHierarchyId.WILDCARD) + val effectiveRoleSuper = service.getEffectiveRole(USER_ID, CompoundHierarchyId.WILDCARD) + + checkPermissions(effectiveRoleNormal) + checkPermissions(effectiveRoleSuper, OrganizationRole.ADMIN, expectedSuperuser = true) } "not fail for invalid role names" { @@ -332,7 +351,7 @@ class DbAuthorizationServiceTest : WordSpec() { checkPermissions(effectiveRole, ProductRole.WRITER) val effectiveRoleOrg = service.getEffectiveRole(USER_ID, productCompoundId.parent!!) - checkPermissions(effectiveRoleOrg, ProductRole.WRITER) + checkPermissions(effectiveRoleOrg) } "create a new role assignment on organization level" { @@ -354,7 +373,7 @@ class DbAuthorizationServiceTest : WordSpec() { checkPermissions(effectiveRoleRepo, OrganizationRole.WRITER) } - "create a new role assignment for the WILDCARD ID" { + "create a new superuser role assignment" { val service = createService() service.assignRole( @@ -364,7 +383,7 @@ class DbAuthorizationServiceTest : WordSpec() { ) val effectiveRole = service.getEffectiveRole(USER_ID, repositoryCompoundId()) - checkPermissions(effectiveRole, OrganizationRole.ADMIN) + checkPermissions(effectiveRole, OrganizationRole.ADMIN, expectedSuperuser = true) } "replace an already exiting assignment" { @@ -710,13 +729,15 @@ private const val USER_ID = "test-user" /** * Check that the given [effectiveRole] contains exactly the specified [expectedOrganizationPermissions], - * [expectedProductPermissions], and [expectedRepositoryPermissions] on the different hierarchy levels. + * [expectedProductPermissions], and [expectedRepositoryPermissions] on the different hierarchy levels. Also check the + * [superuser][expectedSuperuser] flag. */ private fun checkPermissions( effectiveRole: EffectiveRole, expectedOrganizationPermissions: Set = emptySet(), expectedProductPermissions: Set = emptySet(), - expectedRepositoryPermissions: Set = emptySet() + expectedRepositoryPermissions: Set = emptySet(), + expectedSuperuser: Boolean = false ) { OrganizationPermission.entries.forAll { effectiveRole.hasOrganizationPermission(it) shouldBe (it in expectedOrganizationPermissions) @@ -727,15 +748,19 @@ private fun checkPermissions( RepositoryPermission.entries.forAll { effectiveRole.hasRepositoryPermission(it) shouldBe (it in expectedRepositoryPermissions) } + + effectiveRole.isSuperuser shouldBe expectedSuperuser } /** - * Check that the given [effectiveRole] contains exactly the permissions as defined by the given [expectedRole]. + * Check that the given [effectiveRole] contains exactly the permissions as defined by the given [expectedRole]. Also + * check the [superuser][expectedSuperuser] flag. */ -private fun checkPermissions(effectiveRole: EffectiveRole, expectedRole: Role) = +private fun checkPermissions(effectiveRole: EffectiveRole, expectedRole: Role, expectedSuperuser: Boolean = false) = checkPermissions( effectiveRole, expectedRole.organizationPermissions, expectedRole.productPermissions, - expectedRole.repositoryPermissions + expectedRole.repositoryPermissions, + expectedSuperuser ) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9a6b0f1cdd..c719d5b7fa 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { requireCapability("$group:routes:$version") } } + implementation(projects.components.authorization.backend) implementation(projects.components.authorizationKeycloak.backend) implementation(projects.components.infrastructureServices.backend) implementation(projects.components.infrastructureServices.backend) { diff --git a/core/src/main/kotlin/di/Module.kt b/core/src/main/kotlin/di/Module.kt index 6cf8df9d98..a2e9ce072c 100644 --- a/core/src/main/kotlin/di/Module.kt +++ b/core/src/main/kotlin/di/Module.kt @@ -31,6 +31,8 @@ import org.eclipse.apoapsis.ortserver.clients.keycloak.KeycloakClient import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.service.AuthorizationService import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.service.KeycloakAuthorizationService import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.service.UserService +import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService as DbBasedAuthorizationService +import org.eclipse.apoapsis.ortserver.components.authorization.service.DbAuthorizationService import org.eclipse.apoapsis.ortserver.components.infrastructureservices.InfrastructureServiceService import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginEventStore import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginService @@ -194,9 +196,10 @@ fun ortServerModule(config: ApplicationConfig, db: Database?, authorizationServi if (authorizationService != null) { single { authorizationService } } else { + single { DbAuthorizationService(get()) } single { val keycloakGroupPrefix = get().tryGetString("keycloak.groupPrefix").orEmpty() - KeycloakAuthorizationService(get(), get(), get(), get(), get(), keycloakGroupPrefix) + KeycloakAuthorizationService(get(), get(), get(), get(), get(), keycloakGroupPrefix, get()) } } diff --git a/core/src/main/kotlin/plugins/Lifecycle.kt b/core/src/main/kotlin/plugins/Lifecycle.kt index 9a620d9b28..165e9fd70b 100644 --- a/core/src/main/kotlin/plugins/Lifecycle.kt +++ b/core/src/main/kotlin/plugins/Lifecycle.kt @@ -48,19 +48,21 @@ fun Application.configureLifecycle() { thread { MDC.setContextMap(mdcContext) runBlocking(Dispatchers.IO) { - syncRoles(authorizationService) + migrateRoles(authorizationService) } } } } /** - * Trigger the synchronization of permissions and roles in Keycloak. The synchronization then runs in the background. + * Perform a migration to new database-based structures for access rights if necessary. This makes sure that the + * new structures are populated once when switching from access rights stored in Keycloak to the new storage in the + * database. The migration then runs in the background. */ -private suspend fun syncRoles(authorizationService: AuthorizationService) { +private suspend fun migrateRoles(authorizationService: AuthorizationService) { withMdcContext("component" to "core") { launch { - authorizationService.ensureSuperuserAndSynchronizeRolesAndPermissions() + authorizationService.migrateRolesToDb() } } } diff --git a/core/src/test/kotlin/api/DownloadsRouteIntegrationTest.kt b/core/src/test/kotlin/api/DownloadsRouteIntegrationTest.kt index 9f8099fe58..eb3d2498a5 100644 --- a/core/src/test/kotlin/api/DownloadsRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/DownloadsRouteIntegrationTest.kt @@ -30,6 +30,8 @@ import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.http.HttpStatusCode +import io.mockk.mockk + import kotlin.time.Duration.Companion.minutes import kotlinx.datetime.Clock @@ -57,7 +59,8 @@ class DownloadsRouteIntegrationTest : AbstractIntegrationTest({ dbExtension.fixtures.organizationRepository, dbExtension.fixtures.productRepository, dbExtension.fixtures.repositoryRepository, - keycloakGroupPrefix = "" + keycloakGroupPrefix = "", + mockk() ) val organizationService = OrganizationService( diff --git a/core/src/test/kotlin/api/OrganizationsRouteIntegrationTest.kt b/core/src/test/kotlin/api/OrganizationsRouteIntegrationTest.kt index 74965b6407..df467e2080 100644 --- a/core/src/test/kotlin/api/OrganizationsRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/OrganizationsRouteIntegrationTest.kt @@ -49,6 +49,8 @@ import io.ktor.client.request.setBody import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.mockk.mockk + import kotlinx.datetime.Clock import org.eclipse.apoapsis.ortserver.api.v1.mapping.mapToApi @@ -127,7 +129,8 @@ class OrganizationsRouteIntegrationTest : AbstractIntegrationTest({ dbExtension.fixtures.organizationRepository, dbExtension.fixtures.productRepository, dbExtension.fixtures.repositoryRepository, - keycloakGroupPrefix = "" + keycloakGroupPrefix = "", + mockk() ) organizationService = OrganizationService( diff --git a/core/src/test/kotlin/api/ProductsRouteIntegrationTest.kt b/core/src/test/kotlin/api/ProductsRouteIntegrationTest.kt index 21bd04bc9d..479b038333 100644 --- a/core/src/test/kotlin/api/ProductsRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/ProductsRouteIntegrationTest.kt @@ -49,6 +49,8 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.mockk.mockk + import kotlinx.datetime.Clock import org.eclipse.apoapsis.ortserver.api.v1.mapping.mapToApi @@ -144,7 +146,8 @@ class ProductsRouteIntegrationTest : AbstractIntegrationTest({ dbExtension.fixtures.organizationRepository, dbExtension.fixtures.productRepository, dbExtension.fixtures.repositoryRepository, - keycloakGroupPrefix = "" + keycloakGroupPrefix = "", + mockk() ) organizationService = OrganizationService( diff --git a/core/src/test/kotlin/api/RepositoriesRouteIntegrationTest.kt b/core/src/test/kotlin/api/RepositoriesRouteIntegrationTest.kt index 26ac0f1730..9ffed40766 100644 --- a/core/src/test/kotlin/api/RepositoriesRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/RepositoriesRouteIntegrationTest.kt @@ -46,6 +46,8 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.mockk.mockk + import java.util.EnumSet import kotlinx.coroutines.Dispatchers @@ -134,7 +136,8 @@ class RepositoriesRouteIntegrationTest : AbstractIntegrationTest({ dbExtension.fixtures.organizationRepository, dbExtension.fixtures.productRepository, dbExtension.fixtures.repositoryRepository, - keycloakGroupPrefix = "" + keycloakGroupPrefix = "", + mockk() ) val organizationService = OrganizationService( diff --git a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt index 125ff30beb..449c7f681a 100644 --- a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt @@ -50,6 +50,8 @@ import io.ktor.http.isSuccess import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.jvm.javaio.copyTo +import io.mockk.mockk + import java.io.File import java.io.IOException import java.util.EnumSet @@ -156,7 +158,8 @@ class RunsRouteIntegrationTest : AbstractIntegrationTest({ dbExtension.fixtures.organizationRepository, dbExtension.fixtures.productRepository, dbExtension.fixtures.repositoryRepository, - keycloakGroupPrefix = "" + keycloakGroupPrefix = "", + mockk() ) organizationService = OrganizationService( diff --git a/shared/ktor-utils/src/testFixtures/kotlin/AbstractAuthorizationTest.kt b/shared/ktor-utils/src/testFixtures/kotlin/AbstractAuthorizationTest.kt index a05beac71c..2212583828 100644 --- a/shared/ktor-utils/src/testFixtures/kotlin/AbstractAuthorizationTest.kt +++ b/shared/ktor-utils/src/testFixtures/kotlin/AbstractAuthorizationTest.kt @@ -40,6 +40,8 @@ import io.ktor.server.routing.routing import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication +import io.mockk.mockk + import kotlinx.serialization.json.Json import org.eclipse.apoapsis.ortserver.clients.keycloak.DefaultKeycloakClient.Companion.configureAuthentication @@ -115,7 +117,8 @@ abstract class AbstractAuthorizationTest(body: AbstractAuthorizationTest.() -> U dbExtension.fixtures.organizationRepository, dbExtension.fixtures.productRepository, dbExtension.fixtures.repositoryRepository, - keycloakGroupPrefix = "" + keycloakGroupPrefix = "", + mockk() ) authorizationService.ensureSuperuserAndSynchronizeRolesAndPermissions()