diff --git a/components/authorization/backend/src/main/kotlin/rights/HierarchyPermissions.kt b/components/authorization/backend/src/main/kotlin/rights/HierarchyPermissions.kt new file mode 100644 index 0000000000..9d003ab630 --- /dev/null +++ b/components/authorization/backend/src/main/kotlin/rights/HierarchyPermissions.kt @@ -0,0 +1,266 @@ +/* + * 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.rights + +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId + +/** + * A class that encapsulates a number of permissions to check on hierarchy elements. + */ +class PermissionChecker( + /** The required permissions on organization level. */ + val organizationPermissions: Set = emptySet(), + + /** The required permissions on product level. */ + val productPermissions: Set = emptySet(), + + /** The required permissions on repository level. */ + val repositoryPermissions: Set = emptySet() +) { + /** + * Check whether the given [role] contains all permissions required by this [PermissionChecker]. + */ + operator fun invoke(role: Role): Boolean = + organizationPermissions.all { it in role.organizationPermissions } && + productPermissions.all { it in role.productPermissions } && + repositoryPermissions.all { it in role.repositoryPermissions } +} + +/** + * Alias for a [Map] that groups [CompoundHierarchyId]s by their hierarchy level. The keys correspond to constants + * defined by [CompoundHierarchyId]. + */ +typealias IdsByLevel = Map> + +/** + * A class to manage permissions on different levels of the hierarchy. + * + * This class controls the effect of role assignments to users and how permissions are inherited through the hierarchy. + * It implements the following rules: + * - Role assignments on higher levels in the hierarchy inherit downwards to lower levels. So, if a user is granted + * the `WRITER` role on an organization, they automatically have `WRITER` permissions on all products and + * repositories within that organization. + * - Role assignments on lower levels in the hierarchy can widen the permissions inherited from higher levels, but not + * restrict them. For example, a `WRITER` role assignment for a user on product level could be turned into an `ADMIN` + * role assignment on repository level. However, a `READER` role assignment on repository level would be ignored if + * the user already has `WRITER` permissions on the parent product. + * - Role assignments on lower levels of the hierarchy can trigger implicit permissions on higher levels. So, if a user + * has access to a repository, they implicitly have at least `READER` access to the parent product and organization. + * + * An instance of this class is created for a given set of role assignments and for a specific set of permissions + * controlled by a [PermissionChecker]. The functions of this class can then be used to find out on which hierarchy + * elements these permissions are granted. + */ +interface HierarchyPermissions { + companion object { + /** + * Create a new [HierarchyPermissions] instance for the given collection of role [roleAssignments] that + * evaluates the permissions controlled by the given [checker] function. + */ + fun create( + roleAssignments: Collection>, + checker: PermissionChecker + ): HierarchyPermissions { + val assignmentsByLevel = roleAssignments.groupBy { it.first.level } + + return assignmentsByLevel[CompoundHierarchyId.WILDCARD_LEVEL]?.singleOrNull() + ?.takeIf { it.second == OrganizationRole.ADMIN }?.let { superuserInstance } + ?: createStandardInstance(assignmentsByLevel, checker) + } + + /** + * Return a [PermissionChecker] that checks for the presence of all given organization permissions [ps]. + */ + fun permissions(vararg ps: OrganizationPermission): PermissionChecker = + PermissionChecker(organizationPermissions = ps.toSet()) + + /** + * Return a [PermissionChecker] that checks for the presence of all given product permissions [ps]. + */ + fun permissions(vararg ps: ProductPermission): PermissionChecker = + PermissionChecker(productPermissions = ps.toSet()) + + /** + * Return a [PermissionChecker] that checks for the presence of all given repository permissions [ps]. + */ + fun permissions(vararg ps: RepositoryPermission): PermissionChecker = + PermissionChecker(repositoryPermissions = ps.toSet()) + + /** + * Return a [PermissionChecker] that checks for the presence of all permissions defined by the given [role]. + */ + fun permissions(role: Role): PermissionChecker = + PermissionChecker( + organizationPermissions = role.organizationPermissions, + productPermissions = role.productPermissions, + repositoryPermissions = role.repositoryPermissions + ) + } + + /** + * Check whether the permissions evaluated by this instance are granted on the hierarchy element identified by the + * given [compoundHierarchyId]. + */ + fun hasPermission(compoundHierarchyId: CompoundHierarchyId): Boolean + + /** + * Return a [Map] with the IDs of all hierarchy elements for which a role assignment exists that grants the + * permissions evaluated by this instance. The result is grouped by hierarchy level. This can be used to generate + * filter conditions for database queries selecting elements in the hierarchy. + */ + fun includes(): IdsByLevel + + /** + * Return a [Map] with the IDs of hierarchy elements for which the permissions evaluated by this instance are + * implicitly granted due to role assignments on lower levels in the hierarchy. For instance, if READ access is + * granted on a repository, READ access is also needed on the parent product and organization. Such implicit + * permissions are different from explicitly granted ones, since they do not inherit downwards in the hierarchy. + * The result is grouped by hierarchy level. The resulting IDs do not include those returned by [includes]. When + * constructing database query filters, these IDs need to be included alongside those from [includes]. + */ + fun implicitIncludes(): IdsByLevel + + /** + * Return a flag whether this instance represents superuser permissions. + */ + fun isSuperuser(): Boolean +} + +/** + * A special instance of [HierarchyPermissions] that is returned by [HierarchyPermissions.create] when an assignment + * of superuser permissions is detected. This instance grants all permissions and returns corresponding filters. + */ +private val superuserInstance = object : HierarchyPermissions { + override fun hasPermission(compoundHierarchyId: CompoundHierarchyId): Boolean = true + + override fun includes(): IdsByLevel = + mapOf(CompoundHierarchyId.WILDCARD_LEVEL to listOf(CompoundHierarchyId.WILDCARD)) + + override fun implicitIncludes(): IdsByLevel = emptyMap() + + override fun isSuperuser(): Boolean = true +} + +/** + * Create an instance of [HierarchyPermissions] for standard users based on the given [Map] with + * [assignmentsByLevel] and the [checker] function. + */ +private fun createStandardInstance( + assignmentsByLevel: Map>>, + checker: PermissionChecker +): HierarchyPermissions { + val assignmentsMap = constructAssignmentsMap(assignmentsByLevel, checker) + val implicits = computeImplicitIncludes(assignmentsMap, assignmentsByLevel, checker) + val implicitIds = implicits.values.flatMapTo(mutableSetOf()) { it } + + return object : HierarchyPermissions { + override fun hasPermission(compoundHierarchyId: CompoundHierarchyId): Boolean = + findAssignment(assignmentsMap, compoundHierarchyId) || compoundHierarchyId in implicitIds + + override fun includes(): IdsByLevel = + assignmentsMap.filter { e -> e.value } + .keys + .byLevel() + + override fun implicitIncludes(): IdsByLevel = implicits + + override fun isSuperuser(): Boolean = false + } +} + +/** + * Return the closest permission check result for the given [id] by traversing up the hierarchy if necessary. If no + * assignment is found for the given [id] or any of its parents, assume that the permissions are not present. + */ +private tailrec fun findAssignment( + assignments: Map, + id: CompoundHierarchyId? +): Boolean = + if (id == null) { + false + } else { + assignments[id] ?: findAssignment(assignments, id.parent) + } + +/** + * Construct the [Map] with information about available permissions on different levels in the hierarchy based on + * the given [assignmentsByLevel] and the [checker] function. + */ +private fun constructAssignmentsMap( + assignmentsByLevel: Map>>, + checker: PermissionChecker +): Map = buildMap { + for (level in CompoundHierarchyId.ORGANIZATION_LEVEL..CompoundHierarchyId.REPOSITORY_LEVEL) { + val levelAssignments = assignmentsByLevel[level].orEmpty() + levelAssignments.forEach { (id, role) -> + val isPresent = checker(role) + val isPresentOnParent = findAssignment(this, id.parent) + + // If this assignment does not change the status from a higher level, it can be skipped. + if (isPresent && !isPresentOnParent) { + put(id, true) + } + } + } +} + +/** + * Find the IDs of all hierarchy elements from [assignmentsByLevel] that are granted implicit permissions due to role + * assignments on lower levels in the hierarchy. The given [assignmentsMap] has already been populated with explicit + * role assignments. Use the given [checker] function to determine whether permissions are granted. + */ +private fun computeImplicitIncludes( + assignmentsMap: Map, + assignmentsByLevel: Map>>, + checker: PermissionChecker +): IdsByLevel = buildSet { + for (level in CompoundHierarchyId.PRODUCT_LEVEL..CompoundHierarchyId.REPOSITORY_LEVEL) { + assignmentsByLevel[level].orEmpty().filter { (_, role) -> checker(role) } + .forEach { (id, _) -> + val parents = id.parents() + if (parents.none { it in assignmentsMap }) { + addAll(parents) + } + } + } +}.byLevel() + +/** + * Group the IDs contained in this [Collection] by their hierarchy level. + */ +private fun Collection.byLevel(): IdsByLevel = + this.groupBy { it.level } + +/** + * Return a list with the IDs of all parents of this [CompoundHierarchyId]. + */ +private fun CompoundHierarchyId.parents(): List { + val parents = mutableListOf() + + tailrec fun findParents(id: CompoundHierarchyId?) { + if (id != null) { + parents += id + findParents(id.parent) + } + } + + findParents(parent) + return parents +} diff --git a/components/authorization/backend/src/main/kotlin/rights/OrganizationRole.kt b/components/authorization/backend/src/main/kotlin/rights/OrganizationRole.kt index e590156371..088093567b 100644 --- a/components/authorization/backend/src/main/kotlin/rights/OrganizationRole.kt +++ b/components/authorization/backend/src/main/kotlin/rights/OrganizationRole.kt @@ -22,18 +22,25 @@ package org.eclipse.apoapsis.ortserver.components.authorization.rights /** * This enum contains the available roles for [organizations][org.eclipse.apoapsis.ortserver.model.Organization]. It * maps the permissions available for an organization to the default roles [READER], [WRITER], and [ADMIN]. + * + * Notes: + * - Permissions are inherited down the hierarchy. This is implemented by including the lower level permissions + * of the corresponding roles in the sets of permissions managed by the single instances. + * - The constants are expected to be listed in increasing order of permissions. */ enum class OrganizationRole( override val organizationPermissions: Set, - override val productPermissions: Set = emptySet(), - override val repositoryPermissions: Set = emptySet() + override val productPermissions: Set, + override val repositoryPermissions: Set ) : Role { /** A role that grants read permissions for an [org.eclipse.apoapsis.ortserver.model.Organization]. */ READER( organizationPermissions = setOf( OrganizationPermission.READ, OrganizationPermission.READ_PRODUCTS - ) + ), + productPermissions = ProductRole.READER.productPermissions, + repositoryPermissions = RepositoryRole.READER.repositoryPermissions ), /** A role that grants write permissions for an [org.eclipse.apoapsis.ortserver.model.Organization]. */ @@ -43,7 +50,9 @@ enum class OrganizationRole( OrganizationPermission.WRITE, OrganizationPermission.READ_PRODUCTS, OrganizationPermission.CREATE_PRODUCT - ) + ), + productPermissions = ProductRole.WRITER.productPermissions, + repositoryPermissions = RepositoryRole.WRITER.repositoryPermissions ), /** diff --git a/components/authorization/backend/src/main/kotlin/rights/ProductRole.kt b/components/authorization/backend/src/main/kotlin/rights/ProductRole.kt index de025a4976..3467e7387b 100644 --- a/components/authorization/backend/src/main/kotlin/rights/ProductRole.kt +++ b/components/authorization/backend/src/main/kotlin/rights/ProductRole.kt @@ -22,18 +22,24 @@ package org.eclipse.apoapsis.ortserver.components.authorization.rights /** * This enum contains the available roles for [products][org.eclipse.apoapsis.ortserver.model.Product]. It * maps the permissions available for a product to the default roles [READER], [WRITER], and [ADMIN]. + * + * Notes: + * - Permissions are inherited down the hierarchy. This is implemented by including the lower level permissions + * of the corresponding roles in the sets of permissions managed by the single instances. + * - The constants are expected to be listed in increasing order of permissions. */ enum class ProductRole( override val organizationPermissions: Set = setOf(OrganizationPermission.READ), override val productPermissions: Set, - override val repositoryPermissions: Set = emptySet() + override val repositoryPermissions: Set ) : Role { /** A role that grants read permissions for a [org.eclipse.apoapsis.ortserver.model.Product]. */ READER( productPermissions = setOf( ProductPermission.READ, ProductPermission.READ_REPOSITORIES - ) + ), + repositoryPermissions = RepositoryRole.READER.repositoryPermissions ), /** A role that grants write permissions for a [org.eclipse.apoapsis.ortserver.model.Product]. */ @@ -44,7 +50,8 @@ enum class ProductRole( ProductPermission.READ_REPOSITORIES, ProductPermission.CREATE_REPOSITORY, ProductPermission.TRIGGER_ORT_RUN - ) + ), + repositoryPermissions = RepositoryRole.WRITER.repositoryPermissions ), /** diff --git a/components/authorization/backend/src/main/kotlin/routes/AuthorizationChecker.kt b/components/authorization/backend/src/main/kotlin/routes/AuthorizationChecker.kt index 490686cfb5..c2cf424b83 100644 --- a/components/authorization/backend/src/main/kotlin/routes/AuthorizationChecker.kt +++ b/components/authorization/backend/src/main/kotlin/routes/AuthorizationChecker.kt @@ -22,7 +22,9 @@ 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.HierarchyPermissions import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationPermission +import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationRole 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 @@ -51,15 +53,10 @@ 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. + * this element. If the permission check is successful, return a properly initialized [EffectiveRole] instance. + * Otherwise, return *null*, which cause the request to fail with a 403 error. */ - 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 + suspend fun loadEffectiveRole(service: AuthorizationService, userId: String, call: ApplicationCall): EffectiveRole? } /** The name of the request parameter referring to the organization ID. */ @@ -80,11 +77,12 @@ fun requirePermission(permission: OrganizationPermission): AuthorizationChecker 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) + ): EffectiveRole? = + service.checkPermissions( + userId, + OrganizationId(call.requireIdParameter(ORGANIZATION_ID_PARAM)), + HierarchyPermissions.permissions(permission) + ) override fun toString(): String = "RequireOrganizationPermission($permission)" } @@ -98,11 +96,12 @@ fun requirePermission(permission: ProductPermission): AuthorizationChecker = 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) + ): EffectiveRole? = + service.checkPermissions( + userId, + ProductId(call.requireIdParameter(PRODUCT_ID_PARAM)), + HierarchyPermissions.permissions(permission) + ) override fun toString(): String = "RequireProductPermission($permission)" } @@ -116,11 +115,12 @@ fun requirePermission(permission: RepositoryPermission): AuthorizationChecker = 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) + ): EffectiveRole? = + service.checkPermissions( + userId, + RepositoryId(call.requireIdParameter(REPOSITORY_ID_PARAM)), + HierarchyPermissions.permissions(permission) + ) override fun toString(): String = "RequireRepositoryPermission($permission)" } @@ -134,11 +134,12 @@ fun requireSuperuser(): AuthorizationChecker = service: AuthorizationService, userId: String, call: ApplicationCall - ): EffectiveRole = - service.getEffectiveRole(userId, CompoundHierarchyId.WILDCARD) - - override fun checkAuthorization(effectiveRole: EffectiveRole): Boolean = - effectiveRole.isSuperuser + ): EffectiveRole? = + service.checkPermissions( + userId, + CompoundHierarchyId.WILDCARD, + HierarchyPermissions.permissions(OrganizationRole.ADMIN) + )?.takeIf { it.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 index b31ae7a923..b953850ade 100644 --- a/components/authorization/backend/src/main/kotlin/routes/AuthorizedRoutes.kt +++ b/components/authorization/backend/src/main/kotlin/routes/AuthorizedRoutes.kt @@ -52,11 +52,15 @@ suspend fun ApplicationCall.createAuthorizedPrincipal( (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 + val effectiveRole = if (checker != null) { + checker.loadEffectiveRole( + service = authorizationService, + userId = payload.getClaim("preferred_username").asString(), + call = this + ) + } else { + EffectiveRole.EMPTY + } OrtServerPrincipal.create(payload, effectiveRole) } @@ -122,9 +126,8 @@ private fun Route.documentedAuthorized( authorizedRoute.attributes.put(AuthorizationCheckerKey, checker) val authorizedBody: suspend RoutingContext.() -> Unit = { - val principal = call.principal() ?: throw AuthorizationException() - - if (!checker.checkAuthorization(principal.effectiveRole)) { + // Check whether an authorized principal is available in the call. + if (call.principal()?.isAuthorized != true) { throw AuthorizationException() } diff --git a/components/authorization/backend/src/main/kotlin/routes/OrtServerPrincipal.kt b/components/authorization/backend/src/main/kotlin/routes/OrtServerPrincipal.kt index 62f80b37b7..3585f4b138 100644 --- a/components/authorization/backend/src/main/kotlin/routes/OrtServerPrincipal.kt +++ b/components/authorization/backend/src/main/kotlin/routes/OrtServerPrincipal.kt @@ -36,8 +36,11 @@ class OrtServerPrincipal( /** The full name of the principal. */ val fullName: String, - /** The effective role computed for the principal. */ - val effectiveRole: EffectiveRole + /** + * The effective role computed for the principal. This can be *null* if either no authorization is required or the + * authorization check failed. In the latter case, an exception is thrown when the role is accessed. + */ + private val role: EffectiveRole? ) { companion object { /** Constant for the name of the claim containing the username. */ @@ -49,12 +52,26 @@ class OrtServerPrincipal( /** * Create an [OrtServerPrincipal] from the given JWT [payload] and [effectiveRole]. */ - fun create(payload: Payload, effectiveRole: EffectiveRole): OrtServerPrincipal = + 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 + role = effectiveRole ) } + + /** + * A flag indicating whether the principal is authorized. If this is *true*, the effective role of the principal + * can be accessed via [effectiveRole]. + */ + val isAuthorized: Boolean + get() = role != null + + /** + * The effective role of the principal if authorization was successful. Otherwise, accessing this property throws + * an [AuthorizationException]. + */ + val effectiveRole: EffectiveRole + get() = role ?: throw AuthorizationException() } diff --git a/components/authorization/backend/src/main/kotlin/service/AuthorizationService.kt b/components/authorization/backend/src/main/kotlin/service/AuthorizationService.kt index 5cc49b04b3..a9f161f516 100644 --- a/components/authorization/backend/src/main/kotlin/service/AuthorizationService.kt +++ b/components/authorization/backend/src/main/kotlin/service/AuthorizationService.kt @@ -20,15 +20,43 @@ package org.eclipse.apoapsis.ortserver.components.authorization.service 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.PermissionChecker +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.rights.Role import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId import org.eclipse.apoapsis.ortserver.model.HierarchyId +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter /** * A service interface providing functionality to query and manage roles and permissions for users on entities in the * hierarchy. */ interface AuthorizationService { + /** + * Check whether the user identified by [userId] has the permissions defined by the given [checker] on the element + * identified by [compoundHierarchyId]. If the check is successful, the function returns an [EffectiveRole] object + * with the requested permissions and the [compoundHierarchyId]; otherwise, it returns *null*. + */ + suspend fun checkPermissions( + userId: String, + compoundHierarchyId: CompoundHierarchyId, + checker: PermissionChecker + ): EffectiveRole? + + /** + * Check whether the user identified by [userId] has the permissions defined by the given [checker] on the element + * identified by [hierarchyId]. This function constructs a [CompoundHierarchyId] based on the passed in + * [hierarchyId]. If such a [CompoundHierarchyId] is already known, using the overloaded function is more + * efficient. + */ + suspend fun checkPermissions( + userId: String, + hierarchyId: HierarchyId, + checker: PermissionChecker + ): EffectiveRole? + /** * Return an [EffectiveRole] object for the specified [userId] that contains all permissions for this user on the * element identified by [compoundHierarchyId]. @@ -70,4 +98,43 @@ interface AuthorizationService { * higher levels, such as organization admins. */ suspend fun listUsers(compoundHierarchyId: CompoundHierarchyId): Map> + + /** + * Return a [HierarchyFilter] with information about all hierarchy elements for which the specified [userId] has at + * least all the permissions defined by [organizationPermissions], [productPermissions], and + * [repositoryPermissions]. With the optional [containedIn] ID, the filter can be restricted to elements that + * belong to this root element (directly or indirectly). This function can be used to generate filters for queries + * based on the access rights of a user. For the returned [CompoundHierarchyId]s not necessarily all components + * corresponding to the requested permissions are filled. For instance, if all repositories with READ access are + * requested and the user has the ADMIN role on the product, the result will contain the ID of this product. This + * basically means that all repositories under this product are accessible to the user. + */ + suspend fun filterHierarchyIds( + userId: String, + organizationPermissions: Set = emptySet(), + productPermissions: Set = emptySet(), + repositoryPermissions: Set = emptySet(), + containedIn: HierarchyId? = null + ): HierarchyFilter + + /** + * Return a [HierarchyFilter] with information about all hierarchy elements for which the specified [userId] has at + * least the permissions defined by the given [requiredRole], optionally restricted to the hierarchical structure + * defined by [containedIn]. This is an overload of the function with the same name that obtains the required + * permissions from the passed in role. Note that this does not perform an exact match of the role, but checks + * whether all the permissions defined by the role are granted to the user. So, for instance, when searching for a + * READER role, also WRITER and ADMIN roles will match. + */ + suspend fun filterHierarchyIds( + userId: String, + requiredRole: Role, + containedIn: HierarchyId? = null + ): HierarchyFilter = + filterHierarchyIds( + userId = userId, + organizationPermissions = requiredRole.organizationPermissions, + productPermissions = requiredRole.productPermissions, + repositoryPermissions = requiredRole.repositoryPermissions, + containedIn = containedIn + ) } diff --git a/components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt b/components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt index 7dd95061ff..5d380afe84 100644 --- a/components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt +++ b/components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt @@ -17,12 +17,17 @@ * License-Filename: LICENSE */ +@file:Suppress("TooManyFunctions") + package org.eclipse.apoapsis.ortserver.components.authorization.service import org.eclipse.apoapsis.ortserver.components.authorization.db.RoleAssignmentsTable import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.HierarchyPermissions +import org.eclipse.apoapsis.ortserver.components.authorization.rights.IdsByLevel import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationPermission import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.PermissionChecker import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductPermission import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductRole import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryPermission @@ -36,6 +41,7 @@ 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.util.HierarchyFilter import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.JoinType @@ -63,24 +69,61 @@ class DbAuthorizationService( /** The database to use. */ private val db: Database ) : AuthorizationService { + override suspend fun checkPermissions( + userId: String, + compoundHierarchyId: CompoundHierarchyId, + checker: PermissionChecker + ): EffectiveRole? { + val roleAssignments = loadAssignments(userId, compoundHierarchyId) + + val permissions = HierarchyPermissions.create(roleAssignments, checker) + return if (permissions.hasPermission(compoundHierarchyId)) { + EffectiveRoleImpl( + elementId = compoundHierarchyId, + isSuperuser = permissions.isSuperuser(), + permissions = checker + ) + } else { + null + } + } + + override suspend fun checkPermissions( + userId: String, + hierarchyId: HierarchyId, + checker: PermissionChecker + ): EffectiveRole? { + val compoundHierarchyId = resolveCompoundId(hierarchyId) + + return compoundHierarchyId.takeUnless { it.isInvalid() }?.let { + checkPermissions(userId, it, checker) + } + } + override suspend fun getEffectiveRole( 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( + val roles = rolesForLevel(compoundHierarchyId).reversed().asSequence() + + // Check for all roles on the current level, starting with the ADMIN role, whether all its permissions are + // granted. This is then the effective role. This assumes that constants in the enums for roles are ordered + // by the number of permissions they grant in ascending order. If this changes, there should be failing tests. + return roles.mapNotNull { role -> + val permissionChecker = HierarchyPermissions.permissions(role) + HierarchyPermissions.create(roleAssignments, permissionChecker) + .takeIf { it.hasPermission(compoundHierarchyId) }?.let { + EffectiveRoleImpl( + elementId = compoundHierarchyId, + isSuperuser = it.isSuperuser(), + permissions = permissionChecker + ) + } + }.firstOrNull() ?: EffectiveRoleImpl( elementId = compoundHierarchyId, - isSuperuser = isSuperuser, - permissions = permissions + isSuperuser = false, + permissions = PermissionChecker() ) } @@ -96,7 +139,7 @@ class DbAuthorizationService( EffectiveRoleImpl( elementId = compoundHierarchyId, isSuperuser = false, - permissions = EMPTY_PERMISSIONS + permissions = PermissionChecker() ) } else { getEffectiveRole(userId, compoundHierarchyId) @@ -160,6 +203,54 @@ class DbAuthorizationService( .mapValues { it.value.filterNotNullTo(mutableSetOf()) } } + override suspend fun filterHierarchyIds( + userId: String, + organizationPermissions: Set, + productPermissions: Set, + repositoryPermissions: Set, + containedIn: HierarchyId? + ): HierarchyFilter { + val assignments = loadAssignments(userId, null) + val checker = PermissionChecker( + organizationPermissions, + productPermissions, + repositoryPermissions + ) + val permissions = HierarchyPermissions.create(assignments, checker) + + val containedInId = containedIn?.let { resolveCompoundId(it) } + val includes = includesDominatedByContainsFilter(permissions, containedInId) + ?: permissions.includes().filterContainedIn(containedInId) + + return HierarchyFilter( + transitiveIncludes = includes, + nonTransitiveIncludes = permissions.implicitIncludes().filterContainedIn(containedInId), + isWildcard = permissions.isSuperuser() + ) + } + + /** + * Check if the given [containedInId] filter is contained in any of the includes in the given [permissions]. If so, + * the user can access all elements under the [containedInId] ID, so return a map of includes with just this ID. + * If this is not the case or [containedInId] is undefined, return *null*. + */ + private fun includesDominatedByContainsFilter( + permissions: HierarchyPermissions, + containedInId: CompoundHierarchyId? + ): IdsByLevel? = + containedInId?.let { + val containedInLevel = it.level + val isFilterCovered = permissions.includes().entries.any { (level, ids) -> + level < containedInLevel && ids.any { id -> id.contains(containedInId) } + } + + if (isFilterCovered) { + mapOf(containedInId.level to listOf(containedInId)) + } else { + null + } + } + /** * Retrieve the missing components to construct a [CompoundHierarchyId] from the given [hierarchyId]. Throw a * meaningful exception if this fails. @@ -206,20 +297,26 @@ class DbAuthorizationService( /** * Load all role assignments for the given [userId] in the hierarchy defined by [compoundHierarchyId]. Return a - * list of [HierarchyPermissions] instances for the entities that were found. + * list with pairs of IDs and assigned roles for the entities that were found. The function selects assignments in + * the whole hierarchy below the organization referenced by [compoundHierarchyId] or all assignments of the given + * [userId] if no ID is specified. This makes sure that all relevant assignments are found. */ private suspend fun loadAssignments( userId: String, - compoundHierarchyId: CompoundHierarchyId - ): List = db.dbQuery { + compoundHierarchyId: CompoundHierarchyId? + ): List> = db.dbQuery { RoleAssignmentsTable.selectAll() .where { - (RoleAssignmentsTable.userId eq userId) and ( - repositoryCondition(compoundHierarchyId) or - productWildcardCondition(compoundHierarchyId) or - organizationWildcardCondition() - ) - }.toList() + val hierarchyCondition = compoundHierarchyId?.let { id -> + (RoleAssignmentsTable.organizationId eq id.organizationId?.value) or + (RoleAssignmentsTable.organizationId eq null) + } ?: Op.TRUE + (RoleAssignmentsTable.userId eq userId) and hierarchyCondition + }.mapNotNull { row -> + row.extractRole()?.let { role -> + row.extractHierarchyId() to role + } + } } /** @@ -233,15 +330,15 @@ class DbAuthorizationService( (RoleAssignmentsTable.productId eq compoundHierarchyId.productId?.value) and (RoleAssignmentsTable.repositoryId eq compoundHierarchyId.repositoryId?.value) } == 1 - ).also { - if (it) { - logger.info( - "Removed role assignment for user '{}' on hierarchy element {}.", - userId, - compoundHierarchyId - ) + ).also { + if (it) { + logger.info( + "Removed role assignment for user '{}' on hierarchy element {}.", + userId, + compoundHierarchyId + ) + } } - } } /** @@ -249,27 +346,6 @@ class DbAuthorizationService( */ private const val INVALID_ID = -1L -/** - * An internally used data class to store the available permissions on all levels of the hierarchy for a user. - */ -private data class HierarchyPermissions( - /** The permissions granted on organization level. */ - val organizationPermissions: Set, - - /** The permissions granted on product level. */ - val productPermissions: Set, - - /** The permissions granted on repository level. */ - val repositoryPermissions: Set -) - -/** An instance of [HierarchyPermissions] with no permissions at all. */ -private val EMPTY_PERMISSIONS = HierarchyPermissions( - organizationPermissions = emptySet(), - productPermissions = emptySet(), - repositoryPermissions = emptySet() -) - /** * An implementation of the [EffectiveRole] interface used by [DbAuthorizationService]. */ @@ -279,7 +355,7 @@ private class EffectiveRoleImpl( override val isSuperuser: Boolean, /** The permissions granted on the different levels of the hierarchy. */ - private val permissions: HierarchyPermissions + private val permissions: PermissionChecker ) : EffectiveRole { override fun hasOrganizationPermission(permission: OrganizationPermission): Boolean = permission in permissions.organizationPermissions @@ -313,30 +389,26 @@ private fun ResultRow.extractRole(): Role? = runCatching { }.getOrNull() /** - * Obtain the information about roles from this [ResultRow] and construct a [HierarchyPermissions] object from it. - */ -private fun ResultRow.toHierarchyPermissions(): HierarchyPermissions = - extractRole()?.let { role -> - HierarchyPermissions( - organizationPermissions = role.organizationPermissions, - productPermissions = role.productPermissions, - repositoryPermissions = role.repositoryPermissions - ) - } ?: EMPTY_PERMISSIONS - -/** - * Combine two [HierarchyPermissions] instances [p1] and [p2] by constructing the union of their permissions on all - * levels. + * Extract the [CompoundHierarchyId] on the correct level from this [ResultRow]. */ -private fun reducePermissions( - p1: HierarchyPermissions, - p2: HierarchyPermissions -): HierarchyPermissions = - HierarchyPermissions( - organizationPermissions = p1.organizationPermissions + p2.organizationPermissions, - productPermissions = p1.productPermissions + p2.productPermissions, - repositoryPermissions = p1.repositoryPermissions + p2.repositoryPermissions - ) +private fun ResultRow.extractHierarchyId(): CompoundHierarchyId { + val orgId = this[RoleAssignmentsTable.organizationId]?.let { OrganizationId(it.value) } + if (orgId == null) { + return CompoundHierarchyId.WILDCARD + } else { + val productId = this[RoleAssignmentsTable.productId]?.let { ProductId(it.value) } + if (productId == null) { + return CompoundHierarchyId.forOrganization(orgId) + } else { + val repositoryId = this[RoleAssignmentsTable.repositoryId]?.let { RepositoryId(it.value) } + return if (repositoryId == null) { + CompoundHierarchyId.forProduct(orgId, productId) + } else { + CompoundHierarchyId.forRepository(orgId, productId, repositoryId) + } + } + } +} /** * Generate the SQL condition to match the repository part of this [hierarchyId]. The condition also has to select @@ -344,11 +416,11 @@ private fun reducePermissions( */ private fun SqlExpressionBuilder.repositoryCondition(hierarchyId: CompoundHierarchyId): Op = ( - (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) + (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 @@ -358,12 +430,6 @@ private fun SqlExpressionBuilder.productWildcardCondition(hierarchyId: CompoundH (RoleAssignmentsTable.productId eq null) and (RoleAssignmentsTable.organizationId eq hierarchyId.organizationId?.value) -/** - * Generate the SQL condition to match role assignments for which no organization ID is defined. - */ -private fun SqlExpressionBuilder.organizationWildcardCondition(): Op = - RoleAssignmentsTable.organizationId eq null - /** * Generate the SQL condition to match role assignments for the given [role]. */ @@ -373,3 +439,28 @@ private fun SqlExpressionBuilder.roleCondition(role: Role): Op = is ProductRole -> RoleAssignmentsTable.productRole eq role.name is RepositoryRole -> RoleAssignmentsTable.repositoryRole eq role.name } + +/** + * Filter the IDs in this [IdsByLevel] to only include those that are contained in the given [containedInId]. If + * [containedInId] is *null*, return this object unmodified. + */ +private fun IdsByLevel.filterContainedIn( + containedInId: CompoundHierarchyId? +): IdsByLevel = + if (containedInId == null) { + this + } else { + this.mapValues { (_, ids) -> + ids.filter { id -> id in containedInId } + }.filterValues { it.isNotEmpty() } + } + +/** + * Return a collection with the roles that are relevant on the hierarchy level defined by [hierarchyId]. + */ +private fun rolesForLevel(hierarchyId: CompoundHierarchyId): Collection = + when (hierarchyId.level) { + CompoundHierarchyId.REPOSITORY_LEVEL -> RepositoryRole.entries + CompoundHierarchyId.PRODUCT_LEVEL -> ProductRole.entries + else -> OrganizationRole.entries + } diff --git a/components/authorization/backend/src/test/kotlin/rights/HierarchyPermissionsTest.kt b/components/authorization/backend/src/test/kotlin/rights/HierarchyPermissionsTest.kt new file mode 100644 index 0000000000..3802e317b0 --- /dev/null +++ b/components/authorization/backend/src/test/kotlin/rights/HierarchyPermissionsTest.kt @@ -0,0 +1,317 @@ +/* + * 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.rights + +import io.kotest.core.spec.style.WordSpec +import io.kotest.inspectors.forAll +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.maps.beEmpty +import io.kotest.matchers.maps.shouldHaveSize +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe + +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 HierarchyPermissionsTest : WordSpec({ + "permissions" should { + "return a checker for organization permissions" { + val checker = HierarchyPermissions.permissions( + OrganizationPermission.WRITE, OrganizationPermission.CREATE_PRODUCT + ) + + checker(OrganizationRole.ADMIN) shouldBe true + checker(OrganizationRole.WRITER) shouldBe true + checker(OrganizationRole.READER) shouldBe false + } + + "return a checker for product permissions" { + val checker = HierarchyPermissions.permissions( + ProductPermission.WRITE, ProductPermission.CREATE_REPOSITORY, ProductPermission.TRIGGER_ORT_RUN + ) + + checker(ProductRole.ADMIN) shouldBe true + checker(ProductRole.WRITER) shouldBe true + checker(ProductRole.READER) shouldBe false + } + + "return a checker for repository permissions" { + val checker = HierarchyPermissions.permissions( + RepositoryPermission.WRITE, RepositoryPermission.TRIGGER_ORT_RUN + ) + + checker(RepositoryRole.ADMIN) shouldBe true + checker(RepositoryRole.WRITER) shouldBe true + checker(RepositoryRole.READER) shouldBe false + } + + "return a checker for the permissions of a role" { + val checker = HierarchyPermissions.permissions(ProductRole.WRITER) + + checker(ProductRole.ADMIN) shouldBe true + checker(ProductRole.WRITER) shouldBe true + checker(ProductRole.READER) shouldBe false + checker(RepositoryRole.ADMIN) shouldBe false + checker(OrganizationRole.READER) shouldBe false + } + } + + "hasPermission" should { + "return false if there is no matching role assignment for an element" { + val id = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + val permissions = HierarchyPermissions.create( + emptyList(), + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + + permissions.hasPermission(id) shouldBe false + } + + "return true if permissions are granted on a hierarchy element" { + val repositoryId = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + + val permissions = HierarchyPermissions.create( + listOf(repositoryId to RepositoryRole.READER), + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + + permissions.hasPermission(repositoryId) shouldBe true + } + + "return true if permissions are inherited from a higher level in the hierarchy" { + val organizationId = OrganizationId(1) + val product1Id = ProductId(2) + val product2Id = ProductId(3) + val repository1Id = CompoundHierarchyId.forRepository(organizationId, product1Id, RepositoryId(4)) + val repository2Id = CompoundHierarchyId.forRepository(organizationId, product1Id, RepositoryId(5)) + val repository3Id = CompoundHierarchyId.forRepository(organizationId, product2Id, RepositoryId(6)) + + val permissions = HierarchyPermissions.create( + listOf( + CompoundHierarchyId.forProduct(organizationId, product1Id) to RepositoryRole.WRITER + ), + HierarchyPermissions.permissions(RepositoryPermission.WRITE) + ) + + permissions.hasPermission(repository1Id) shouldBe true + permissions.hasPermission(repository2Id) shouldBe true + permissions.hasPermission(repository3Id) shouldBe false + } + + "support widening permissions on lower levels in the hierarchy" { + val organizationId = OrganizationId(1) + val product1Id = ProductId(2) + val repository1Id = CompoundHierarchyId.forRepository(organizationId, product1Id, RepositoryId(4)) + + val permissions = HierarchyPermissions.create( + listOf( + CompoundHierarchyId.forOrganization(organizationId) to RepositoryRole.READER, + repository1Id to RepositoryRole.WRITER + ), + HierarchyPermissions.permissions(RepositoryPermission.WRITE) + ) + + permissions.hasPermission(repository1Id) shouldBe true + } + + "override restricted roles on lower levels in the hierarchy" { + val organizationId = OrganizationId(1) + val product1Id = ProductId(2) + val repository1Id = CompoundHierarchyId.forRepository(organizationId, product1Id, RepositoryId(4)) + + val permissions = HierarchyPermissions.create( + listOf( + CompoundHierarchyId.forOrganization(organizationId) to RepositoryRole.ADMIN, + repository1Id to RepositoryRole.READER + ), + HierarchyPermissions.permissions(RepositoryPermission.WRITE) + ) + + permissions.hasPermission(repository1Id) shouldBe true + } + + "take implicitly granted permissions into account" { + val repoId = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + val productId = repoId.parent!! + val orgId = productId.parent!! + + val permissions = HierarchyPermissions.create( + listOf( + repoId to RepositoryRole.READER + ), + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + + permissions.hasPermission(repoId) shouldBe true + permissions.hasPermission(productId) shouldBe true + permissions.hasPermission(orgId) shouldBe true + } + } + + "includes" should { + "return the IDs for which a sufficient role assignment exists" { + val id1 = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + val id2 = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(4)) + val id3 = CompoundHierarchyId.forProduct(OrganizationId(1), ProductId(5)) + val id4 = CompoundHierarchyId.forOrganization(OrganizationId(6)) + + val permissions = HierarchyPermissions.create( + listOf( + id1 to RepositoryRole.READER, + id2 to RepositoryRole.WRITER, + id3 to RepositoryRole.WRITER, + id4 to RepositoryRole.ADMIN + ), + HierarchyPermissions.permissions(RepositoryPermission.WRITE) + ) + val includes = permissions.includes() + + includes shouldHaveSize 3 + includes[CompoundHierarchyId.ORGANIZATION_LEVEL] shouldContainExactlyInAnyOrder listOf(id4) + includes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactlyInAnyOrder listOf(id3) + includes[CompoundHierarchyId.REPOSITORY_LEVEL] shouldContainExactlyInAnyOrder listOf(id2) + } + + "not contain elements that are already dominated by higher level assignments" { + val orgId = CompoundHierarchyId.forOrganization(OrganizationId(1)) + val repo1Id = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + val repo2Id = CompoundHierarchyId.forRepository(OrganizationId(4), ProductId(5), RepositoryId(6)) + + val permissions = HierarchyPermissions.create( + listOf( + orgId to RepositoryRole.READER, + repo1Id to RepositoryRole.READER, + repo2Id to RepositoryRole.WRITER + ), + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + val includes = permissions.includes() + + includes[CompoundHierarchyId.REPOSITORY_LEVEL] shouldContainExactlyInAnyOrder listOf(repo2Id) + } + } + + "implicitIncludes" should { + "return the IDs of elements that are implicitly included by elements on lower levels" { + val repoId = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + + val permissions = HierarchyPermissions.create( + listOf( + repoId to RepositoryRole.READER + ), + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + val implicitIncludes = permissions.implicitIncludes() + + implicitIncludes shouldHaveSize 2 + implicitIncludes[CompoundHierarchyId.ORGANIZATION_LEVEL] shouldContainExactlyInAnyOrder listOf( + CompoundHierarchyId.forOrganization(OrganizationId(1)) + ) + implicitIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactlyInAnyOrder listOf(repoId.parent) + } + + "not return the IDs of elements dominated by higher level assignments" { + val orgId = CompoundHierarchyId.forOrganization(OrganizationId(1)) + val repoId = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + + val permissions = HierarchyPermissions.create( + listOf( + orgId to RepositoryRole.READER, + repoId to RepositoryRole.READER + ), + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + val implicitIncludes = permissions.implicitIncludes() + + implicitIncludes shouldBe emptyMap() + } + + "not return duplicate IDs" { + val repo1Id = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + val repo2Id = CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(4)) + + val permissions = HierarchyPermissions.create( + listOf( + repo1Id to RepositoryRole.READER, + repo2Id to RepositoryRole.READER + ), + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + val implicitIncludes = permissions.implicitIncludes() + + implicitIncludes shouldHaveSize 2 + implicitIncludes[CompoundHierarchyId.ORGANIZATION_LEVEL] shouldContainExactlyInAnyOrder listOf( + CompoundHierarchyId.forOrganization(OrganizationId(1)) + ) + implicitIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactlyInAnyOrder listOf(repo1Id.parent) + } + } + + "create" should { + "return only a superuser instance if a correct ADMIN role is available" { + val permissions = HierarchyPermissions.create( + listOf( + CompoundHierarchyId.WILDCARD to OrganizationRole.WRITER + ), + HierarchyPermissions.permissions(ProductPermission.READ) + ) + + permissions.isSuperuser() shouldBe false + } + } + + "the superuser instance" should { + val superuserPermissions = HierarchyPermissions.create( + listOf( + CompoundHierarchyId.WILDCARD to OrganizationRole.ADMIN, + CompoundHierarchyId.forOrganization(OrganizationId(1)) to OrganizationRole.READER + ), + HierarchyPermissions.permissions(ProductPermission.WRITE) + ) + + "always return true for hasPermission" { + val ids = listOf( + CompoundHierarchyId.forOrganization(OrganizationId(1)), + CompoundHierarchyId.forProduct(OrganizationId(1), ProductId(2)), + CompoundHierarchyId.forRepository(OrganizationId(1), ProductId(2), RepositoryId(3)) + ) + ids.forAll { superuserPermissions.hasPermission(it) shouldBe true } + } + + "return a map with includes containing only the wildcard ID" { + val includes = superuserPermissions.includes() + + includes shouldHaveSize 1 + includes[CompoundHierarchyId.WILDCARD_LEVEL] shouldContainExactlyInAnyOrder listOf( + CompoundHierarchyId.WILDCARD + ) + } + + "return an empty map for implicit includes" { + superuserPermissions.implicitIncludes() should beEmpty() + } + + "declare itself as superuser instance" { + superuserPermissions.isSuperuser() shouldBe true + } + } +}) diff --git a/components/authorization/backend/src/test/kotlin/routes/AuthorizedRoutesTest.kt b/components/authorization/backend/src/test/kotlin/routes/AuthorizedRoutesTest.kt index cd43cba278..cd71d7e602 100644 --- a/components/authorization/backend/src/test/kotlin/routes/AuthorizedRoutesTest.kt +++ b/components/authorization/backend/src/test/kotlin/routes/AuthorizedRoutesTest.kt @@ -27,6 +27,7 @@ import io.github.smiley4.ktoropenapi.get import io.kotest.core.spec.style.WordSpec import io.kotest.inspectors.forAll +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe @@ -52,18 +53,22 @@ import io.ktor.server.routing.routing import io.ktor.server.testing.testApplication import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot 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.PermissionChecker 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.HierarchyId import org.eclipse.apoapsis.ortserver.model.OrganizationId import org.eclipse.apoapsis.ortserver.model.ProductId import org.eclipse.apoapsis.ortserver.model.RepositoryId @@ -117,6 +122,42 @@ class AuthorizedRoutesTest : WordSpec() { } } + /** + * Run a test with an authorized route that requires the given [requiredPermission]. Delegate to the overloaded + * function with a mock service and the given [routeBuilder] and [test] function. After the test completes, + * verify that the service was called correctly. + */ + private fun > runAuthorizationTest( + requiredPermission: E, + routeBuilder: Route.() -> Unit, + test: suspend (HttpClient) -> Unit + ) { + val service = createAuthorizationService() + + runAuthorizationTest(service, routeBuilder, test) + + val slotHierarchyId = slot() + val slotChecker = slot() + coVerify { + service.checkPermissions(USERNAME, capture(slotHierarchyId), capture(slotChecker)) + } + + when (requiredPermission) { + is OrganizationPermission -> { + slotHierarchyId.captured shouldBe OrganizationId(ID_PARAMETER) + slotChecker.captured.organizationPermissions shouldContainExactly setOf(requiredPermission) + } + is ProductPermission -> { + slotHierarchyId.captured shouldBe ProductId(ID_PARAMETER) + slotChecker.captured.productPermissions shouldContainExactly setOf(requiredPermission) + } + is RepositoryPermission -> { + slotHierarchyId.captured shouldBe RepositoryId(ID_PARAMETER) + slotChecker.captured.repositoryPermissions shouldContainExactly setOf(requiredPermission) + } + } + } + init { "authorized routes" should { "support a route without permission requirements" { @@ -150,13 +191,8 @@ class AuthorizedRoutesTest : WordSpec() { } "support GET with an organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(OrganizationPermission.WRITE_SECRETS) } returns true - } - val service = createServiceForOrganizationRole(effectiveRole) - runAuthorizationTest( - service, + OrganizationPermission.WRITE_SECRETS, routeBuilder = { route("test/{organizationId}") { get(testDocs, requirePermission(OrganizationPermission.WRITE_SECRETS)) { @@ -175,13 +211,8 @@ class AuthorizedRoutesTest : WordSpec() { } "support POST with an organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(OrganizationPermission.MANAGE_GROUPS) } returns true - } - val service = createServiceForOrganizationRole(effectiveRole) - runAuthorizationTest( - service, + OrganizationPermission.MANAGE_GROUPS, routeBuilder = { route("test/{organizationId}") { post(testDocs, requirePermission(OrganizationPermission.MANAGE_GROUPS)) { @@ -200,13 +231,8 @@ class AuthorizedRoutesTest : WordSpec() { } "support PATCH with an organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(OrganizationPermission.CREATE_PRODUCT) } returns true - } - val service = createServiceForOrganizationRole(effectiveRole) - runAuthorizationTest( - service, + OrganizationPermission.CREATE_PRODUCT, routeBuilder = { route("test/{organizationId}") { patch(testDocs, requirePermission(OrganizationPermission.CREATE_PRODUCT)) { @@ -225,13 +251,8 @@ class AuthorizedRoutesTest : WordSpec() { } "support PUT with an organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(OrganizationPermission.READ_PRODUCTS) } returns true - } - val service = createServiceForOrganizationRole(effectiveRole) - runAuthorizationTest( - service, + OrganizationPermission.READ_PRODUCTS, routeBuilder = { route("test/{organizationId}") { put(testDocs, requirePermission(OrganizationPermission.READ_PRODUCTS)) { @@ -250,13 +271,8 @@ class AuthorizedRoutesTest : WordSpec() { } "support DELETE with an organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(OrganizationPermission.WRITE) } returns true - } - val service = createServiceForOrganizationRole(effectiveRole) - runAuthorizationTest( - service, + OrganizationPermission.WRITE, routeBuilder = { route("test/{organizationId}") { delete(testDocs, requirePermission(OrganizationPermission.WRITE)) { @@ -275,17 +291,8 @@ class AuthorizedRoutesTest : WordSpec() { } "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, + ProductPermission.DELETE, routeBuilder = { route("test/{productId}") { get(testDocs, requirePermission(ProductPermission.DELETE)) { @@ -300,25 +307,12 @@ class AuthorizedRoutesTest : WordSpec() { ) { 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, + RepositoryPermission.READ, routeBuilder = { route("test/{repositoryId}") { get(testDocs, requirePermission(RepositoryPermission.READ)) { @@ -333,10 +327,6 @@ class AuthorizedRoutesTest : WordSpec() { ) { client -> val response = client.get("test/$ID_PARAMETER") response.status shouldBe HttpStatusCode.OK - - verify { - effectiveRole.hasRepositoryPermission(RepositoryPermission.READ) - } } } @@ -345,9 +335,7 @@ class AuthorizedRoutesTest : WordSpec() { every { isSuperuser } returns true } val service = mockk { - coEvery { - getEffectiveRole(USERNAME, CompoundHierarchyId.WILDCARD) - } returns effectiveRole + coEvery { checkPermissions(USERNAME, CompoundHierarchyId.WILDCARD, any()) } returns effectiveRole } runAuthorizationTest( @@ -376,10 +364,7 @@ class AuthorizedRoutesTest : WordSpec() { "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) + val service = createAuthorizationService(null) runAuthorizationTest( service, @@ -397,10 +382,7 @@ class AuthorizedRoutesTest : WordSpec() { } "return a 403 response for POST with insufficient organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(any()) } returns false - } - val service = createServiceForOrganizationRole(effectiveRole) + val service = createAuthorizationService(null) runAuthorizationTest( service, @@ -418,10 +400,7 @@ class AuthorizedRoutesTest : WordSpec() { } "return a 403 response for PATCH with insufficient organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(any()) } returns false - } - val service = createServiceForOrganizationRole(effectiveRole) + val service = createAuthorizationService(null) runAuthorizationTest( service, @@ -439,10 +418,7 @@ class AuthorizedRoutesTest : WordSpec() { } "return a 403 response for PUT with insufficient organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(any()) } returns false - } - val service = createServiceForOrganizationRole(effectiveRole) + val service = createAuthorizationService(null) runAuthorizationTest( service, @@ -460,10 +436,7 @@ class AuthorizedRoutesTest : WordSpec() { } "return a 403 response for DELETE with insufficient organization permission" { - val effectiveRole = mockk { - every { hasOrganizationPermission(any()) } returns false - } - val service = createServiceForOrganizationRole(effectiveRole) + val service = createAuthorizationService(null) runAuthorizationTest( service, @@ -510,12 +483,9 @@ private fun createToken(): String = .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. + * Create a mock [AuthorizationService] that is prepared to handle a permission check. Per default, the check + * returns a mock effective role. */ -private fun createServiceForOrganizationRole(effectiveRole: EffectiveRole): AuthorizationService = - mockk { - coEvery { - getEffectiveRole(USERNAME, OrganizationId(ID_PARAMETER)) - } returns effectiveRole +private fun createAuthorizationService(effectiveRole: EffectiveRole? = mockk()): AuthorizationService = mockk { + coEvery { checkPermissions(any(), any(), any()) } returns effectiveRole } diff --git a/components/authorization/backend/src/test/kotlin/routes/OrtServerPrincipalTest.kt b/components/authorization/backend/src/test/kotlin/routes/OrtServerPrincipalTest.kt index 237d765372..aea8269b90 100644 --- a/components/authorization/backend/src/test/kotlin/routes/OrtServerPrincipalTest.kt +++ b/components/authorization/backend/src/test/kotlin/routes/OrtServerPrincipalTest.kt @@ -21,7 +21,8 @@ package org.eclipse.apoapsis.ortserver.components.authorization.routes import com.auth0.jwt.interfaces.Payload -import io.kotest.core.spec.style.StringSpec +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.shouldBe import io.mockk.every @@ -29,23 +30,76 @@ 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 +class OrtServerPrincipalTest : WordSpec({ + "create()" should { + "create an instance 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 + } + } + + "isAuthorized" should { + "return true if an effective role is present" { + val principal = OrtServerPrincipal( + userId = "user-id", + username = "username", + fullName = "Full Name", + role = mockk() + ) + + principal.isAuthorized shouldBe true + } + + "return false if no effective role is present" { + val principal = OrtServerPrincipal( + userId = "user-id", + username = "username", + fullName = "Full Name", + role = null + ) + + principal.isAuthorized shouldBe false } - val effectiveRole = mockk() + } - val principal = OrtServerPrincipal.create(payload, effectiveRole) + "effectiveRole" should { + "return the effective role if present" { + val effectiveRole = mockk() + val principal = OrtServerPrincipal( + userId = "user-id", + username = "username", + fullName = "Full Name", + role = effectiveRole + ) - principal.userId shouldBe userId - principal.username shouldBe username - principal.fullName shouldBe fullName - principal.effectiveRole shouldBe effectiveRole + principal.effectiveRole shouldBe effectiveRole + } + + "throw an exception if no effective role is present" { + val principal = OrtServerPrincipal( + userId = "user-id", + username = "username", + fullName = "Full Name", + role = null + ) + + shouldThrow { + principal.effectiveRole + } + } } }) diff --git a/components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt b/components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt index 56f0796b1f..953592e946 100644 --- a/components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt +++ b/components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt @@ -21,12 +21,18 @@ package org.eclipse.apoapsis.ortserver.components.authorization.service import io.kotest.core.spec.style.WordSpec import io.kotest.inspectors.forAll +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.maps.shouldHaveSize +import io.kotest.matchers.nulls.beNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.should import io.kotest.matchers.shouldBe import org.eclipse.apoapsis.ortserver.components.authorization.db.RoleAssignmentsTable import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole +import org.eclipse.apoapsis.ortserver.components.authorization.rights.HierarchyPermissions import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationPermission import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationRole import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductPermission @@ -43,6 +49,7 @@ import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.jetbrains.exposed.sql.insert +@Suppress("LargeClass") class DbAuthorizationServiceTest : WordSpec() { private val dbExtension = extension(DatabaseTestExtension()) @@ -144,46 +151,61 @@ class DbAuthorizationServiceTest : WordSpec() { } "correctly resolve permissions on repository level" { - val repositoryCompoundId = repositoryCompoundId() - createAssignment( - organizationId = dbExtension.fixtures.organization.id, - productId = dbExtension.fixtures.product.id, - repositoryId = dbExtension.fixtures.repository.id, - repositoryRole = RepositoryRole.READER - ) val service = createService() - val effectiveRole = service.getEffectiveRole(USER_ID, repositoryCompoundId) + RepositoryRole.entries.forAll { role -> + val repository = dbExtension.fixtures.createRepository( + url = "https://example.com/testRepo_$role.git" + ) - checkPermissions(effectiveRole, RepositoryRole.READER) + createAssignment( + organizationId = dbExtension.fixtures.organization.id, + productId = dbExtension.fixtures.product.id, + repositoryId = repository.id, + repositoryRole = role + ) + + val effectiveRole = service.getEffectiveRole(USER_ID, RepositoryId(repository.id)) + + checkPermissions(effectiveRole, role) + } } "correctly resolve permissions on product level" { - createAssignment( - organizationId = dbExtension.fixtures.organization.id, - productId = dbExtension.fixtures.product.id, - productRole = ProductRole.WRITER - ) val service = createService() - val effectiveRole = service.getEffectiveRole(USER_ID, ProductId(dbExtension.fixtures.product.id)) + ProductRole.entries.forAll { role -> + val product = dbExtension.fixtures.createProduct("testProduct_$role") - checkPermissions(effectiveRole, ProductRole.WRITER) + createAssignment( + organizationId = dbExtension.fixtures.organization.id, + productId = product.id, + productRole = role + ) + + val effectiveRole = service.getEffectiveRole(USER_ID, ProductId(product.id)) + + checkPermissions(effectiveRole, role) + } } "correctly resolve permissions on organization level" { - createAssignment( - organizationId = dbExtension.fixtures.organization.id, - organizationRole = OrganizationRole.WRITER - ) val service = createService() - val effectiveRole = service.getEffectiveRole( - USER_ID, - OrganizationId(dbExtension.fixtures.organization.id) - ) + OrganizationRole.entries.forAll { role -> + val org = dbExtension.fixtures.createOrganization("testOrg_$role") + createAssignment( + organizationId = org.id, + organizationRole = role + ) - checkPermissions(effectiveRole, OrganizationRole.WRITER) + val effectiveRole = service.getEffectiveRole( + USER_ID, + OrganizationId(org.id) + ) + + checkPermissions(effectiveRole, role) + } } "consider all roles in the hierarchy" { @@ -196,7 +218,7 @@ class DbAuthorizationServiceTest : WordSpec() { val effectiveRole = service.getEffectiveRole(USER_ID, repositoryCompoundId) - checkPermissions(effectiveRole, OrganizationRole.ADMIN) + checkPermissions(effectiveRole, RepositoryRole.ADMIN) } "filter correctly by user ID" { @@ -245,7 +267,7 @@ class DbAuthorizationServiceTest : WordSpec() { checkPermissions(effectiveRole, RepositoryRole.READER) } - "combine multiple assignments" { + "allow overriding assignments from higher levels" { val repositoryCompoundId = repositoryCompoundId() createAssignment( organizationId = dbExtension.fixtures.organization.id, @@ -253,11 +275,6 @@ class DbAuthorizationServiceTest : WordSpec() { repositoryId = dbExtension.fixtures.repository.id, repositoryRole = RepositoryRole.READER ) - createAssignment( - organizationId = dbExtension.fixtures.organization.id, - productId = dbExtension.fixtures.product.id, - productRole = ProductRole.ADMIN - ) createAssignment( organizationId = dbExtension.fixtures.organization.id, organizationRole = OrganizationRole.ADMIN @@ -266,7 +283,7 @@ class DbAuthorizationServiceTest : WordSpec() { val effectiveRole = service.getEffectiveRole(USER_ID, repositoryCompoundId) - checkPermissions(effectiveRole, OrganizationRole.ADMIN) + checkPermissions(effectiveRole, RepositoryRole.ADMIN) } "support role assignments on super user level (without an organization ID)" { @@ -278,7 +295,7 @@ class DbAuthorizationServiceTest : WordSpec() { val effectiveRole = service.getEffectiveRole(USER_ID, repositoryCompoundId) - checkPermissions(effectiveRole, OrganizationRole.ADMIN, expectedSuperuser = true) + checkPermissions(effectiveRole, RepositoryRole.ADMIN, expectedSuperuser = true) } "allow querying super users only" { @@ -316,6 +333,120 @@ class DbAuthorizationServiceTest : WordSpec() { } } + "checkPermissions" should { + "return null for missing permissions" { + val repositoryCompoundId = repositoryCompoundId() + val service = createService() + + val effectiveRole = service.checkPermissions( + USER_ID, + repositoryCompoundId, + HierarchyPermissions.permissions(RepositoryPermission.TRIGGER_ORT_RUN) + ) + + effectiveRole should beNull() + } + + "return an EffectiveRole for permissions explicitly granted" { + val repositoryCompoundId = repositoryCompoundId() + createAssignment( + organizationId = dbExtension.fixtures.organization.id, + productId = dbExtension.fixtures.product.id, + repositoryId = dbExtension.fixtures.repository.id, + repositoryRole = RepositoryRole.WRITER + ) + val service = createService() + + val effectiveRole = service.checkPermissions( + USER_ID, + repositoryCompoundId, + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + + effectiveRole shouldNotBeNull { + checkPermissions(this, expectedRepositoryPermissions = setOf(RepositoryPermission.READ)) + elementId shouldBe repositoryCompoundId + } + } + + "return an EffectiveRole for permissions granted via a higher level" { + val repositoryCompoundId = repositoryCompoundId() + createAssignment( + organizationId = dbExtension.fixtures.organization.id, + organizationRole = OrganizationRole.READER + ) + val service = createService() + + val effectiveRole = service.checkPermissions( + USER_ID, + repositoryCompoundId, + HierarchyPermissions.permissions(RepositoryPermission.READ) + ) + + effectiveRole shouldNotBeNull { + checkPermissions(this, expectedRepositoryPermissions = setOf(RepositoryPermission.READ)) + elementId shouldBe repositoryCompoundId + } + } + + "return an EffectiveRole for implicit permissions derived from a lower level" { + val orgId = OrganizationId(dbExtension.fixtures.organization.id) + createAssignment( + organizationId = dbExtension.fixtures.organization.id, + productId = dbExtension.fixtures.product.id, + repositoryId = dbExtension.fixtures.repository.id, + repositoryRole = RepositoryRole.WRITER + ) + val service = createService() + + val effectiveRole = service.checkPermissions( + USER_ID, + orgId, + HierarchyPermissions.permissions(OrganizationPermission.READ) + ) + + effectiveRole shouldNotBeNull { + checkPermissions(this, expectedOrganizationPermissions = setOf(OrganizationPermission.READ)) + elementId shouldBe CompoundHierarchyId.forOrganization(orgId) + } + } + + "return an EffectiveRole for superuser permissions" { + val repositoryCompoundId = repositoryCompoundId() + createAssignment( + organizationRole = OrganizationRole.ADMIN + ) + val service = createService() + + val effectiveRole = service.checkPermissions( + USER_ID, + repositoryCompoundId, + HierarchyPermissions.permissions(RepositoryPermission.DELETE) + ) + + effectiveRole shouldNotBeNull { + checkPermissions( + this, + expectedRepositoryPermissions = setOf(RepositoryPermission.DELETE), + expectedSuperuser = true + ) + elementId shouldBe repositoryCompoundId + } + } + + "handle an invalid compound hierarchy ID gracefully" { + val service = createService() + + val effectiveRole = service.checkPermissions( + USER_ID, + ProductId(-1L), + HierarchyPermissions.permissions(ProductPermission.READ) + ) + + effectiveRole should beNull() + } + } + "assignRole" should { "create a new role assignment on repository level" { val repositoryCompoundId = repositoryCompoundId() @@ -370,7 +501,7 @@ class DbAuthorizationServiceTest : WordSpec() { checkPermissions(effectiveRole, OrganizationRole.WRITER) val effectiveRoleRepo = service.getEffectiveRole(USER_ID, repositoryCompoundId()) - checkPermissions(effectiveRoleRepo, OrganizationRole.WRITER) + checkPermissions(effectiveRoleRepo, RepositoryRole.WRITER) } "create a new superuser role assignment" { @@ -383,7 +514,7 @@ class DbAuthorizationServiceTest : WordSpec() { ) val effectiveRole = service.getEffectiveRole(USER_ID, repositoryCompoundId()) - checkPermissions(effectiveRole, OrganizationRole.ADMIN, expectedSuperuser = true) + checkPermissions(effectiveRole, RepositoryRole.ADMIN, expectedSuperuser = true) } "replace an already exiting assignment" { @@ -642,6 +773,440 @@ class DbAuthorizationServiceTest : WordSpec() { users[USER_ID] shouldContainExactlyInAnyOrder listOf(RepositoryRole.READER) } } + + "filterHierarchyIds" should { + "return a filter that includes the IDs of repositories a user has access to" { + val repositoryCompoundId = repositoryCompoundId() + val otherRepo = dbExtension.fixtures.createRepository(url = "https://example.com/other.git") + val otherRepoId = CompoundHierarchyId.forRepository( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(dbExtension.fixtures.product.id), + RepositoryId(otherRepo.id) + ) + val otherOrg = dbExtension.fixtures.createOrganization("otherOrg") + val otherProduct = dbExtension.fixtures.createProduct("otherProduct", organizationId = otherOrg.id) + val repoInOtherStructure = dbExtension.fixtures.createRepository( + url = "https://example.com/other-structure.git", + productId = otherProduct.id + ) + val repoInOtherStructureId = CompoundHierarchyId.forRepository( + OrganizationId(otherOrg.id), + ProductId(otherProduct.id), + RepositoryId(repoInOtherStructure.id) + ) + dbExtension.fixtures.createRepository(url = "https://example.com/forbidden.git") + val service = createService() + + service.assignRole( + USER_ID, + RepositoryRole.READER, + repositoryCompoundId + ) + service.assignRole( + USER_ID, + RepositoryRole.WRITER, + otherRepoId + ) + service.assignRole( + USER_ID, + RepositoryRole.ADMIN, + repoInOtherStructureId + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ) + ) + + filter.transitiveIncludes shouldHaveSize 1 + filter.transitiveIncludes[CompoundHierarchyId.REPOSITORY_LEVEL] shouldContainExactlyInAnyOrder setOf( + repositoryCompoundId, + otherRepoId, + repoInOtherStructureId + ) + filter.isWildcard shouldBe false + } + + "return a filter that includes the IDs of organizations the user has access to" { + val organizationId = CompoundHierarchyId.forOrganization( + OrganizationId(dbExtension.fixtures.organization.id) + ) + val otherOrg = dbExtension.fixtures.createOrganization("otherOrg") + val otherOrgId = CompoundHierarchyId.forOrganization(OrganizationId(otherOrg.id)) + dbExtension.fixtures.createOrganization("forbiddenOrg") + val service = createService() + + service.assignRole( + USER_ID, + OrganizationRole.READER, + organizationId + ) + service.assignRole( + USER_ID, + OrganizationRole.ADMIN, + otherOrgId + ) + + val filter = service.filterHierarchyIds( + USER_ID, + requiredRole = OrganizationRole.READER + ) + + filter.transitiveIncludes[CompoundHierarchyId.ORGANIZATION_LEVEL] shouldContainExactlyInAnyOrder setOf( + organizationId, + otherOrgId + ) + } + + "only list IDs for which all required permissions are available" { + val productId = CompoundHierarchyId.forProduct( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(dbExtension.fixtures.product.id) + ) + val otherProduct = dbExtension.fixtures.createProduct("otherProduct") + val otherProductId = CompoundHierarchyId.forProduct( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(otherProduct.id) + ) + dbExtension.fixtures.createProduct("forbiddenProduct") + val service = createService() + + service.assignRole( + USER_ID, + ProductRole.READER, + productId + ) + service.assignRole( + USER_ID, + ProductRole.WRITER, + otherProductId + ) + + val filter = service.filterHierarchyIds( + USER_ID, + productPermissions = setOf(ProductPermission.WRITE) + ) + + filter.transitiveIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactlyInAnyOrder setOf( + otherProductId + ) + filter.transitiveIncludes shouldHaveSize 1 + } + + "handle role assignments on higher levels correctly" { + val repositoryCompoundId = repositoryCompoundId() + val productId = repositoryCompoundId.parent!! + val otherOrg = dbExtension.fixtures.createOrganization("otherOrg") + val otherOrgId = CompoundHierarchyId.forOrganization(OrganizationId(otherOrg.id)) + val otherProduct = dbExtension.fixtures.createProduct("otherProduct", organizationId = otherOrg.id) + dbExtension.fixtures.createRepository( + url = "https://example.com/other.git", + productId = otherProduct.id + ) + val thirdOrg = dbExtension.fixtures.createOrganization("thirdOrg") + val service = createService() + + service.assignRole( + USER_ID, + OrganizationRole.ADMIN, + otherOrgId + ) + service.assignRole( + USER_ID, + ProductRole.ADMIN, + productId + ) + service.assignRole( + USER_ID, + OrganizationRole.READER, + CompoundHierarchyId.forOrganization(OrganizationId(thirdOrg.id)) + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.WRITE) + ) + + filter.transitiveIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactlyInAnyOrder setOf( + productId + ) + filter.transitiveIncludes[CompoundHierarchyId.ORGANIZATION_LEVEL] shouldContainExactlyInAnyOrder setOf( + otherOrgId + ) + } + + "drop IDs contained in others" { + val repositoryCompoundId = repositoryCompoundId() + val productId = repositoryCompoundId.parent!! + val organizationId = productId.parent!! + val service = createService() + + service.assignRole( + USER_ID, + RepositoryRole.READER, + repositoryCompoundId + ) + service.assignRole( + USER_ID, + ProductRole.ADMIN, + productId + ) + service.assignRole( + USER_ID, + OrganizationRole.ADMIN, + organizationId + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ) + ) + + filter.transitiveIncludes[CompoundHierarchyId.ORGANIZATION_LEVEL] shouldContainExactly setOf( + organizationId + ) + filter.transitiveIncludes shouldHaveSize 1 + } + + "handle superuser assignments correctly" { + val repositoryCompoundId = repositoryCompoundId() + val productId = repositoryCompoundId.parent!! + val organizationId = productId.parent!! + val service = createService() + + service.assignRole( + USER_ID, + OrganizationRole.ADMIN, + CompoundHierarchyId.WILDCARD + ) + service.assignRole( + USER_ID, + OrganizationRole.ADMIN, + organizationId + ) + service.assignRole( + USER_ID, + ProductRole.ADMIN, + productId + ) + service.assignRole( + USER_ID, + RepositoryRole.READER, + repositoryCompoundId + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ) + ) + + filter.isWildcard shouldBe true + } + + "correctly filter for the user ID" { + val repositoryCompoundId = repositoryCompoundId() + val otherRepo = dbExtension.fixtures.createRepository(url = "https://example.com/other.git") + val otherRepoId = CompoundHierarchyId.forRepository( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(dbExtension.fixtures.product.id), + RepositoryId(otherRepo.id) + ) + val service = createService() + + service.assignRole( + USER_ID, + RepositoryRole.READER, + repositoryCompoundId + ) + service.assignRole( + "other-user", + RepositoryRole.READER, + otherRepoId + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ) + ) + + filter.transitiveIncludes[CompoundHierarchyId.REPOSITORY_LEVEL] shouldContainExactly setOf( + repositoryCompoundId + ) + } + + "apply a containedIn filter on product level" { + val repositoryCompoundId = repositoryCompoundId() + val otherProduct = dbExtension.fixtures.createProduct("otherProduct") + val otherRepo = dbExtension.fixtures.createRepository( + url = "https://example.com/other.git", + productId = otherProduct.id + ) + val service = createService() + + service.assignRole( + USER_ID, + RepositoryRole.READER, + repositoryCompoundId + ) + service.assignRole( + USER_ID, + RepositoryRole.READER, + CompoundHierarchyId.forRepository( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(otherProduct.id), + RepositoryId(otherRepo.id) + ) + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ), + containedIn = repositoryCompoundId.productId + ) + + filter.transitiveIncludes[CompoundHierarchyId.REPOSITORY_LEVEL] shouldContainExactly setOf( + repositoryCompoundId + ) + } + + "apply a containedIn filter on organization level" { + val productId = CompoundHierarchyId.forProduct( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(dbExtension.fixtures.product.id) + ) + val otherOrg = dbExtension.fixtures.createOrganization("otherOrg") + val otherProduct = dbExtension.fixtures.createProduct("otherProduct", organizationId = otherOrg.id) + val service = createService() + + service.assignRole( + USER_ID, + ProductRole.WRITER, + productId + ) + service.assignRole( + USER_ID, + ProductRole.WRITER, + CompoundHierarchyId.forProduct( + OrganizationId(otherOrg.id), + ProductId(otherProduct.id) + ) + ) + + val filter = service.filterHierarchyIds( + USER_ID, + productPermissions = setOf(ProductPermission.WRITE), + containedIn = productId.organizationId + ) + + filter.transitiveIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactly setOf(productId) + } + + "handle a containedIn filter together with a superuser assignment" { + val repositoryCompoundId = repositoryCompoundId() + val service = createService() + + service.assignRole( + USER_ID, + OrganizationRole.ADMIN, + CompoundHierarchyId.WILDCARD + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ), + containedIn = repositoryCompoundId.productId + ) + + filter.transitiveIncludes shouldHaveSize 1 + filter.transitiveIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactly setOf( + repositoryCompoundId.parent + ) + } + + "handle a containedIn filter together with permissive rights on a higher level" { + val repositoryCompoundId = repositoryCompoundId() + val service = createService() + + service.assignRole( + USER_ID, + OrganizationRole.ADMIN, + CompoundHierarchyId.forOrganization( + OrganizationId(dbExtension.fixtures.organization.id) + ) + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ), + containedIn = repositoryCompoundId.productId + ) + + filter.transitiveIncludes shouldHaveSize 1 + filter.transitiveIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactly setOf( + repositoryCompoundId.parent + ) + } + + "find non-transitive includes" { + val repositoryCompoundId = repositoryCompoundId() + val service = createService() + + service.assignRole( + USER_ID, + RepositoryRole.READER, + repositoryCompoundId + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ) + ) + + filter.nonTransitiveIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactly setOf( + repositoryCompoundId.parent + ) + filter.nonTransitiveIncludes[CompoundHierarchyId.ORGANIZATION_LEVEL] shouldContainExactly setOf( + repositoryCompoundId.parent?.parent + ) + filter.nonTransitiveIncludes shouldHaveSize 2 + } + + "apply a containedIn filter to non-transitive includes" { + val repositoryCompoundId = repositoryCompoundId() + val product2 = dbExtension.fixtures.createProduct("otherProduct") + val repo2 = dbExtension.fixtures.createRepository( + url = "https://example.com/other.git", + productId = product2.id + ) + val service = createService() + + service.assignRole( + USER_ID, + RepositoryRole.READER, + repositoryCompoundId + ) + service.assignRole( + USER_ID, + RepositoryRole.READER, + CompoundHierarchyId.forRepository( + OrganizationId(dbExtension.fixtures.organization.id), + ProductId(product2.id), + RepositoryId(repo2.id) + ) + ) + + val filter = service.filterHierarchyIds( + USER_ID, + repositoryPermissions = setOf(RepositoryPermission.READ), + containedIn = repositoryCompoundId.productId + ) + + filter.nonTransitiveIncludes[CompoundHierarchyId.PRODUCT_LEVEL] shouldContainExactly setOf( + repositoryCompoundId.parent + ) + filter.nonTransitiveIncludes shouldHaveSize 1 + } + } } /** diff --git a/dao/src/main/kotlin/repositories/organization/DaoOrganizationRepository.kt b/dao/src/main/kotlin/repositories/organization/DaoOrganizationRepository.kt index 59a81b252e..cadecb37d3 100644 --- a/dao/src/main/kotlin/repositories/organization/DaoOrganizationRepository.kt +++ b/dao/src/main/kotlin/repositories/organization/DaoOrganizationRepository.kt @@ -21,16 +21,20 @@ package org.eclipse.apoapsis.ortserver.dao.repositories.organization import org.eclipse.apoapsis.ortserver.dao.blockingQuery import org.eclipse.apoapsis.ortserver.dao.entityQuery +import org.eclipse.apoapsis.ortserver.dao.utils.apply import org.eclipse.apoapsis.ortserver.dao.utils.applyRegex +import org.eclipse.apoapsis.ortserver.dao.utils.extractIds import org.eclipse.apoapsis.ortserver.dao.utils.listQuery +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId import org.eclipse.apoapsis.ortserver.model.repositories.OrganizationRepository import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.OptionalValue import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Op -import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.SqlExpressionBuilder /** * An implementation of [OrganizationRepository] that stores organizations in [OrganizationsTable]. @@ -45,17 +49,17 @@ class DaoOrganizationRepository(private val db: Database) : OrganizationReposito override fun get(id: Long) = db.entityQuery { OrganizationDao[id].mapToModel() } - override fun list(parameters: ListQueryParameters, filter: FilterParameter?) = + override fun list(parameters: ListQueryParameters, nameFilter: FilterParameter?, hierarchyFilter: HierarchyFilter) = db.blockingQuery { - OrganizationDao.listQuery(parameters, OrganizationDao::mapToModel) { - var condition: Op = Op.TRUE - filter?.let { - condition = condition and OrganizationsTable.name.applyRegex( - it.value - ) - } - condition + val nameCondition = nameFilter?.let { + OrganizationsTable.name.applyRegex(it.value) + } ?: Op.TRUE + + val builder = hierarchyFilter.apply(nameCondition) { level, ids, filter -> + generateHierarchyCondition(level, ids, filter) } + + OrganizationDao.listQuery(parameters, OrganizationDao::mapToModel, builder) } override fun update(id: Long, name: OptionalValue, description: OptionalValue) = db.blockingQuery { @@ -69,3 +73,21 @@ class DaoOrganizationRepository(private val db: Database) : OrganizationReposito override fun delete(id: Long) = db.blockingQuery { OrganizationDao[id].delete() } } + +/** + * Generate a condition defined by a [filter] for the given [level] and [ids]. + */ +private fun SqlExpressionBuilder.generateHierarchyCondition( + level: Int, + ids: List, + filter: HierarchyFilter +): Op = + when (level) { + CompoundHierarchyId.ORGANIZATION_LEVEL -> + OrganizationsTable.id inList ( + ids.extractIds(CompoundHierarchyId.ORGANIZATION_LEVEL) + + filter.nonTransitiveIncludes[CompoundHierarchyId.ORGANIZATION_LEVEL].orEmpty() + .extractIds(CompoundHierarchyId.ORGANIZATION_LEVEL) + ) + else -> Op.FALSE + } diff --git a/dao/src/main/kotlin/repositories/product/DaoProductRepository.kt b/dao/src/main/kotlin/repositories/product/DaoProductRepository.kt index b1e44b70bb..682a58ba11 100644 --- a/dao/src/main/kotlin/repositories/product/DaoProductRepository.kt +++ b/dao/src/main/kotlin/repositories/product/DaoProductRepository.kt @@ -21,15 +21,20 @@ package org.eclipse.apoapsis.ortserver.dao.repositories.product import org.eclipse.apoapsis.ortserver.dao.blockingQuery import org.eclipse.apoapsis.ortserver.dao.entityQuery +import org.eclipse.apoapsis.ortserver.dao.utils.apply import org.eclipse.apoapsis.ortserver.dao.utils.applyRegex +import org.eclipse.apoapsis.ortserver.dao.utils.extractIds import org.eclipse.apoapsis.ortserver.dao.utils.listQuery +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId import org.eclipse.apoapsis.ortserver.model.repositories.ProductRepository import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.OptionalValue import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SqlExpressionBuilder import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and @@ -44,17 +49,17 @@ class DaoProductRepository(private val db: Database) : ProductRepository { override fun get(id: Long) = db.entityQuery { ProductDao[id].mapToModel() } - override fun list(parameters: ListQueryParameters, filter: FilterParameter?) = + override fun list(parameters: ListQueryParameters, nameFilter: FilterParameter?, hierarchyFilter: HierarchyFilter) = db.blockingQuery { - ProductDao.listQuery(parameters, ProductDao::mapToModel) { - var condition: Op = Op.TRUE - filter?.let { - condition = condition and ProductsTable.name.applyRegex( - it.value - ) - } - condition + val nameCondition = nameFilter?.let { + ProductsTable.name.applyRegex(it.value) + } ?: Op.TRUE + + val builder = hierarchyFilter.apply(nameCondition) { level, ids, filter -> + generateHierarchyCondition(level, ids, filter) } + + ProductDao.listQuery(parameters, ProductDao::mapToModel, builder) } override fun countForOrganization(organizationId: Long) = @@ -62,14 +67,14 @@ class DaoProductRepository(private val db: Database) : ProductRepository { override fun listForOrganization(organizationId: Long, parameters: ListQueryParameters, filter: FilterParameter?) = db.blockingQuery { - ProductDao.listQuery(parameters, ProductDao::mapToModel) { - if (filter != null) { - ProductsTable.organizationId eq organizationId and ProductsTable.name.applyRegex(filter.value) - } else { - ProductsTable.organizationId eq organizationId + ProductDao.listQuery(parameters, ProductDao::mapToModel) { + if (filter != null) { + ProductsTable.organizationId eq organizationId and ProductsTable.name.applyRegex(filter.value) + } else { + ProductsTable.organizationId eq organizationId + } } } - } override fun update(id: Long, name: OptionalValue, description: OptionalValue) = db.blockingQuery { val product = ProductDao[id] @@ -82,3 +87,25 @@ class DaoProductRepository(private val db: Database) : ProductRepository { override fun delete(id: Long) = db.blockingQuery { ProductDao[id].delete() } } + +/** + * Generate a condition defined by a [HierarchyFilter] for the given [level] and [ids]. + */ +private fun SqlExpressionBuilder.generateHierarchyCondition( + level: Int, + ids: List, + filter: HierarchyFilter +): Op = + when (level) { + CompoundHierarchyId.PRODUCT_LEVEL -> + ProductsTable.id inList ( + ids.extractIds(CompoundHierarchyId.PRODUCT_LEVEL) + + filter.nonTransitiveIncludes[CompoundHierarchyId.PRODUCT_LEVEL].orEmpty() + .extractIds(CompoundHierarchyId.PRODUCT_LEVEL) + ) + + CompoundHierarchyId.ORGANIZATION_LEVEL -> + ProductsTable.organizationId inList ids.extractIds(CompoundHierarchyId.ORGANIZATION_LEVEL) + + else -> Op.FALSE + } diff --git a/dao/src/main/kotlin/repositories/repository/DaoRepositoryRepository.kt b/dao/src/main/kotlin/repositories/repository/DaoRepositoryRepository.kt index 4e6d08147f..078465920b 100644 --- a/dao/src/main/kotlin/repositories/repository/DaoRepositoryRepository.kt +++ b/dao/src/main/kotlin/repositories/repository/DaoRepositoryRepository.kt @@ -21,17 +21,23 @@ package org.eclipse.apoapsis.ortserver.dao.repositories.repository import org.eclipse.apoapsis.ortserver.dao.blockingQuery import org.eclipse.apoapsis.ortserver.dao.entityQuery +import org.eclipse.apoapsis.ortserver.dao.repositories.product.ProductsTable +import org.eclipse.apoapsis.ortserver.dao.utils.apply import org.eclipse.apoapsis.ortserver.dao.utils.applyRegex +import org.eclipse.apoapsis.ortserver.dao.utils.extractIds import org.eclipse.apoapsis.ortserver.dao.utils.listQuery +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId import org.eclipse.apoapsis.ortserver.model.Hierarchy import org.eclipse.apoapsis.ortserver.model.RepositoryType import org.eclipse.apoapsis.ortserver.model.repositories.RepositoryRepository import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.OptionalValue import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SqlExpressionBuilder import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.deleteWhere @@ -56,17 +62,17 @@ class DaoRepositoryRepository(private val db: Database) : RepositoryRepository { Hierarchy(repository.mapToModel(), product.mapToModel(), organization.mapToModel()) } - override fun list(parameters: ListQueryParameters, filter: FilterParameter?) = + override fun list(parameters: ListQueryParameters, urlFilter: FilterParameter?, hierarchyFilter: HierarchyFilter) = db.blockingQuery { - RepositoryDao.listQuery(parameters, RepositoryDao::mapToModel) { - var condition: Op = Op.TRUE - filter?.let { - condition = condition and RepositoriesTable.url.applyRegex( - it.value - ) - } - condition + val urlCondition = urlFilter?.let { + RepositoriesTable.url.applyRegex(it.value) + } ?: Op.TRUE + + val builder = hierarchyFilter.apply(urlCondition) { level, ids, _ -> + generateHierarchyCondition(level, ids) } + + RepositoryDao.listQuery(parameters, RepositoryDao::mapToModel, builder) } override fun listForProduct(productId: Long, parameters: ListQueryParameters, filter: FilterParameter?) = @@ -101,3 +107,27 @@ class DaoRepositoryRepository(private val db: Database) : RepositoryRepository { RepositoriesTable.deleteWhere { RepositoriesTable.productId eq productId } } } + +/** + * Generate a condition defined by a [HierarchyFilter] for the given [level] and [ids]. + */ +private fun SqlExpressionBuilder.generateHierarchyCondition( + level: Int, + ids: List +): Op = + when (level) { + CompoundHierarchyId.REPOSITORY_LEVEL -> + RepositoriesTable.id inList ids.extractIds(CompoundHierarchyId.REPOSITORY_LEVEL) + + CompoundHierarchyId.PRODUCT_LEVEL -> + RepositoriesTable.productId inList ids.extractIds(CompoundHierarchyId.PRODUCT_LEVEL) + + CompoundHierarchyId.ORGANIZATION_LEVEL -> { + val subquery = ProductsTable.select(ProductsTable.id).where { + ProductsTable.organizationId inList ids.extractIds(CompoundHierarchyId.ORGANIZATION_LEVEL) + } + RepositoriesTable.productId inSubQuery subquery + } + + else -> Op.FALSE + } diff --git a/dao/src/main/kotlin/utils/Extensions.kt b/dao/src/main/kotlin/utils/Extensions.kt index d60ccde71c..912f89bcb5 100644 --- a/dao/src/main/kotlin/utils/Extensions.kt +++ b/dao/src/main/kotlin/utils/Extensions.kt @@ -28,7 +28,9 @@ import kotlinx.datetime.minus import org.eclipse.apoapsis.ortserver.dao.ConditionBuilder import org.eclipse.apoapsis.ortserver.dao.QueryParametersException +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId import org.eclipse.apoapsis.ortserver.model.util.ComparisonOperator +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult import org.eclipse.apoapsis.ortserver.model.util.OrderDirection @@ -46,6 +48,7 @@ import org.jetbrains.exposed.sql.QueryParameter import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SizedIterable import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater import org.jetbrains.exposed.sql.SqlExpressionBuilder.greaterEq @@ -55,6 +58,8 @@ import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq import org.jetbrains.exposed.sql.SqlExpressionBuilder.neq import org.jetbrains.exposed.sql.SqlExpressionBuilder.notInList import org.jetbrains.exposed.sql.TextColumnType +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.or /** * Transform the given column to an [EntityID] when creating a DAO object. This can be used for foreign key columns to @@ -80,6 +85,45 @@ fun Column.transformToDatabasePrecision() = */ fun Instant.toDatabasePrecision() = minus(nanosecondsOfSecond, DateTimeUnit.NANOSECOND) +/** + * Extract the defined IDs on the specified [level] from the [CompoundHierarchyId]s in this collection as long values. + */ +fun Collection.extractIds(level: Int): List = mapNotNull { it[level]?.value } + +/** + * Definition of a function type for generating query conditions based on accessible hierarchy elements. The function + * has access to a [SqlExpressionBuilder] to create the conditions. It is passed the level in the hierarchy to filter + * by, a list with the IDs to be included together with their child elements, and the filter itself to gain access to + * additional properties. The function returns an [Op] representing the condition. The conditions for the different + * hierarchy levels are then combined using an `OR` operator. + */ +typealias HierarchyConditionGenerator = SqlExpressionBuilder.( + level: Int, + ids: List, + filter: HierarchyFilter +) -> Op + +/** + * Generate a condition for this [HierarchyFilter] using the provided [generator] function. The [generator] is + * responsible for creating the conditions on each hierarchy level. This function combines these conditions using an + * `OR` operator. The result is then combined with the optional [otherCondition] using an `AND` operator. + */ +fun HierarchyFilter.apply( + otherCondition: Op = Op.TRUE, + generator: HierarchyConditionGenerator +): ConditionBuilder = { + if (isWildcard) { + otherCondition + } else { + val hierarchyCondition = transitiveIncludes.entries.fold(Op.FALSE as Op) { op, (level, ids) -> + val condition = generator(this, level, ids, this@apply) + op or condition + } + + otherCondition and hierarchyCondition + } +} + /** * Run the provided [query] with the given [parameters] to create a [ListQueryResult]. The entities are mapped to the * corresponding model objects using the provided [entityMapper]. diff --git a/dao/src/test/kotlin/repositories/organization/DaoOrganizationRepositoryTest.kt b/dao/src/test/kotlin/repositories/organization/DaoOrganizationRepositoryTest.kt index aea74dc9b0..4b318ec496 100644 --- a/dao/src/test/kotlin/repositories/organization/DaoOrganizationRepositoryTest.kt +++ b/dao/src/test/kotlin/repositories/organization/DaoOrganizationRepositoryTest.kt @@ -25,8 +25,11 @@ import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId import org.eclipse.apoapsis.ortserver.model.Organization +import org.eclipse.apoapsis.ortserver.model.OrganizationId import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult import org.eclipse.apoapsis.ortserver.model.util.OptionalValue @@ -147,6 +150,46 @@ class DaoOrganizationRepositoryTest : StringSpec({ ) } + "list should apply a hierarchy filter" { + val createdOrg1 = organizationRepository.create("org1", "description1") + val org1Id = CompoundHierarchyId.forOrganization(OrganizationId(createdOrg1.id)) + val createdOrg2 = organizationRepository.create("org2", "description2") + val org2Id = CompoundHierarchyId.forOrganization(OrganizationId(createdOrg2.id)) + organizationRepository.create("org3", "description3") + + val hierarchyFilter = HierarchyFilter( + transitiveIncludes = mapOf(CompoundHierarchyId.ORGANIZATION_LEVEL to listOf(org1Id, org2Id)), + nonTransitiveIncludes = emptyMap(), + ) + val result = organizationRepository.list(hierarchyFilter = hierarchyFilter) + + result shouldBe ListQueryResult( + data = listOf(createdOrg1, createdOrg2), + params = ListQueryParameters.DEFAULT, + totalCount = 2 + ) + } + + "list should apply a hierarchy filter with non-transitive includes" { + val createdOrg1 = organizationRepository.create("org1", "description1") + val org1Id = CompoundHierarchyId.forOrganization(OrganizationId(createdOrg1.id)) + val createdOrg2 = organizationRepository.create("org2", "description2") + val org2Id = CompoundHierarchyId.forOrganization(OrganizationId(createdOrg2.id)) + organizationRepository.create("org3", "description3") + + val hierarchyFilter = HierarchyFilter( + transitiveIncludes = mapOf(CompoundHierarchyId.ORGANIZATION_LEVEL to listOf(org1Id)), + nonTransitiveIncludes = mapOf(CompoundHierarchyId.ORGANIZATION_LEVEL to listOf(org2Id)), + ) + val result = organizationRepository.list(hierarchyFilter = hierarchyFilter) + + result shouldBe ListQueryResult( + data = listOf(createdOrg1, createdOrg2), + params = ListQueryParameters.DEFAULT, + totalCount = 2 + ) + } + "update should update an entity in the database" { val createdOrg = organizationRepository.create("name", "description") diff --git a/dao/src/test/kotlin/repositories/product/DaoProductRepositoryTest.kt b/dao/src/test/kotlin/repositories/product/DaoProductRepositoryTest.kt index 95ad7b172e..3afa4da086 100644 --- a/dao/src/test/kotlin/repositories/product/DaoProductRepositoryTest.kt +++ b/dao/src/test/kotlin/repositories/product/DaoProductRepositoryTest.kt @@ -28,8 +28,12 @@ import io.kotest.matchers.shouldBe import org.eclipse.apoapsis.ortserver.dao.UniqueConstraintException import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId +import org.eclipse.apoapsis.ortserver.model.OrganizationId import org.eclipse.apoapsis.ortserver.model.Product +import org.eclipse.apoapsis.ortserver.model.ProductId import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult import org.eclipse.apoapsis.ortserver.model.util.OrderDirection @@ -112,7 +116,7 @@ class DaoProductRepositoryTest : StringSpec({ fixtures.createProduct("product-gateway") fixtures.createProduct("core-service") - productRepository.list(filter = FilterParameter("product$")) shouldBe ListQueryResult( + productRepository.list(nameFilter = FilterParameter("product$")) shouldBe ListQueryResult( data = listOf( Product(prod1.id, orgId, prod1.name, prod1.description), Product(prod2.id, orgId, prod2.name, prod2.description) @@ -128,7 +132,92 @@ class DaoProductRepositoryTest : StringSpec({ fixtures.createProduct("user-product") fixtures.createProduct("name") - productRepository.list(filter = FilterParameter("^product")) shouldBe ListQueryResult( + productRepository.list(nameFilter = FilterParameter("^product")) shouldBe ListQueryResult( + data = listOf( + Product(prod1.id, orgId, prod1.name, prod1.description), + Product(prod2.id, orgId, prod2.name, prod2.description) + ), + params = ListQueryParameters.DEFAULT, + totalCount = 2 + ) + } + + "list should apply a hierarchy filter on product level" { + val prod1 = fixtures.createProduct("prod1") + val prod1Id = CompoundHierarchyId.forProduct( + OrganizationId(fixtures.organization.id), + ProductId(prod1.id) + ) + val prod2 = fixtures.createProduct("prod2") + val prod2Id = CompoundHierarchyId.forProduct( + OrganizationId(fixtures.organization.id), + ProductId(prod2.id) + ) + fixtures.createProduct("prod3") + + val hierarchyFilter = HierarchyFilter( + transitiveIncludes = mapOf(CompoundHierarchyId.PRODUCT_LEVEL to listOf(prod1Id, prod2Id)), + nonTransitiveIncludes = emptyMap(), + ) + val result = productRepository.list(hierarchyFilter = hierarchyFilter) + + result shouldBe ListQueryResult( + data = listOf( + Product(prod1.id, orgId, prod1.name, prod1.description), + Product(prod2.id, orgId, prod2.name, prod2.description) + ), + params = ListQueryParameters.DEFAULT, + totalCount = 2 + ) + } + + "list should apply a hierarchy filter on organization level" { + val org2 = fixtures.createOrganization(name = "org2") + val org1Id = CompoundHierarchyId.forOrganization(OrganizationId(fixtures.organization.id)) + val org2Id = CompoundHierarchyId.forOrganization(OrganizationId(org2.id)) + + val prod1 = fixtures.createProduct("prod1") + val prod2 = fixtures.createProduct("prod2", organizationId = org2.id) + + val otherOrg = fixtures.createOrganization(name = "otherOrg") + fixtures.createProduct("prod3", organizationId = otherOrg.id) + + val hierarchyFilter = HierarchyFilter( + transitiveIncludes = mapOf(CompoundHierarchyId.ORGANIZATION_LEVEL to listOf(org1Id, org2Id)), + nonTransitiveIncludes = emptyMap(), + ) + val result = productRepository.list(hierarchyFilter = hierarchyFilter) + + result shouldBe ListQueryResult( + data = listOf( + Product(prod1.id, orgId, prod1.name, prod1.description), + Product(prod2.id, org2.id, prod2.name, prod2.description) + ), + params = ListQueryParameters.DEFAULT, + totalCount = 2 + ) + } + + "list should apply a filter with non-transitive includes" { + val prod1 = fixtures.createProduct("prod1") + val prod1Id = CompoundHierarchyId.forProduct( + OrganizationId(fixtures.organization.id), + ProductId(prod1.id) + ) + val prod2 = fixtures.createProduct("prod2") + val prod2Id = CompoundHierarchyId.forProduct( + OrganizationId(fixtures.organization.id), + ProductId(prod2.id) + ) + fixtures.createProduct("prod3") + + val hierarchyFilter = HierarchyFilter( + transitiveIncludes = mapOf(CompoundHierarchyId.PRODUCT_LEVEL to listOf(prod1Id)), + nonTransitiveIncludes = mapOf(CompoundHierarchyId.PRODUCT_LEVEL to listOf(prod2Id)), + ) + val result = productRepository.list(hierarchyFilter = hierarchyFilter) + + result shouldBe ListQueryResult( data = listOf( Product(prod1.id, orgId, prod1.name, prod1.description), Product(prod2.id, orgId, prod2.name, prod2.description) diff --git a/dao/src/test/kotlin/repositories/repository/DaoRepositoryRepositoryTest.kt b/dao/src/test/kotlin/repositories/repository/DaoRepositoryRepositoryTest.kt index 3d324c9ce8..d717653d38 100644 --- a/dao/src/test/kotlin/repositories/repository/DaoRepositoryRepositoryTest.kt +++ b/dao/src/test/kotlin/repositories/repository/DaoRepositoryRepositoryTest.kt @@ -28,10 +28,15 @@ import io.kotest.matchers.shouldBe import org.eclipse.apoapsis.ortserver.dao.UniqueConstraintException import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId import org.eclipse.apoapsis.ortserver.model.Hierarchy +import org.eclipse.apoapsis.ortserver.model.OrganizationId +import org.eclipse.apoapsis.ortserver.model.ProductId import org.eclipse.apoapsis.ortserver.model.Repository +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.RepositoryType import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult import org.eclipse.apoapsis.ortserver.model.util.OptionalValue @@ -121,7 +126,7 @@ class DaoRepositoryRepositoryTest : StringSpec({ fixtures.createRepository(url = "https://example.com/repo3.git") fixtures.createRepository(url = "https://example.com/repo4.git") - repositoryRepository.list(filter = FilterParameter("repository.git$")) shouldBe ListQueryResult( + repositoryRepository.list(urlFilter = FilterParameter("repository.git$")) shouldBe ListQueryResult( data = listOf( Repository(repo1.id, orgId, repo1.productId, repo1.type, repo1.url, repo1.description), Repository(repo2.id, orgId, repo2.productId, repo2.type, repo2.url, repo2.description), @@ -142,7 +147,7 @@ class DaoRepositoryRepositoryTest : StringSpec({ url = "https://subdomain.example.com/repo.git" ) - val result = repositoryRepository.list(filter = FilterParameter("example\\.com")) + val result = repositoryRepository.list(urlFilter = FilterParameter("example\\.com")) result shouldBe ListQueryResult( data = listOf( @@ -155,6 +160,102 @@ class DaoRepositoryRepositoryTest : StringSpec({ ) } + "list should apply a hierarchy filter on repository level" { + val repo1 = fixtures.createRepository(url = "https://example.com/repo1.git") + val repo1Id = CompoundHierarchyId.forRepository( + OrganizationId(fixtures.organization.id), + ProductId(fixtures.product.id), + RepositoryId(repo1.id) + ) + val repo2 = fixtures.createRepository(url = "https://example.com/repo2.git") + val repo2Id = CompoundHierarchyId.forRepository( + OrganizationId(fixtures.organization.id), + ProductId(fixtures.product.id), + RepositoryId(repo2.id) + ) + fixtures.createRepository(url = "https://example.com/repo3.git") + + val hierarchyFilter = HierarchyFilter( + transitiveIncludes = mapOf(CompoundHierarchyId.REPOSITORY_LEVEL to listOf(repo1Id, repo2Id)), + nonTransitiveIncludes = emptyMap() + ) + val result = repositoryRepository.list(hierarchyFilter = hierarchyFilter) + + result shouldBe ListQueryResult( + data = listOf( + Repository(repo1.id, orgId, repo1.productId, repo1.type, repo1.url, repo1.description), + Repository(repo2.id, orgId, repo2.productId, repo2.type, repo2.url, repo2.description), + ), + params = ListQueryParameters.DEFAULT, + totalCount = 2 + ) + } + + "list should apply a hierarchy filter on product level" { + val product1 = fixtures.createProduct(name = "product1") + val product1Id = CompoundHierarchyId.forProduct( + OrganizationId(fixtures.organization.id), + ProductId(product1.id) + ) + val repo1 = fixtures.createRepository(url = "https://example.com/repo1.git", productId = product1.id) + val product2 = fixtures.createProduct(name = "product2") + val product2Id = CompoundHierarchyId.forProduct( + OrganizationId(fixtures.organization.id), + ProductId(product2.id) + ) + val repo2 = fixtures.createRepository(url = "https://example.com/repo2.git", productId = product2.id) + fixtures.createRepository(url = "https://example.com/repo3.git") + + val hierarchyFilter = HierarchyFilter( + transitiveIncludes = mapOf(CompoundHierarchyId.PRODUCT_LEVEL to listOf(product1Id, product2Id)), + nonTransitiveIncludes = emptyMap() + ) + val result = repositoryRepository.list(hierarchyFilter = hierarchyFilter) + + result shouldBe ListQueryResult( + data = listOf( + Repository(repo1.id, orgId, repo1.productId, repo1.type, repo1.url, repo1.description), + Repository(repo2.id, orgId, repo2.productId, repo2.type, repo2.url, repo2.description), + ), + params = ListQueryParameters.DEFAULT, + totalCount = 2 + ) + } + + "list should apply a hierarchy filter on organization level" { + val organization1 = fixtures.createOrganization(name = "testOrganization") + val organization1Id = CompoundHierarchyId.forOrganization(OrganizationId(organization1.id)) + val product1 = fixtures.createProduct(name = "product1", organizationId = organization1.id) + val repo1 = fixtures.createRepository(url = "https://example.com/repo1.git", productId = product1.id) + + val organization2 = fixtures.createOrganization(name = "organization2") + val organization2Id = CompoundHierarchyId.forOrganization(OrganizationId(organization2.id)) + val product2 = fixtures.createProduct(name = "product2", organizationId = organization2.id) + val repo2 = fixtures.createRepository(url = "https://example.com/repo2.git", productId = product2.id) + + fixtures.createRepository(url = "https://example.com/repo3.git") + + val hierarchyFilter = HierarchyFilter( + transitiveIncludes = mapOf( + CompoundHierarchyId.ORGANIZATION_LEVEL to listOf( + organization1Id, + organization2Id + ) + ), + nonTransitiveIncludes = emptyMap() + ) + val result = repositoryRepository.list(hierarchyFilter = hierarchyFilter) + + result shouldBe ListQueryResult( + data = listOf( + Repository(repo1.id, organization1.id, repo1.productId, repo1.type, repo1.url, repo1.description), + Repository(repo2.id, organization2.id, repo2.productId, repo2.type, repo2.url, repo2.description), + ), + params = ListQueryParameters.DEFAULT, + totalCount = 2 + ) + } + "listForProduct should return all repositories for a product" { val type = RepositoryType.GIT diff --git a/model/src/commonMain/kotlin/repositories/OrganizationRepository.kt b/model/src/commonMain/kotlin/repositories/OrganizationRepository.kt index 7521be85ce..30db946dcd 100644 --- a/model/src/commonMain/kotlin/repositories/OrganizationRepository.kt +++ b/model/src/commonMain/kotlin/repositories/OrganizationRepository.kt @@ -21,6 +21,7 @@ package org.eclipse.apoapsis.ortserver.model.repositories import org.eclipse.apoapsis.ortserver.model.Organization import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult import org.eclipse.apoapsis.ortserver.model.util.OptionalValue @@ -40,11 +41,13 @@ interface OrganizationRepository { fun get(id: Long): Organization? /** - * List all organizations according to the given [parameters]. + * List all organizations according to the given [parameters]. Optionally, a [nameFilter] on the product name and a + * [hierarchyFilter] can be provided. */ fun list( parameters: ListQueryParameters = ListQueryParameters.DEFAULT, - filter: FilterParameter? = null + nameFilter: FilterParameter? = null, + hierarchyFilter: HierarchyFilter = HierarchyFilter.WILDCARD ): ListQueryResult /** diff --git a/model/src/commonMain/kotlin/repositories/ProductRepository.kt b/model/src/commonMain/kotlin/repositories/ProductRepository.kt index 54c795eeda..a376eca7fb 100644 --- a/model/src/commonMain/kotlin/repositories/ProductRepository.kt +++ b/model/src/commonMain/kotlin/repositories/ProductRepository.kt @@ -21,6 +21,7 @@ package org.eclipse.apoapsis.ortserver.model.repositories import org.eclipse.apoapsis.ortserver.model.Product import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult import org.eclipse.apoapsis.ortserver.model.util.OptionalValue @@ -40,11 +41,13 @@ interface ProductRepository { fun get(id: Long): Product? /** - * List all products according to the given [parameters]. + * List all products according to the given [parameters]. Optionally, a [nameFilter] on the product name and a + * [hierarchyFilter] can be provided. */ fun list( parameters: ListQueryParameters = ListQueryParameters.DEFAULT, - filter: FilterParameter? = null + nameFilter: FilterParameter? = null, + hierarchyFilter: HierarchyFilter = HierarchyFilter.WILDCARD ): ListQueryResult /** diff --git a/model/src/commonMain/kotlin/repositories/RepositoryRepository.kt b/model/src/commonMain/kotlin/repositories/RepositoryRepository.kt index 59bce37bc8..d5a1b73e8f 100644 --- a/model/src/commonMain/kotlin/repositories/RepositoryRepository.kt +++ b/model/src/commonMain/kotlin/repositories/RepositoryRepository.kt @@ -23,6 +23,7 @@ import org.eclipse.apoapsis.ortserver.model.Hierarchy import org.eclipse.apoapsis.ortserver.model.Repository import org.eclipse.apoapsis.ortserver.model.RepositoryType import org.eclipse.apoapsis.ortserver.model.util.FilterParameter +import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult import org.eclipse.apoapsis.ortserver.model.util.OptionalValue @@ -48,11 +49,13 @@ interface RepositoryRepository { fun getHierarchy(id: Long): Hierarchy /** - * List all repositories according to the given [parameters]. + * List all repositories according to the given [parameters]. Optionally, a [urlFilter] on the repository URL and a + * [hierarchyFilter] can be provided. */ fun list( parameters: ListQueryParameters = ListQueryParameters.DEFAULT, - filter: FilterParameter? = null + urlFilter: FilterParameter? = null, + hierarchyFilter: HierarchyFilter = HierarchyFilter.WILDCARD ): ListQueryResult /** diff --git a/model/src/commonMain/kotlin/util/HierarchyFilter.kt b/model/src/commonMain/kotlin/util/HierarchyFilter.kt new file mode 100644 index 0000000000..3ae18832b3 --- /dev/null +++ b/model/src/commonMain/kotlin/util/HierarchyFilter.kt @@ -0,0 +1,68 @@ +/* + * 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.model.util + +import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId + +/** + * A data class that holds information about elements in the hierarchy the current user has access to. A filter + * instance is created based on the user's permissions and can be used to generate `WHERE` conditions for database + * queries to only select accessible entities. + * + * In ORT Server's role model, permissions granted on higher levels of the hierarchy inherit down to lower levels. + * Therefore, if a user can access a specific element, the elements below in the hierarchy are accessible as well. + * + * There is also the case that an element on a higher level is implicitly accessible because the user has permissions + * on lower levels. Such elements must be added to results, but not their child elements. + * + * All these conditions are reflected by the different properties of this class. + */ +data class HierarchyFilter( + /** + * A [Map] with the IDs of elements that should be included together with their child element, grouped by their + * hierarchy level. + */ + val transitiveIncludes: Map>, + + /** + * A [Map] with the IDs of elements that should be included, but without their child elements, grouped by their + * hierarchy level. + */ + val nonTransitiveIncludes: Map>, + + /** + * A flag whether this filter is a wildcard filter, which means that it matches all elements in the hierarchy. + * If this is *true*, all other properties can be ignored, and no filter condition needs to be generated. Filters + * of this type are created if the user has superuser rights. + */ + val isWildcard: Boolean = false +) { + companion object { + /** + * A special instance of [HierarchyFilter] that declares itself as a wildcard filter and therefore matches + * all entities. This filter should not alter any query results. + */ + val WILDCARD = HierarchyFilter( + transitiveIncludes = emptyMap(), + nonTransitiveIncludes = emptyMap(), + isWildcard = true + ) + } +}