Skip to content

Commit cf0d465

Browse files
committed
feat(authorization): Introduce functions for authorized routes
Extend the Ktor DSL for route definitions by functions that perform an automatic check for permissions based on provided `AuthorizationChecker` objects. This enables a declarative approach for permission checks. Provide a concrete `AuthorizationChecker` implementation for organization permissions. Add a test class to test this approach. Note that currently failed permission checks yield a return status code of 401 rather than 403. This is going to be changed in an upcoming commit. Signed-off-by: Oliver Heger <[email protected]>
1 parent 8ffa71d commit cf0d465

File tree

5 files changed

+512
-0
lines changed

5 files changed

+512
-0
lines changed

components/authorization/backend/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,20 @@ dependencies {
3232
api(ktorLibs.server.auth)
3333
api(ktorLibs.server.auth.jwt)
3434
api(ktorLibs.server.core)
35+
api(libs.ktorOpenApi)
3536

3637
implementation(projects.dao)
38+
implementation(projects.shared.ktorUtils)
3739

3840
implementation(libs.exposedCore)
3941
implementation(libs.exposedJdbc)
4042

4143
testImplementation(testFixtures(projects.dao))
4244

45+
testImplementation(ktorLibs.client.contentNegotiation)
46+
testImplementation(ktorLibs.client.core)
47+
testImplementation(ktorLibs.server.testHost)
48+
testImplementation(ktorLibs.utils)
4349
testImplementation(libs.kotestAssertionsCore)
4450
testImplementation(libs.kotestAssertionsKtor)
4551
testImplementation(libs.kotestRunnerJunit5)

components/authorization/backend/src/main/kotlin/rights/EffectiveRole.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ import org.eclipse.apoapsis.ortserver.model.CompoundHierarchyId
2828
* provided here, client code can check whether the user has the required permissions to perform the requested action.
2929
*/
3030
interface EffectiveRole {
31+
companion object {
32+
/**
33+
* A special instance of [EffectiveRole] that does not contain any permissions and is not associated with any
34+
* specific hierarchy element.
35+
*/
36+
val EMPTY: EffectiveRole = object : EffectiveRole {
37+
override val elementId: CompoundHierarchyId = CompoundHierarchyId.WILDCARD
38+
39+
override val isSuperuser: Boolean = false
40+
41+
override fun hasOrganizationPermission(permission: OrganizationPermission): Boolean = false
42+
43+
override fun hasProductPermission(permission: ProductPermission): Boolean = false
44+
45+
override fun hasRepositoryPermission(permission: RepositoryPermission): Boolean = false
46+
}
47+
}
48+
3149
/**
3250
* The compound ID of the hierarchy element this effective role applies to. This object contains the aggregated
3351
* permissions of the current user for this element.

components/authorization/backend/src/main/kotlin/routes/AuthorizationChecker.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ package org.eclipse.apoapsis.ortserver.components.authorization.routes
2222
import io.ktor.server.application.ApplicationCall
2323

2424
import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole
25+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.OrganizationPermission
2526
import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService
27+
import org.eclipse.apoapsis.ortserver.model.OrganizationId
28+
import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter
2629

2730
/**
2831
* An interface defining a mechanism to check for required permissions using an [AuthorizationService] instance.
@@ -53,3 +56,21 @@ interface AuthorizationChecker {
5356
*/
5457
fun checkAuthorization(effectiveRole: EffectiveRole): Boolean
5558
}
59+
60+
/**
61+
* Create an [AuthorizationChecker] that checks for the presence of the given organization-level [permission].
62+
*/
63+
fun requirePermission(permission: OrganizationPermission): AuthorizationChecker =
64+
object : AuthorizationChecker {
65+
override suspend fun loadEffectiveRole(
66+
service: AuthorizationService,
67+
userId: String,
68+
call: ApplicationCall
69+
): EffectiveRole =
70+
service.getEffectiveRole(userId, OrganizationId(call.requireIdParameter("organizationId")))
71+
72+
override fun checkAuthorization(effectiveRole: EffectiveRole): Boolean =
73+
effectiveRole.hasOrganizationPermission(permission)
74+
75+
override fun toString(): String = "RequireOrganizationPermission($permission)"
76+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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.routes
21+
22+
import com.auth0.jwt.interfaces.Payload
23+
24+
import io.github.smiley4.ktoropenapi.config.RouteConfig
25+
import io.github.smiley4.ktoropenapi.delete
26+
import io.github.smiley4.ktoropenapi.get
27+
import io.github.smiley4.ktoropenapi.patch
28+
import io.github.smiley4.ktoropenapi.post
29+
import io.github.smiley4.ktoropenapi.put
30+
31+
import io.ktor.server.application.ApplicationCall
32+
import io.ktor.server.routing.Route
33+
import io.ktor.server.routing.RouteSelector
34+
import io.ktor.server.routing.RouteSelectorEvaluation
35+
import io.ktor.server.routing.RoutingContext
36+
import io.ktor.server.routing.RoutingPipelineCall
37+
import io.ktor.server.routing.RoutingResolveContext
38+
import io.ktor.util.AttributeKey
39+
40+
import org.eclipse.apoapsis.ortserver.components.authorization.rights.EffectiveRole
41+
import org.eclipse.apoapsis.ortserver.components.authorization.service.AuthorizationService
42+
43+
/**
44+
* Create an [OrtServerPrincipal] for this [ApplicationCall]. If an [AuthorizationChecker] is present in the current
45+
* context, use it to an [EffectiveRole] and perform an authorization check. Result is *null* if this check fails.
46+
*/
47+
suspend fun ApplicationCall.createAuthorizedPrincipal(
48+
authorizationService: AuthorizationService,
49+
payload: Payload
50+
): OrtServerPrincipal? =
51+
(this as? RoutingPipelineCall)?.let { routingCall ->
52+
val checker = routingCall.route.findAuthorizationChecker()
53+
54+
val effectiveRole = if (checker != null) {
55+
checker.loadEffectiveRole(
56+
service = authorizationService,
57+
userId = payload.getClaim("preferred_username").asString(),
58+
call = this
59+
).takeIf { checker.checkAuthorization(it) }
60+
} else {
61+
EffectiveRole.EMPTY
62+
}
63+
64+
effectiveRole?.let { OrtServerPrincipal.create(payload, it) }
65+
}
66+
67+
/**
68+
* Create a new [Route] for HTTP GET requests that performs an automatic authorization check using the given [checker].
69+
*/
70+
fun Route.get(
71+
builder: RouteConfig.() -> Unit,
72+
checker: AuthorizationChecker,
73+
body: suspend RoutingContext.() -> Unit
74+
): Route = documentedAuthorized(checker) { get(builder, body) }
75+
76+
/**
77+
* Create a new [Route] for HTTP POST requests that performs an automatic authorization check using the given [checker].
78+
*/
79+
fun Route.post(
80+
builder: RouteConfig.() -> Unit,
81+
checker: AuthorizationChecker,
82+
body: suspend RoutingContext.() -> Unit
83+
): Route = documentedAuthorized(checker) { post(builder, body) }
84+
85+
/**
86+
* Create a new [Route] for HTTP PATCH requests that performs an automatic authorization check using the given
87+
* [checker].
88+
*/
89+
fun Route.patch(
90+
builder: RouteConfig.() -> Unit,
91+
checker: AuthorizationChecker,
92+
body: suspend RoutingContext.() -> Unit
93+
): Route = documentedAuthorized(checker) { patch(builder, body) }
94+
95+
/**
96+
* Create a new [Route] for HTTP PUT requests that performs an automatic authorization check using the given
97+
* [checker].
98+
*/
99+
fun Route.put(
100+
builder: RouteConfig.() -> Unit,
101+
checker: AuthorizationChecker,
102+
body: suspend RoutingContext.() -> Unit
103+
): Route = documentedAuthorized(checker) { put(builder, body) }
104+
105+
/**
106+
* Create a new [Route] for HTTP DELETE requests that performs an automatic authorization check using the given
107+
* [checker].
108+
*/
109+
fun Route.delete(
110+
builder: RouteConfig.() -> Unit,
111+
checker: AuthorizationChecker,
112+
body: suspend RoutingContext.() -> Unit
113+
): Route = documentedAuthorized(checker) { delete(builder, body) }
114+
115+
/**
116+
* Generic function to create a new [Route] that performs an automatic authorization check using the given [checker].
117+
* The content of the route is defined by the given [build] function.
118+
*/
119+
private fun Route.documentedAuthorized(checker: AuthorizationChecker, build: Route.() -> Unit): Route {
120+
val authorizedRoute = createChild(authorizedRouteSelector(checker.toString()))
121+
authorizedRoute.attributes.put(AuthorizationCheckerKey, checker)
122+
authorizedRoute.build()
123+
return authorizedRoute
124+
}
125+
126+
/**
127+
* Create a [RouteSelector] for a new authorized [Route] whose string representation is derived from the given [tag].
128+
*/
129+
private fun authorizedRouteSelector(tag: String): RouteSelector =
130+
object : RouteSelector() {
131+
override suspend fun evaluate(
132+
context: RoutingResolveContext,
133+
segmentIndex: Int
134+
): RouteSelectorEvaluation = RouteSelectorEvaluation.Transparent
135+
136+
override fun toString(): String {
137+
return "(authorized $tag)"
138+
}
139+
}
140+
141+
/**
142+
* Search for an [AuthorizationChecker] object in the context of the current [Route]. The checker has been defined
143+
* using the routes DSL. It may be available in this route or in any of its parent routes.
144+
*/
145+
private fun Route.findAuthorizationChecker(): AuthorizationChecker? =
146+
this.attributes.getOrNull(AuthorizationCheckerKey)
147+
?: parent?.findAuthorizationChecker()
148+
149+
/**
150+
* Constant for a key in the attributes of a [Route] to store an [AuthorizationChecker].
151+
*/
152+
private val AuthorizationCheckerKey = AttributeKey<AuthorizationChecker>("AuthorizationCheckerKey")
153+
154+
/**
155+
* An object defining constants for the names of supported authentication providers.
156+
*/
157+
object AuthenticationProviders {
158+
/**
159+
* The name of the authentication provider for authorization based on JWT tokens.
160+
*/
161+
const val TOKEN_PROVIDER = "token"
162+
}

0 commit comments

Comments
 (0)