Skip to content

Commit d91a0b3

Browse files
committed
feat(authorization): Extend role assignment information for users
Introduce a new `RoleInfo` class that stores additional information on which hierarchy level a role has been assigned to a user. Change `AuthorizationService.listUsers()` to return such `RoleInfo` objects. This additional information can be used by the UI to distinguish between users who have been explicitly assigned to a hierarchy element and those with roles inherited from other elements. For now, the REST API only uses this new functionality to filter out users with inherited roles. This can be extended later to support more advanced views about users in the UI. Signed-off-by: Oliver Heger <[email protected]>
1 parent d6e11d7 commit d91a0b3

File tree

11 files changed

+203
-31
lines changed

11 files changed

+203
-31
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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.rights
21+
22+
import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
23+
24+
/**
25+
* A data class storing information about a role assignment. An instance holds the assigned [Role] and information
26+
* about where in the hierarchy the role was assigned. This makes it possible to distinguish between explicit role
27+
* assignments for a specific level and inherited role assignments from other levels.
28+
*/
29+
data class RoleInfo(
30+
/** The assigned [Role]. */
31+
val role: Role,
32+
33+
/** The hierarchy ID where the role was assigned. */
34+
val assignedAt: CompoundHierarchyId
35+
)

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import org.eclipse.apoapsis.ortserver.components.authorization.rights.Permission
2525
import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductPermission
2626
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryPermission
2727
import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role
28+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RoleInfo
2829
import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
2930
import org.eclipse.apoapsis.ortserver.model.HierarchyId
3031
import org.eclipse.apoapsis.ortserver.model.util.HierarchyFilter
@@ -93,11 +94,12 @@ interface AuthorizationService {
9394
suspend fun listUsersWithRole(role: Role, compoundHierarchyId: CompoundHierarchyId): Set<String>
9495

9596
/**
96-
* Return a [Map] with the IDs of all users and their assigned role on the hierarchy element identified by the
97-
* given [compoundHierarchyId]. The result includes users who inherit access rights on this hierarchy element from
98-
* higher levels, such as organization admins, but no superusers.
97+
* Return a [Map] with the IDs of all users and information about their assigned role on the hierarchy element
98+
* identified by the given [compoundHierarchyId]. The result includes users who inherit access rights on this
99+
* hierarchy element from higher levels, such as organization admins, but no superusers. The [RoleInfo] objects
100+
* can be used to find out from where users have been granted their access rights.
99101
*/
100-
suspend fun listUsers(compoundHierarchyId: CompoundHierarchyId): Map<String, Role>
102+
suspend fun listUsers(compoundHierarchyId: CompoundHierarchyId): Map<String, RoleInfo>
101103

102104
/**
103105
* Return a [HierarchyFilter] with information about all hierarchy elements for which the specified [userId] has at

components/authorization/backend/src/main/kotlin/service/DbAuthorizationService.kt

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductRol
3737
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryPermission
3838
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryRole
3939
import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role
40+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RoleInfo
4041
import org.eclipse.apoapsis.ortserver.dao.dbQuery
4142
import org.eclipse.apoapsis.ortserver.dao.repositories.product.ProductsTable
4243
import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable
@@ -174,7 +175,7 @@ class DbAuthorizationService(
174175
}.mapTo(mutableSetOf()) { it[RoleAssignmentsTable.userId] }
175176
}
176177

177-
override suspend fun listUsers(compoundHierarchyId: CompoundHierarchyId): Map<String, Role> =
178+
override suspend fun listUsers(compoundHierarchyId: CompoundHierarchyId): Map<String, RoleInfo> =
178179
withContext(Dispatchers.Default) {
179180
db.dbQuery {
180181
logger.debug("Loading role assignments on element {}...", compoundHierarchyId)
@@ -414,7 +415,7 @@ private fun computeRoleForUser(
414415
user: String,
415416
hierarchyId: CompoundHierarchyId,
416417
assignments: List<Pair<CompoundHierarchyId, Role>>
417-
): Role? {
418+
): RoleInfo? {
418419
logger.debug("Computing effective role for user '{}' on element {}...", user, hierarchyId)
419420

420421
return findHighestRole(assignments, hierarchyId)?.first
@@ -455,17 +456,17 @@ private fun IdsByLevel.filterContainedIn(
455456
private fun findHighestRole(
456457
roleAssignments: List<Pair<CompoundHierarchyId, Role>>,
457458
hierarchyId: CompoundHierarchyId
458-
): Triple<Role, PermissionChecker, HierarchyPermissions>? {
459+
): Triple<RoleInfo, PermissionChecker, HierarchyPermissions>? {
459460
val roles = (
460461
Role.rolesForLevel(hierarchyId.level)
461462
.takeUnless { it.isEmpty() } ?: OrganizationRole.entries
462463
).reversed().asSequence()
463464

464465
return roles.mapNotNull { role ->
465466
val permissionChecker = HierarchyPermissions.permissions(role)
466-
HierarchyPermissions.create(roleAssignments, permissionChecker)
467-
.takeIf { it.hasPermission(hierarchyId) }?.let {
468-
Triple(role, permissionChecker, it)
469-
}
467+
val permissions = HierarchyPermissions.create(roleAssignments, permissionChecker)
468+
permissions.permissionGrantedOnLevel(hierarchyId)?.let {
469+
Triple(RoleInfo(role, it), permissionChecker, permissions)
470+
}
470471
}.firstOrNull()
471472
}

components/authorization/backend/src/test/kotlin/service/DbAuthorizationServiceTest.kt

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import org.eclipse.apoapsis.ortserver.components.authorization.rights.ProductRol
4242
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryPermission
4343
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RepositoryRole
4444
import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role
45+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RoleInfo
4546
import org.eclipse.apoapsis.ortserver.dao.dbQuery
4647
import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension
4748
import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
@@ -626,6 +627,9 @@ class DbAuthorizationServiceTest : WordSpec() {
626627
val repository2 = dbExtension.fixtures.createRepository(url = "https://example.com/other.git")
627628
val product2 = dbExtension.fixtures.createProduct("otherProduct")
628629
val organization2 = dbExtension.fixtures.createOrganization("otherOrg")
630+
val organizationId = CompoundHierarchyId.forOrganization(
631+
OrganizationId(dbExtension.fixtures.organization.id)
632+
)
629633
val writerUser = "writer-user"
630634
val productAdminUser = "product-admin-user"
631635
val organizationAdminUser = "organization-admin-user"
@@ -663,7 +667,7 @@ class DbAuthorizationServiceTest : WordSpec() {
663667
service.assignRole(
664668
organizationAdminUser,
665669
OrganizationRole.ADMIN,
666-
CompoundHierarchyId.forOrganization(OrganizationId(dbExtension.fixtures.organization.id))
670+
organizationId
667671
)
668672

669673
val users = service.listUsers(repositoryCompoundId)
@@ -674,10 +678,10 @@ class DbAuthorizationServiceTest : WordSpec() {
674678
organizationAdminUser
675679
)
676680

677-
users[USER_ID] shouldBe RepositoryRole.READER
678-
users[writerUser] shouldBe RepositoryRole.WRITER
679-
users[productAdminUser] shouldBe RepositoryRole.ADMIN
680-
users[organizationAdminUser] shouldBe RepositoryRole.ADMIN
681+
users[USER_ID] shouldBe RoleInfo(RepositoryRole.READER, repositoryCompoundId)
682+
users[writerUser] shouldBe RoleInfo(RepositoryRole.WRITER, repositoryCompoundId)
683+
users[productAdminUser] shouldBe RoleInfo(RepositoryRole.ADMIN, repositoryCompoundId.parent!!)
684+
users[organizationAdminUser] shouldBe RoleInfo(RepositoryRole.ADMIN, organizationId)
681685
}
682686

683687
"list users with assignments on organization level" {
@@ -703,10 +707,10 @@ class DbAuthorizationServiceTest : WordSpec() {
703707
repoReaderUser
704708
)
705709

706-
users[USER_ID] shouldBe OrganizationRole.READER
707-
users[writerUser] shouldBe OrganizationRole.WRITER
708-
users[adminUser] shouldBe OrganizationRole.ADMIN
709-
users[repoReaderUser] shouldBe OrganizationRole.READER
710+
users[USER_ID] shouldBe RoleInfo(OrganizationRole.READER, organizationCompoundId)
711+
users[writerUser] shouldBe RoleInfo(OrganizationRole.WRITER, organizationCompoundId)
712+
users[adminUser] shouldBe RoleInfo(OrganizationRole.ADMIN, organizationCompoundId)
713+
users[repoReaderUser] shouldBe RoleInfo(OrganizationRole.READER, repositoryCompoundId)
710714
}
711715

712716
"list users with implicit rights from lower levels" {
@@ -718,24 +722,27 @@ class DbAuthorizationServiceTest : WordSpec() {
718722
val users = service.listUsers(repositoryCompoundId.parent!!)
719723
users shouldHaveSize 1
720724

721-
users[USER_ID] shouldBe ProductRole.READER
725+
users[USER_ID] shouldBe RoleInfo(ProductRole.READER, repositoryCompoundId)
722726
}
723727

724728
"inherit roles from higher levels" {
725729
val repositoryCompoundId = repositoryCompoundId()
730+
val orgCompoundId = CompoundHierarchyId.forOrganization(
731+
OrganizationId(dbExtension.fixtures.organization.id)
732+
)
726733
val service = createService()
727734

728735
service.assignRole(
729736
USER_ID,
730737
OrganizationRole.WRITER,
731-
CompoundHierarchyId.forOrganization(OrganizationId(dbExtension.fixtures.organization.id))
738+
orgCompoundId
732739
)
733740
service.assignRole(USER_ID, RepositoryRole.READER, repositoryCompoundId)
734741

735742
val users = service.listUsers(repositoryCompoundId)
736743
users shouldHaveSize 1
737744

738-
users[USER_ID] shouldBe RepositoryRole.WRITER
745+
users[USER_ID] shouldBe RoleInfo(RepositoryRole.WRITER, orgCompoundId)
739746
}
740747

741748
"not include super users" {
@@ -770,7 +777,7 @@ class DbAuthorizationServiceTest : WordSpec() {
770777

771778
users.entries.shouldBeSingleton { (key, value) ->
772779
key shouldBe USER_ID
773-
value shouldBe RepositoryRole.READER
780+
value shouldBe RoleInfo(RepositoryRole.READER, repositoryCompoundId)
774781
}
775782
}
776783
}

core/src/main/kotlin/api/OrganizationsRoute.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,8 @@ fun Route.organizations() = route("organizations") {
375375
)
376376
val pagingOptions = call.pagingOptions(SortProperty("username", SortDirection.ASCENDING))
377377

378-
val users = authorizationService.listUsers(orgId).mapToApi(userService)
378+
val users = authorizationService.listUsers(orgId)
379+
.mapToApi(userService) { it.assignedAt.level == CompoundHierarchyId.ORGANIZATION_LEVEL }
379380
call.respond(
380381
PagedResponse(users.sortAndPage(pagingOptions), pagingOptions.toPagingData(users.size.toLong()))
381382
)

core/src/main/kotlin/api/ProductsRoute.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import org.eclipse.apoapsis.ortserver.core.services.OrchestratorService
6464
import org.eclipse.apoapsis.ortserver.core.utils.getPluginConfigs
6565
import org.eclipse.apoapsis.ortserver.core.utils.hasKeepAliveWorkerFlag
6666
import org.eclipse.apoapsis.ortserver.core.utils.vulnerabilityForRunsFilters
67+
import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
6768
import org.eclipse.apoapsis.ortserver.model.Repository
6869
import org.eclipse.apoapsis.ortserver.model.UserDisplayName
6970
import org.eclipse.apoapsis.ortserver.model.VulnerabilityWithAccumulatedData
@@ -425,7 +426,7 @@ fun Route.products() = route("products/{productId}") {
425426
val pagingOptions = call.pagingOptions(SortProperty("username", SortDirection.ASCENDING))
426427

427428
val users = authorizationService.listUsers(call.ortServerPrincipal.effectiveRole.elementId)
428-
.mapToApi(userService)
429+
.mapToApi(userService) { it.assignedAt.level == CompoundHierarchyId.PRODUCT_LEVEL }
429430

430431
call.respond(
431432
PagedResponse(users.sortAndPage(pagingOptions), pagingOptions.toPagingData(users.size.toLong()))

core/src/main/kotlin/api/RepositoriesRoute.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import org.eclipse.apoapsis.ortserver.core.apiDocs.putRepositoryRoleToUser
5959
import org.eclipse.apoapsis.ortserver.core.services.OrchestratorService
6060
import org.eclipse.apoapsis.ortserver.core.utils.getPluginConfigs
6161
import org.eclipse.apoapsis.ortserver.core.utils.hasKeepAliveWorkerFlag
62+
import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
6263
import org.eclipse.apoapsis.ortserver.model.UserDisplayName
6364
import org.eclipse.apoapsis.ortserver.services.RepositoryService
6465
import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService
@@ -247,7 +248,7 @@ fun Route.repositories() = route("repositories/{repositoryId}") {
247248
val pagingOptions = call.pagingOptions(SortProperty("username", SortDirection.ASCENDING))
248249

249250
val users = authorizationService.listUsers(call.ortServerPrincipal.effectiveRole.elementId)
250-
.mapToApi(userService)
251+
.mapToApi(userService) { it.assignedAt.level == CompoundHierarchyId.REPOSITORY_LEVEL }
251252

252253
call.respond(
253254
PagedResponse(users.sortAndPage(pagingOptions), pagingOptions.toPagingData(users.size.toLong()))

core/src/main/kotlin/api/UserWithGroupsHelper.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ package org.eclipse.apoapsis.ortserver.core.api
2121

2222
import org.eclipse.apoapsis.ortserver.api.v1.mapping.mapToApi
2323
import org.eclipse.apoapsis.ortserver.api.v1.model.UserWithGroups
24-
import org.eclipse.apoapsis.ortserver.components.authorization.rights.Role
24+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.RoleInfo
2525
import org.eclipse.apoapsis.ortserver.components.authorization.routes.mapToGroup
2626
import org.eclipse.apoapsis.ortserver.components.authorization.service.UserService
2727
import org.eclipse.apoapsis.ortserver.dao.QueryParametersException
@@ -70,11 +70,20 @@ internal object UserWithGroupsHelper {
7070
)
7171
}
7272

73-
internal suspend fun Map<String, Role>.mapToApi(userService: UserService): List<UserWithGroups> {
73+
/**
74+
* Convert this [Map] with information about users and their assigned roles to a list of [UserWithGroups] objects
75+
* that can be used to display user / group information for a specific hierarchy element. Apply the given
76+
* [roleFilter] to select only specific items based on their role information. This can be used for instance, to
77+
* distinguish between users with an explicit role assignment and users who only have inherited roles.
78+
*/
79+
internal suspend fun Map<String, RoleInfo>.mapToApi(
80+
userService: UserService,
81+
roleFilter: (RoleInfo) -> Boolean = { true }
82+
): List<UserWithGroups> {
7483
val userMapping = userService.getUsersById(keys).associateBy(User::username)
7584

76-
return filter { it.key in userMapping }
85+
return filter { it.key in userMapping && roleFilter(it.value) }
7786
.mapKeys { userMapping.getValue(it.key) }
78-
.mapValues { (_, role) -> setOf(role.mapToGroup()) }.mapToApi()
87+
.mapValues { (_, roleInfo) -> setOf(roleInfo.role.mapToGroup()) }.mapToApi()
7988
}
8089
}

