Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .bruno/CG-DMF/collection.bru
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ script:pre-request {
client_id: clientId,
user_id: "bruno-gzac@example.com",
user_representation: "Bruno Client",
scope: "documenten.aanmaken documenten.lezen documenten.bijwerken documenten.verwijderen documenten.lock documenten.geforceerd-unlock audittrails.lezen"
};

const token = jwt.sign(payload, clientSecret, { algorithm: "HS256" });
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ services:
- '10000:10000'
- '10001:10001'
- '10002:10002'
command: 'azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0'
command: ' azurite --skipApiVersionCheck --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0'

notificaties-api:
image: openzaak/open-notificaties:latest
Expand Down
24 changes: 13 additions & 11 deletions src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package com.baseflow.api.documenten.routes

import com.baseflow.api.middleware.RequestScopeKey
import com.baseflow.api.models.AuditTrailResponse
import com.baseflow.api.routes.checkScope
import com.baseflow.services.AuditTrailService
import io.ktor.http.*
import io.ktor.openapi.jsonSchema
Expand All @@ -13,11 +14,9 @@ import io.ktor.server.routing.openapi.*
import io.ktor.utils.io.*
import java.util.*

private val RoutingContext.service: AuditTrailService
get() = call.attributes[RequestScopeKey].get()

@OptIn(ExperimentalKtorApi::class)
fun Route.auditTrailRoutes() {
// Require "audittrails.lezen" scope for all audit trail routes
route("/{uuid}/audittrail/{auditTrailUuid}") {
/**
* Een specifieke audit trail regel opvragen.
Expand All @@ -38,10 +37,10 @@ fun Route.auditTrailRoutes() {
* @tag AuditTrail
*/
get {
val resourceUuid =
call.parameters["uuid"]
?.let { UUID.fromString(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid resource UUID")
call.checkScope("audittrails.lezen")
val resourceUuid = call.parameters["uuid"]
?.let { UUID.fromString(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid resource UUID")

val auditTrailUuid =
call.parameters["auditTrailUuid"]
Expand Down Expand Up @@ -93,10 +92,10 @@ fun Route.auditTrailRoutes() {
* @tag AuditTrail
*/
get {
val resourceUuid =
call.parameters["uuid"]
?.let { UUID.fromString(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid UUID")
call.checkScope("audittrails.lezen")
val resourceUuid = call.parameters["uuid"]
?.let { UUID.fromString(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid UUID")

val auditTrails = service.listByResource(resourceUuid)
call.respond(auditTrails)
Expand All @@ -120,3 +119,6 @@ fun Route.auditTrailRoutes() {
}
}
}

private val RoutingContext.service: AuditTrailService
get() = call.attributes[RequestScopeKey].get()
133 changes: 133 additions & 0 deletions src/main/kotlin/api/documenten/routes/CheckScope.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: EUPL-1.2
// Copyright (C) 2026 Gemeente Utrecht
package com.baseflow.api.routes

import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import org.slf4j.LoggerFactory

private val logger = LoggerFactory.getLogger("ScopeAuthorizationPlugin")

/**
* Configuration for scope authorization.
*/
class ScopeAuthorizationConfig {
/**
* The name of the JWT claim containing scopes. Default is "scope" (OAuth2 standard).
*/
var scopeClaimName: String = "scope"

/**
* Whether to enable wildcard scope matching (e.g., "documenten.*" matches "documenten.lezen").
* Default is true.
*/
var wildcardEnabled: Boolean = true
}

fun ApplicationCall.checkScope(vararg requiredScopes: String) {
checkScope(requiredScopes.toSet())
}

fun ApplicationCall.checkScope(requiredScopes: Set<String>, config: ScopeAuthorizationConfig = ScopeAuthorizationConfig()) {
if (requiredScopes.isEmpty()) {
return
}

val principal = principal<JWTPrincipal>()

// If no principal, treat as unauthorized when scopes are required
if (principal == null) {
logger.warn("No JWT principal found while checking required scopes {}, denying access", requiredScopes)
throw ScopeAuthorizationException(
requiredScopes = requiredScopes,
)
}

// Extract scopes from JWT token
val userScopes = extractScopes(principal, config)
logger.debug("User scopes: {}", userScopes)
logger.debug("Route requires scopes: {}", requiredScopes)

// Check if user has all required scopes
val hasAllScopes = requiredScopes.all { required ->
userScopes.any { userScope ->
matchScope(userScope, required, config.wildcardEnabled)
}
}

if (!hasAllScopes) {
val missingScopes = requiredScopes.filter { required ->
!userScopes.any { userScope ->
matchScope(userScope, required, config.wildcardEnabled)
}
}
logger.warn(
"Access denied. User missing scopes: {}. User has: {}",
missingScopes,
userScopes,
)
throw ScopeAuthorizationException(
requiredScopes = requiredScopes,
)
} else {
logger.debug("Scope check passed")
}
}

/**
* Extract scopes from JWT token.
* Supports both "scope" (space-separated string) and "scopes" (array) claims.
*/
private fun extractScopes(principal: JWTPrincipal, config: ScopeAuthorizationConfig): Set<String> {
val scopes = mutableSetOf<String>()

// Try "scope" claim (space-separated string, OAuth2 standard)
principal.payload.getClaim(config.scopeClaimName)?.let { claim ->
if (!claim.isNull) {
when {
claim.asString() != null -> {
scopes.addAll(claim.asString().split(" ").filter { it.isNotBlank() })
}
claim.asList(String::class.java) != null -> {
scopes.addAll(claim.asList(String::class.java))
}
}
}
}

// Try alternative "scopes" claim (array)
if (config.scopeClaimName != "scopes") {
principal.payload.getClaim("scopes")?.let { claim ->
if (!claim.isNull) {
claim.asList(String::class.java)?.let { scopes.addAll(it) }
}
}
}

return scopes
}

/**
* Match a user scope against a required scope.
* Supports wildcard matching if enabled (e.g., "documenten.*" matches "documenten.lezen").
*/
private fun matchScope(userScope: String, requiredScope: String, wildcardEnabled: Boolean): Boolean {
if (userScope == requiredScope) return true

if (!wildcardEnabled) return false

// Wildcard matching: "documenten.*" matches "documenten.lezen"
if (userScope.endsWith(".*")) {
val prefix = userScope.removeSuffix(".*")
return requiredScope.startsWith("$prefix.")
}

return false
}

/**
* Exception thrown when a user lacks required scopes.
*/
class ScopeAuthorizationException(val requiredScopes: Set<String>) :
Exception("Access denied. Required scopes: ${requiredScopes.joinToString(", ")}")
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.baseflow.api.DOCUMENTEN_API_VERSION
import com.baseflow.api.middleware.ApiVersionHeader
import com.baseflow.api.middleware.RequestScopeKey
import com.baseflow.api.models.*
import com.baseflow.api.routes.checkScope
import com.baseflow.entities.EIORecordEntity
import com.baseflow.entities.latestVersion
import com.baseflow.services.EnkelvoudigInformatieObjectService
Expand Down Expand Up @@ -187,7 +188,9 @@ fun Route.enkelvoudigInformatieObjectenRoutes() {
responses {
response(200) {
description = "Lijst van ENKELVOUDIGINFORMATIEOBJECTen."
ContentType.Application.Json { schema = jsonSchema<PaginatedResponse<EnkelvoudigInformatieObjectResponse>>() }
ContentType.Application.Json {
schema = jsonSchema<PaginatedResponse<EnkelvoudigInformatieObjectResponse>>()
}
}
response(400) { description = "Bad request." }
response(401) { description = "Unauthorized." }
Expand Down Expand Up @@ -282,7 +285,9 @@ fun Route.enkelvoudigInformatieObjectenRoutes() {
responses {
response(200) {
description = "Lijst van gevonden ENKELVOUDIGINFORMATIEOBJECTen."
ContentType.Application.Json { schema = jsonSchema<PaginatedResponse<EnkelvoudigInformatieObjectResponse>>() }
ContentType.Application.Json {
schema = jsonSchema<PaginatedResponse<EnkelvoudigInformatieObjectResponse>>()
}
}
response(400) { description = "Bad request." }
response(401) { description = "Unauthorized." }
Expand Down Expand Up @@ -637,12 +642,14 @@ private val RoutingContext.service: EnkelvoudigInformatieObjectService
get() = call.attributes[RequestScopeKey].get()

private suspend fun RoutingContext.list() {
call.checkScope("documenten.lezen")
val (page, pageSize, filter) = getFilters()
val (items, totalCount) = service.getAll(filter)
call.respond(PaginatedResponse.from(call, RESOURCE_SEGMENT, items, totalCount, page, pageSize))
}

private suspend fun RoutingContext.create() {
call.checkScope("documenten.aanmaken")
val request = call.receive<EnkelvoudigInformatieObjectRequest>()
try {
val response = service.create(request)
Expand All @@ -663,6 +670,7 @@ private suspend fun RoutingContext.create() {
}

private suspend fun RoutingContext.zoek() {
call.checkScope("documenten.lezen")
val request = call.receive<EIOZoekRequest>()
val (page, pageSize, filter) = getFilters(request.uuidIn, request.expand)

Expand Down Expand Up @@ -746,6 +754,7 @@ private fun splitOnComma(params: String?): List<String> = (
)

private suspend fun RoutingContext.head() {
call.checkScope("documenten.lezen")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path()))
Expand All @@ -770,6 +779,7 @@ private suspend fun RoutingContext.head() {
}

private suspend fun RoutingContext.get() {
call.checkScope("documenten.lezen")
// TODO add version and registratieOp query parameters support
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
Expand Down Expand Up @@ -799,6 +809,7 @@ private suspend fun RoutingContext.get() {
}

private suspend fun RoutingContext.put() {
call.checkScope("documenten.bijwerken")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path()))
Expand Down Expand Up @@ -831,6 +842,7 @@ private suspend fun RoutingContext.put() {
}

private suspend fun RoutingContext.patch() {
call.checkScope("documenten.bijwerken")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path()))
Expand Down Expand Up @@ -861,6 +873,7 @@ private suspend fun RoutingContext.patch() {
}

private suspend fun RoutingContext.delete() {
call.checkScope("documenten.verwijderen")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path()))
Expand Down Expand Up @@ -898,6 +911,7 @@ private suspend fun RoutingContext.delete() {
}

private suspend fun RoutingContext.download() {
call.checkScope("documenten.lezen")
// TODO add version and registratieOp query parameters support
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
Expand Down Expand Up @@ -960,6 +974,7 @@ private suspend fun RoutingContext.download() {
}

private suspend fun RoutingContext.lock() {
call.checkScope("documenten.lock")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path()))
Expand All @@ -986,6 +1001,7 @@ private suspend fun RoutingContext.lock() {
}

private suspend fun RoutingContext.unlock() {
call.checkScope("documenten.geforceerd-unlock")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.baseflow.api.DOCUMENTEN_API_VERSION
import com.baseflow.api.middleware.ApiVersionHeader
import com.baseflow.api.middleware.RequestScopeKey
import com.baseflow.api.models.*
import com.baseflow.api.routes.checkScope
import com.baseflow.services.ObjectInformatieObjectService
import com.baseflow.services.models.CreateOIOResult
import com.baseflow.services.models.DeleteOIOResult
Expand Down Expand Up @@ -84,7 +85,9 @@ open class ObjectInformatieObjectenRoutes(
summary = "Alle ${resourceSegment.title} relaties opvragen."
description = "Geeft een lijst van OBJECTINFORMATIEOBJECT relaties, gefilterd via query-parameters."
parameters {
query("informatieobject") { description = "Filter op URL-referentie naar het INFORMATIEOBJECT." }
query("informatieobject") {
description = "Filter op URL-referentie naar het INFORMATIEOBJECT."
}
query("object") { description = "Filter op URL-referentie naar het gerelateerde OBJECT." }
query("expand") { description = "Velden om te expanderen." }
query("page") { description = "**EXPERIMENTEEL** Paginanummer." }
Expand Down Expand Up @@ -263,6 +266,7 @@ open class ObjectInformatieObjectenRoutes(
}

private suspend fun RoutingContext.list() {
call.checkScope("documenten.lezen")
val informatieobject = call.request.queryParameters["informatieobject"]
val subjectObject = call.request.queryParameters["object"]
val expand = call.request.queryParameters.getAll("expand") ?: emptyList()
Expand All @@ -289,6 +293,7 @@ open class ObjectInformatieObjectenRoutes(
}

private suspend fun RoutingContext.create() {
call.checkScope("documenten.aanmaken")
val request = call.receive<CreateOIORequest>()

when (val result = service.create(request)) {
Expand All @@ -312,6 +317,7 @@ open class ObjectInformatieObjectenRoutes(
}

private suspend fun RoutingContext.head(resourceTitle: String) {
call.checkScope("documenten.lezen")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(
Expand Down Expand Up @@ -339,6 +345,7 @@ open class ObjectInformatieObjectenRoutes(
}

private suspend fun RoutingContext.get(resourceTitle: String) {
call.checkScope("documenten.lezen")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(
Expand Down Expand Up @@ -368,6 +375,7 @@ open class ObjectInformatieObjectenRoutes(
}

private suspend fun RoutingContext.delete(resourceTitle: String) {
call.checkScope("documenten.verwijderen")
val uuidString = call.parameters["uuid"]
if (uuidString == null) {
call.respondProblem(
Expand Down
Loading
Loading