Skip to content

Commit 7fabca3

Browse files
committed
feat(authorization): Implement AuthorizationService
Add the `DbAuthorizationService` class which uses the previously introduced data structures to store role and permission information. Signed-off-by: Oliver Heger <[email protected]>
1 parent 14c6955 commit 7fabca3

File tree

2 files changed

+1102
-0
lines changed

2 files changed

+1102
-0
lines changed
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
/*
2+
* Copyright (C) 2025 The ORT Server Authors (See <https://github.com/eclipse-apoapsis/ort-server/blob/main/NOTICE>)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* License-Filename: LICENSE
18+
*/
19+
20+
package org.eclipse.apoapsis.ortserver.components.authorization.service
21+
22+
import org.eclipse.apoapsis.ortserver.components.authorization.db.RoleAssignmentsTable
23+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole
24+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationPermission
25+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationRole
26+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductPermission
27+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductRole
28+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryPermission
29+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryRole
30+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role
31+
import org.eclipse.apoapsis.ortserver.dao.dbQuery
32+
import org.eclipse.apoapsis.ortserver.dao.repositories.product.ProductsTable
33+
import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable
34+
import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
35+
import org.eclipse.apoapsis.ortserver.model.HierarchyId
36+
import org.eclipse.apoapsis.ortserver.model.OrganizationId
37+
import org.eclipse.apoapsis.ortserver.model.ProductId
38+
import org.eclipse.apoapsis.ortserver.model.RepositoryId
39+
40+
import org.jetbrains.exposed.sql.Database
41+
import org.jetbrains.exposed.sql.JoinType
42+
import org.jetbrains.exposed.sql.Op
43+
import org.jetbrains.exposed.sql.ResultRow
44+
import org.jetbrains.exposed.sql.SqlExpressionBuilder
45+
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
46+
import org.jetbrains.exposed.sql.and
47+
import org.jetbrains.exposed.sql.deleteWhere
48+
import org.jetbrains.exposed.sql.insert
49+
import org.jetbrains.exposed.sql.or
50+
import org.jetbrains.exposed.sql.selectAll
51+
52+
import org.slf4j.LoggerFactory
53+
54+
private val logger = LoggerFactory.getLogger(DbAuthorizationService::class.java)
55+
56+
/**
57+
* An implementation of [AuthorizationService] storing information about role assignments in the database.
58+
*
59+
* Note that this implementation assumes that user IDs are managed in an external system. It does not interpret these
60+
* values, but just stores and retrieves them as provided.
61+
*/
62+
class DbAuthorizationService(
63+
/** The database to use. */
64+
private val db: Database
65+
) : AuthorizationService {
66+
override suspend fun getEffectiveRole(
67+
userId: String,
68+
compoundHierarchyId: CompoundHierarchyId
69+
): EffectiveRole {
70+
return EffectiveRoleImpl(
71+
elementId = compoundHierarchyId,
72+
permissions = loadAssignments(userId, compoundHierarchyId)
73+
.takeUnless { it.isEmpty() }?.reduce(::reducePermissions) ?: EMPTY_PERMISSIONS
74+
)
75+
}
76+
77+
override suspend fun getEffectiveRole(
78+
userId: String,
79+
hierarchyId: HierarchyId
80+
): EffectiveRole {
81+
val compoundHierarchyId = resolveCompoundId(hierarchyId)
82+
83+
return if (compoundHierarchyId.isInvalid()) {
84+
logger.warn("Failed to resolve hierarchy ID $hierarchyId.")
85+
86+
EffectiveRoleImpl(
87+
elementId = compoundHierarchyId,
88+
permissions = EMPTY_PERMISSIONS
89+
)
90+
} else {
91+
getEffectiveRole(userId, compoundHierarchyId)
92+
}
93+
}
94+
95+
override suspend fun assignRole(
96+
userId: String,
97+
role: Role,
98+
compoundHierarchyId: CompoundHierarchyId
99+
) {
100+
db.dbQuery {
101+
doRemoveAssignment(userId, compoundHierarchyId)
102+
103+
logger.info(
104+
"Assigning role '{}' to user '{}' on hierarchy element {}.",
105+
role,
106+
userId,
107+
compoundHierarchyId
108+
)
109+
110+
RoleAssignmentsTable.insert {
111+
it[RoleAssignmentsTable.userId] = userId
112+
it[RoleAssignmentsTable.organizationId] = compoundHierarchyId.organizationId?.value
113+
it[RoleAssignmentsTable.productId] = compoundHierarchyId.productId?.value
114+
it[RoleAssignmentsTable.repositoryId] = compoundHierarchyId.repositoryId?.value
115+
it[RoleAssignmentsTable.organizationRole] = (role as? OrganizationRole)?.name
116+
it[RoleAssignmentsTable.productRole] = (role as? ProductRole)?.name
117+
it[RoleAssignmentsTable.repositoryRole] = (role as? RepositoryRole)?.name
118+
}
119+
}
120+
}
121+
122+
override suspend fun removeAssignment(
123+
userId: String,
124+
compoundHierarchyId: CompoundHierarchyId
125+
): Boolean = db.dbQuery {
126+
doRemoveAssignment(userId, compoundHierarchyId)
127+
}
128+
129+
override suspend fun listUsersWithRole(
130+
role: Role,
131+
compoundHierarchyId: CompoundHierarchyId
132+
): Set<String> = db.dbQuery {
133+
RoleAssignmentsTable.select(RoleAssignmentsTable.userId)
134+
.where {
135+
(RoleAssignmentsTable.organizationId eq compoundHierarchyId.organizationId?.value) and
136+
(RoleAssignmentsTable.productId eq compoundHierarchyId.productId?.value) and
137+
(RoleAssignmentsTable.repositoryId eq compoundHierarchyId.repositoryId?.value) and
138+
roleCondition(role)
139+
}.mapTo(mutableSetOf()) { it[RoleAssignmentsTable.userId] }
140+
}
141+
142+
override suspend fun listUsers(compoundHierarchyId: CompoundHierarchyId): Map<String, Set<Role>> = db.dbQuery {
143+
RoleAssignmentsTable.selectAll()
144+
.where {
145+
repositoryCondition(compoundHierarchyId) or productWildcardCondition(compoundHierarchyId)
146+
}.map { row -> row[RoleAssignmentsTable.userId] to row.extractRole() }
147+
.filterNot { it.second == null }
148+
.groupBy(keySelector = { it.first }, valueTransform = { it.second })
149+
.mapValues { it.value.filterNotNullTo(mutableSetOf()) }
150+
}
151+
152+
/**
153+
* Retrieve the missing components to construct a [CompoundHierarchyId] from the given [hierarchyId]. Throw a
154+
* meaningful exception if this fails.
155+
*/
156+
private suspend fun resolveCompoundId(hierarchyId: HierarchyId): CompoundHierarchyId =
157+
when (hierarchyId) {
158+
is OrganizationId -> CompoundHierarchyId.forOrganization(hierarchyId)
159+
is ProductId -> CompoundHierarchyId.forProduct(resolveOrganization(hierarchyId), hierarchyId)
160+
is RepositoryId -> {
161+
val (orgId, prodId) = resolveOrganizationAndProduct(hierarchyId)
162+
CompoundHierarchyId.forRepository(orgId, prodId, hierarchyId)
163+
}
164+
}
165+
166+
/**
167+
* Retrieve the ID of the organization the product with the given [productId] belongs to. Return *null* if the
168+
* product does not exist.
169+
*/
170+
private suspend fun resolveOrganization(productId: ProductId): OrganizationId =
171+
db.dbQuery {
172+
ProductsTable.select(ProductsTable.organizationId)
173+
.where { ProductsTable.id eq productId.value }
174+
.singleOrNull()?.let {
175+
OrganizationId(it[ProductsTable.organizationId].value)
176+
}
177+
} ?: OrganizationId(INVALID_ID)
178+
179+
/**
180+
* Retrieve the IDs of the organization and product the repository with the given [repositoryId] belongs to.
181+
* Return *null* if IDs cannot be resolved.
182+
*/
183+
private suspend fun resolveOrganizationAndProduct(repositoryId: RepositoryId): Pair<OrganizationId, ProductId> =
184+
db.dbQuery {
185+
RepositoriesTable.join(ProductsTable, JoinType.INNER)
186+
.select(ProductsTable.organizationId, RepositoriesTable.productId)
187+
.where { RepositoriesTable.id eq repositoryId.value }
188+
.map { row ->
189+
Pair(
190+
OrganizationId(row[ProductsTable.organizationId].value),
191+
ProductId(row[RepositoriesTable.productId].value)
192+
)
193+
}.singleOrNull()
194+
} ?: Pair(OrganizationId(INVALID_ID), ProductId(INVALID_ID))
195+
196+
/**
197+
* Load all role assignments for the given [userId] in the hierarchy defined by [compoundHierarchyId]. Return a
198+
* list of [HierarchyPermissions] instances for the entities that were found.
199+
*/
200+
private suspend fun loadAssignments(
201+
userId: String,
202+
compoundHierarchyId: CompoundHierarchyId
203+
): List<HierarchyPermissions> = db.dbQuery {
204+
RoleAssignmentsTable.selectAll()
205+
.where {
206+
(RoleAssignmentsTable.userId eq userId) and (
207+
repositoryCondition(compoundHierarchyId) or
208+
productWildcardCondition(compoundHierarchyId) or
209+
organizationWildcardCondition()
210+
)
211+
}.map { it.toHierarchyPermissions() }
212+
}
213+
214+
/**
215+
* Remove the current role assignment for the user with the given [userId] on the hierarchy element defined by
216+
* [compoundHierarchyId] if there is one. The return value indicates whether an assignment was removed.
217+
*/
218+
private fun doRemoveAssignment(userId: String, compoundHierarchyId: CompoundHierarchyId): Boolean = (
219+
RoleAssignmentsTable.deleteWhere {
220+
(RoleAssignmentsTable.userId eq userId) and
221+
(RoleAssignmentsTable.organizationId eq compoundHierarchyId.organizationId?.value) and
222+
(RoleAssignmentsTable.productId eq compoundHierarchyId.productId?.value) and
223+
(RoleAssignmentsTable.repositoryId eq compoundHierarchyId.repositoryId?.value)
224+
} == 1
225+
).also {
226+
if (it) {
227+
logger.info(
228+
"Removed role assignment for user '{}' on hierarchy element {}.",
229+
userId,
230+
compoundHierarchyId
231+
)
232+
}
233+
}
234+
}
235+
236+
/**
237+
* Constant to represent an invalid ID in the database. This is used to determine whether resolving an ID failed.
238+
*/
239+
private const val INVALID_ID = -1L
240+
241+
/**
242+
* An internally used data class to store the available permissions on all levels of the hierarchy for a user.
243+
*/
244+
private data class HierarchyPermissions(
245+
/** The permissions granted on organization level. */
246+
val organizationPermissions: Set<OrganizationPermission>,
247+
248+
/** The permissions granted on product level. */
249+
val productPermissions: Set<ProductPermission>,
250+
251+
/** The permissions granted on repository level. */
252+
val repositoryPermissions: Set<RepositoryPermission>
253+
)
254+
255+
/** An instance of [HierarchyPermissions] with no permissions at all. */
256+
private val EMPTY_PERMISSIONS = HierarchyPermissions(
257+
organizationPermissions = emptySet(),
258+
productPermissions = emptySet(),
259+
repositoryPermissions = emptySet()
260+
)
261+
262+
/**
263+
* An implementation of the [EffectiveRole] interface used by [DbAuthorizationService].
264+
*/
265+
private class EffectiveRoleImpl(
266+
override val elementId: CompoundHierarchyId,
267+
268+
/** The permissions granted on the different levels of the hierarchy. */
269+
private val permissions: HierarchyPermissions
270+
) : EffectiveRole {
271+
override fun hasOrganizationPermission(permission: OrganizationPermission): Boolean =
272+
permission in permissions.organizationPermissions
273+
274+
override fun hasProductPermission(permission: ProductPermission): Boolean =
275+
permission in permissions.productPermissions
276+
277+
override fun hasRepositoryPermission(permission: RepositoryPermission): Boolean =
278+
permission in permissions.repositoryPermissions
279+
}
280+
281+
/**
282+
* Check whether this [CompoundHierarchyId] is invalid. This means that its components could not be resolved.
283+
*/
284+
private fun CompoundHierarchyId.isInvalid() =
285+
organizationId?.value == INVALID_ID ||
286+
productId?.value == INVALID_ID ||
287+
repositoryId?.value == INVALID_ID
288+
289+
/**
290+
* Fetch the concrete [Role] that is referenced by this [ResultRow].
291+
*/
292+
private fun ResultRow.extractRole(): Role? = runCatching {
293+
listOfNotNull(
294+
this[RoleAssignmentsTable.organizationRole]?.let(OrganizationRole::valueOf),
295+
this[RoleAssignmentsTable.productRole]?.let(ProductRole::valueOf),
296+
this[RoleAssignmentsTable.repositoryRole]?.let(RepositoryRole::valueOf)
297+
).first()
298+
}.onFailure {
299+
logger.error("Failed to extract role from database row: ${this[RoleAssignmentsTable.id]}", it)
300+
}.getOrNull()
301+
302+
/**
303+
* Obtain the information about roles from this [ResultRow] and construct a [HierarchyPermissions] object from it.
304+
*/
305+
private fun ResultRow.toHierarchyPermissions(): HierarchyPermissions =
306+
extractRole()?.let { role ->
307+
HierarchyPermissions(
308+
organizationPermissions = role.organizationPermissions,
309+
productPermissions = role.productPermissions,
310+
repositoryPermissions = role.repositoryPermissions
311+
)
312+
} ?: EMPTY_PERMISSIONS
313+
314+
/**
315+
* Combine two [HierarchyPermissions] instances [p1] and [p2] by constructing the union of their permissions on all
316+
* levels.
317+
*/
318+
private fun reducePermissions(
319+
p1: HierarchyPermissions,
320+
p2: HierarchyPermissions
321+
): HierarchyPermissions =
322+
HierarchyPermissions(
323+
organizationPermissions = p1.organizationPermissions + p2.organizationPermissions,
324+
productPermissions = p1.productPermissions + p2.productPermissions,
325+
repositoryPermissions = p1.repositoryPermissions + p2.repositoryPermissions
326+
)
327+
328+
/**
329+
* Generate the SQL condition to match the repository part of this [hierarchyId]. The condition also has to select
330+
* assignments on higher levels in the same hierarchy.
331+
*/
332+
private fun SqlExpressionBuilder.repositoryCondition(hierarchyId: CompoundHierarchyId): Op<Boolean> =
333+
(RoleAssignmentsTable.repositoryId eq hierarchyId.repositoryId?.value) or (
334+
(RoleAssignmentsTable.repositoryId eq null) and
335+
(RoleAssignmentsTable.productId eq hierarchyId.productId?.value) and
336+
(RoleAssignmentsTable.organizationId eq hierarchyId.organizationId?.value)
337+
)
338+
339+
/**
340+
* Generate the SQL condition to match role assignments for the given [hierarchyId] for which no product ID is
341+
* defined.
342+
*/
343+
private fun SqlExpressionBuilder.productWildcardCondition(hierarchyId: CompoundHierarchyId): Op<Boolean> =
344+
(RoleAssignmentsTable.productId eq null) and
345+
(RoleAssignmentsTable.organizationId eq hierarchyId.organizationId?.value)
346+
347+
/**
348+
* Generate the SQL condition to match role assignments for which no organization ID is defined.
349+
*/
350+
private fun SqlExpressionBuilder.organizationWildcardCondition(): Op<Boolean> =
351+
RoleAssignmentsTable.organizationId eq null
352+
353+
/**
354+
* Generate the SQL condition to match role assignments for the given [role].
355+
*/
356+
private fun SqlExpressionBuilder.roleCondition(role: Role): Op<Boolean> =
357+
when (role) {
358+
is OrganizationRole -> RoleAssignmentsTable.organizationRole eq role.name
359+
is ProductRole -> RoleAssignmentsTable.productRole eq role.name
360+
is RepositoryRole -> RoleAssignmentsTable.repositoryRole eq role.name
361+
}

0 commit comments

Comments
 (0)