core/src/test/kotlin/api/OrganizationsRouteIntegrationTest.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,6 +1833,48 @@ class OrganizationsRouteIntegrationTest : AbstractIntegrationTest({
18331833
}
18341834
}
18351835

1836+
"filter out users with inherited roles" {
1837+
integrationTestApplication {
1838+
val orgId = createOrganization().id
1839+
val orgHierarchyId = CompoundHierarchyId.forOrganization(OrganizationId(orgId))
1840+
val productId = dbExtension.fixtures.createProduct(organizationId = orgId).id
1841+
val repositoryId = dbExtension.fixtures.createRepository(productId = productId).id
1842+
1843+
authorizationService.assignRole(
1844+
SUPERUSER.username.value,
1845+
OrganizationRole.WRITER,
1846+
orgHierarchyId
1847+
)
1848+
authorizationService.assignRole(
1849+
TEST_USER.username.value,
1850+
RepositoryRole.READER,
1851+
CompoundHierarchyId.forRepository(
1852+
OrganizationId(orgId),
1853+
ProductId(productId),
1854+
RepositoryId(repositoryId)
1855+
)
1856+
)
1857+
1858+
val response = superuserClient.get("/api/v1/organizations/$orgId/users")
1859+
1860+
response shouldHaveStatus HttpStatusCode.OK
1861+
response shouldHaveBody PagedResponse(
1862+
listOf(
1863+
ApiUserWithGroups(
1864+
ApiUser(SUPERUSER.username.value, SUPERUSER.firstName, SUPERUSER.lastName, SUPERUSER.email),
1865+
listOf(ApiUserGroup.WRITERS)
1866+
)
1867+
),
1868+
PagingData(
1869+
limit = DEFAULT_LIMIT,
1870+
offset = 0,
1871+
totalCount = 1,
1872+
sortProperties = listOf(SortProperty("username", SortDirection.ASCENDING))
1873+
)
1874+
)
1875+
}
1876+
}
1877+
18361878
"respond with 'Bad Request' if there is more than one sort field" {
18371879
integrationTestApplication {
18381880
val orgId = createOrganization().id

core/src/test/kotlin/api/ProductsRouteIntegrationTest.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,6 +1499,44 @@ class ProductsRouteIntegrationTest : AbstractIntegrationTest({
14991499
}
15001500
}
15011501

1502+
"filter out users with inherited roles" {
1503+
integrationTestApplication {
1504+
val product = createProduct()
1505+
val productId = product.id
1506+
val productHierarchyId = CompoundHierarchyId.forProduct(
1507+
OrganizationId(product.organizationId),
1508+
ProductId(productId)
1509+
)
1510+
1511+
authorizationService.assignRole(TEST_USER.username.value, ProductRole.READER, productHierarchyId)
1512+
authorizationService.assignRole(
1513+
SUPERUSER.username.value,
1514+
ProductRole.ADMIN,
1515+
CompoundHierarchyId.forOrganization(
1516+
OrganizationId(product.organizationId)
1517+
)
1518+
)
1519+
1520+
val response = superuserClient.get("/api/v1/products/$productId/users")
1521+
1522+
response shouldHaveStatus HttpStatusCode.OK
1523+
response shouldHaveBody PagedResponse(
1524+
listOf(
1525+
ApiUserWithGroups(
1526+
ApiUser(TEST_USER.username.value, TEST_USER.firstName, TEST_USER.lastName, TEST_USER.email),
1527+
listOf(ApiUserGroup.READERS)
1528+
)
1529+
),
1530+
PagingData(
1531+
limit = DEFAULT_LIMIT,
1532+
offset = 0,
1533+
totalCount = 1,
1534+
sortProperties = listOf(SortProperty("username", SortDirection.ASCENDING))
1535+
)
1536+
)
1537+
}
1538+
}
1539+
15021540
"respond with 'Bad Request' if there is more than one sort field" {
15031541
integrationTestApplication {
15041542
val productId = createProduct().id

0 commit comments

Comments
 (0)