From 6538b0d48ecfccf7ca5994f13b09fe390271bec1 Mon Sep 17 00:00:00 2001 From: Wouter Pot Date: Thu, 7 May 2026 18:24:07 +0200 Subject: [PATCH 1/2] CGDMF-48: Validate API scopes --- .../api/documenten/routes/AuditTrailRoutes.kt | 25 +- .../api/documenten/routes/CheckScope.kt | 140 +++++++++++ .../EnkelvoudigInformatieObjectenRoutes.kt | 54 ++-- .../routes/ObjectInformatieObjectenRoutes.kt | 14 +- .../api/middleware/GlobalExceptionHandler.kt | 14 ++ .../documenten/middleware/CheckScopeTest.kt | 238 ++++++++++++++++++ ...EnkelvoudigInformatieObjectenRoutesTest.kt | 27 ++ .../routes/GlobalExceptionHandlerTest.kt | 3 + .../ObjectInformatieObjectenRoutesTest.kt | 16 ++ .../SubjectInformatieObjectenRoutesTest.kt | 5 + .../kotlin/api/documenten/routes/TestBase.kt | 95 ++++++- 11 files changed, 597 insertions(+), 34 deletions(-) create mode 100644 src/main/kotlin/api/documenten/routes/CheckScope.kt create mode 100644 src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt diff --git a/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt b/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt index 40ef03d3..9a124acc 100644 --- a/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt @@ -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 @@ -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. @@ -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"] @@ -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) @@ -120,3 +119,7 @@ fun Route.auditTrailRoutes() { } } } + +private val RoutingContext.service: AuditTrailService + get() = call.attributes[RequestScopeKey].get() + diff --git a/src/main/kotlin/api/documenten/routes/CheckScope.kt b/src/main/kotlin/api/documenten/routes/CheckScope.kt new file mode 100644 index 00000000..fba139ee --- /dev/null +++ b/src/main/kotlin/api/documenten/routes/CheckScope.kt @@ -0,0 +1,140 @@ +// 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, + config: ScopeAuthorizationConfig = ScopeAuthorizationConfig() +) { + if (requiredScopes.isEmpty()) { + return + } + + val principal = principal() + + // 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 { + val scopes = mutableSetOf() + + // 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 +) : Exception("Access denied. Required scopes: ${requiredScopes.joinToString(", ")}") diff --git a/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt b/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt index 7f48c1d2..50fa3daa 100644 --- a/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt @@ -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 @@ -91,12 +92,12 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { summary = "Alle (enkelvoudige) INFORMATIEOBJECTen opvragen." description = "Geeft een gepagineerde lijst van ENKELVOUDIGINFORMATIEOBJECTen. " + - "Alleen de laatste versie van elk INFORMATIEOBJECT wordt getoond." + "Alleen de laatste versie van elk INFORMATIEOBJECT wordt getoond." parameters { query("bronorganisatie") { description = "Het RSIN van de Niet-natuurlijk persoon zijnde de organisatie die het INFORMATIEOBJECT " + - "heeft gecreëerd of heeft ontvangen en als eerste in een samenwerkingsketen heeft vastgelegd." + "heeft gecreëerd of heeft ontvangen en als eerste in een samenwerkingsketen heeft vastgelegd." } query("identificatie") { description = "Een binnen een gegeven context ondubbelzinnige referentie naar het INFORMATIEOBJECT." @@ -113,7 +114,7 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { query("informatieobjecttype") { description = "**EXPERIMENTEEL** URL-referentie naar de gerelateerde INFORMATIEOBJECTTYPE " + - "(in deze of een andere API)." + "(in deze of een andere API)." } query("vertrouwelijkheidaanduiding") { description = "**EXPERIMENTEEL** De vertrouwelijkheidaanduiding van het INFORMATIEOBJECT. " + @@ -123,27 +124,27 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { query("titel") { description = "**EXPERIMENTEEL** De titel van het INFORMATIEOBJECT " + - "(bevat de gegeven waarde, hoofdletterongevoelig)." + "(bevat de gegeven waarde, hoofdletterongevoelig)." } query("auteur") { description = "**EXPERIMENTEEL** De persoon of organisatie die dit INFORMATIEOBJECT heeft aangemaakt " + - "(bevat de gegeven waarde, hoofdletterongevoelig)." + "(bevat de gegeven waarde, hoofdletterongevoelig)." } query("status") { description = "**EXPERIMENTEEL** Filter op de status van het INFORMATIEOBJECT. " + - "Mogelijke waarden: in_bewerking, ter_vaststelling, definitief, gearchiveerd." + "Mogelijke waarden: in_bewerking, ter_vaststelling, definitief, gearchiveerd." } query("beschrijving") { description = "**EXPERIMENTEEL** De beschrijving van het INFORMATIEOBJECT " + - "(bevat de gegeven waarde, hoofdletterongevoelig)." + "(bevat de gegeven waarde, hoofdletterongevoelig)." } query("trefwoorden__overlap") { description = "**EXPERIMENTEEL** Een lijst van trefwoorden gescheiden door komma's, " + - "geeft alle EnkelvoudigInformatieObjecten terug die ten minste een van de opgegeven trefwoorden hebben." + "geeft alle EnkelvoudigInformatieObjecten terug die ten minste een van de opgegeven trefwoorden hebben." } query("locked") { description = "**EXPERIMENTEEL** Filter op vergrendeld (true) of ontgrendeld (false)." @@ -151,29 +152,29 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { query("creatiedatum__gte") { description = "**EXPERIMENTEEL** De aanmakingsdatum van het INFORMATIEOBJECT " + - "(groter of gelijk aan de gegeven datum, formaat: YYYY-MM-DD)." + "(groter of gelijk aan de gegeven datum, formaat: YYYY-MM-DD)." } query("creatiedatum__lte") { description = "**EXPERIMENTEEL** De aanmakingsdatum van het INFORMATIEOBJECT " + - "(kleiner of gelijk aan de gegeven datum, formaat: YYYY-MM-DD)." + "(kleiner of gelijk aan de gegeven datum, formaat: YYYY-MM-DD)." } query("registratiedatum__gte") { description = "**EXPERIMENTEEL** De registratiedatum (`beginRegistratie`) van het INFORMATIEOBJECT " + - "(groter of gelijk aan de gegeven datum/tijd, formaat: date-time, bijv. 2025-01-01T00:00:00)." + "(groter of gelijk aan de gegeven datum/tijd, formaat: date-time, bijv. 2025-01-01T00:00:00)." } query("registratiedatum__lte") { description = "**EXPERIMENTEEL** De registratiedatum (`beginRegistratie`) van het INFORMATIEOBJECT " + - "(kleiner of gelijk aan de gegeven datum/tijd, formaat: date-time, bijv. 2025-01-01T00:00:00)." + "(kleiner of gelijk aan de gegeven datum/tijd, formaat: date-time, bijv. 2025-01-01T00:00:00)." } query("ordering") { description = "**EXPERIMENTEEL** Sorteer op één of meer velden (komma-gescheiden). " + - "Gebruik een `-` prefix voor aflopende volgorde. " + - "Mogelijke waarden: auteur, bestandsomvang, creatiedatum, formaat, status, titel, " + - "vertrouwelijkheidaanduiding (en hun `-`-varianten)." + "Gebruik een `-` prefix voor aflopende volgorde. " + + "Mogelijke waarden: auteur, bestandsomvang, creatiedatum, formaat, status, titel, " + + "vertrouwelijkheidaanduiding (en hun `-`-varianten)." } query("objectinformatieobjecten__object") { description = @@ -187,7 +188,9 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { responses { response(200) { description = "Lijst van ENKELVOUDIGINFORMATIEOBJECTen." - ContentType.Application.Json { schema = jsonSchema>() } + ContentType.Application.Json { + schema = jsonSchema>() + } } response(400) { description = "Bad request." } response(401) { description = "Unauthorized." } @@ -282,7 +285,9 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { responses { response(200) { description = "Lijst van gevonden ENKELVOUDIGINFORMATIEOBJECTen." - ContentType.Application.Json { schema = jsonSchema>() } + ContentType.Application.Json { + schema = jsonSchema>() + } } response(400) { description = "Bad request." } response(401) { description = "Unauthorized." } @@ -356,7 +361,7 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { header("If-None-Match") { description = "Conditioneel GET: geef de ETag-waarde van de eerder ontvangen response mee. " + - "De server antwoordt met 304 Not Modified als de resource niet gewijzigd is." + "De server antwoordt met 304 Not Modified als de resource niet gewijzigd is." required = false } } @@ -495,7 +500,7 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { summary = "Verwijder een ENKELVOUDIGINFORMATIEOBJECT." description = "Verwijdert het INFORMATIEOBJECT en alle bijbehorende versies. " + - "Alleen mogelijk als er geen OBJECTINFORMATIEOBJECTen aan gerelateerd zijn." + "Alleen mogelijk als er geen OBJECTINFORMATIEOBJECTen aan gerelateerd zijn." parameters { path("uuid") { description = "Unieke resource identifier (UUID4)." } } @@ -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() try { val response = service.create(request) @@ -663,6 +670,7 @@ private suspend fun RoutingContext.create() { } private suspend fun RoutingContext.zoek() { + call.checkScope("documenten.lezen") val request = call.receive() val (page, pageSize, filter) = getFilters(request.uuidIn, request.expand) @@ -746,6 +754,7 @@ private fun splitOnComma(params: String?): List = ( ) 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())) @@ -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) { @@ -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())) @@ -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())) @@ -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())) @@ -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) { @@ -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())) @@ -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())) diff --git a/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt b/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt index 43ee67c9..e818f301 100644 --- a/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt @@ -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 @@ -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." } @@ -129,7 +132,7 @@ open class ObjectInformatieObjectenRoutes( summary = "Maak een ${resourceSegment.title} relatie aan." description = "LET OP: Dit endpoint hoor je als consumer niet zelf aan te spreken. " + - "Andere API's gebruiken dit endpoint bij het synchroniseren van relaties." + "Andere API's gebruiken dit endpoint bij het synchroniseren van relaties." requestBody { required = true description = "Gegevens van de aan te maken relatie." @@ -247,7 +250,7 @@ open class ObjectInformatieObjectenRoutes( summary = "Verwijder een ${resourceSegment.title} relatie." description = "LET OP: Dit endpoint hoor je als consumer niet zelf aan te spreken. " + - "Andere API's gebruiken dit endpoint bij het synchroniseren van relaties." + "Andere API's gebruiken dit endpoint bij het synchroniseren van relaties." parameters { path("uuid") { description = "Unieke resource identifier (UUID4)." } } @@ -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() @@ -289,6 +293,7 @@ open class ObjectInformatieObjectenRoutes( } private suspend fun RoutingContext.create() { + call.checkScope("documenten.aanmaken") val request = call.receive() when (val result = service.create(request)) { @@ -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( @@ -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( @@ -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( diff --git a/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt b/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt index df6bf906..0ceeaf81 100644 --- a/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt +++ b/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt @@ -5,6 +5,7 @@ package com.baseflow.api.middleware import com.baseflow.api.models.ProblemDetailsResponse import com.baseflow.api.models.badRequest import com.baseflow.api.models.respondProblem +import com.baseflow.api.routes.ScopeAuthorizationException import io.ktor.http.HttpStatusCode import io.ktor.serialization.JsonConvertException import io.ktor.server.application.* @@ -21,6 +22,19 @@ fun Application.configureStatusPages() { val logger = LoggerFactory.getLogger("GlobalExceptionHandler") install(StatusPages) { + exception { call, cause -> + logger.warn("Access denied at ${call.request.path()}: ${cause.message}") + call.respondProblem( + HttpStatusCode.Forbidden, + ProblemDetailsResponse( + title = "Insufficient permissions", + status = HttpStatusCode.Forbidden.value, + detail = "Required scopes: ${cause.requiredScopes.joinToString(", ")}", + instance = call.request.path() + ) + ) + } + exception { call, cause -> logger.error("Bad request at ${call.request.path()}: ${cause.message}") diff --git a/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt b/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt new file mode 100644 index 00000000..205e5c01 --- /dev/null +++ b/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Gemeente Utrecht + +package com.baseflow.api.middleware + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.baseflow.api.routes.ScopeAuthorizationException +import com.baseflow.api.routes.checkScope +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import kotlin.test.* + +class CheckScopeTest { + + private val jwtSecret = "test-secret-key-for-testing-only" + private val jwtIssuer = "test-issuer" + + private fun generateToken(scopes: String): String { + return JWT.create() + .withIssuer(jwtIssuer) + .withSubject("testuser") + .withClaim("scope", scopes) + .sign(Algorithm.HMAC256(jwtSecret)) + } + + private fun ApplicationTestBuilder.setupTestApp() { + application { + install(ContentNegotiation) { + json() + } + + install(io.ktor.server.plugins.statuspages.StatusPages) { + exception { call, cause -> + call.respond( + HttpStatusCode.Forbidden, + mapOf( + "error" to "Insufficient permissions", + "detail" to "Required scopes: ${cause.requiredScopes.joinToString(", ")}", + "code" to "insufficient_scope" + ) + ) + } + } + + install(Authentication) { + jwt("auth-jwt") { + verifier( + JWT.require(Algorithm.HMAC256(jwtSecret)) + .withIssuer(jwtIssuer) + .build() + ) + validate { credential -> + if (credential.payload.subject != null) { + JWTPrincipal(credential.payload) + } else { + null + } + } + } + } + + routing { + authenticate("auth-jwt") { + // Route requiring single scope + route("/documents") { + get { + call.checkScope("documenten.lezen") + call.respond(HttpStatusCode.OK, mapOf("message" to "success")) + } + } + + // Route requiring multiple scopes + route("/documents/{id}") { + delete { + call.checkScope("documenten.verwijderen", "documenten.admin") + call.respond(HttpStatusCode.NoContent) + } + } + + // Route without scope requirement + get("/public") { + call.respond(HttpStatusCode.OK, mapOf("message" to "public")) + } + } + } + } + } + + @Test + fun testAccessWithCorrectScope() = testApplication { + setupTestApp() + + val token = generateToken("documenten.lezen") + + val response = client.get("/documents") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun testAccessWithoutRequiredScope() = testApplication { + setupTestApp() + + val token = generateToken("other:scope") + + val response = client.get("/documents") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + assertTrue(response.bodyAsText().contains("Insufficient permissions")) + } + + @Test + fun testAccessWithMultipleScopes() = testApplication { + setupTestApp() + + val token = generateToken("documenten.verwijderen documenten.admin") + + val response = client.delete("/documents/123") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.NoContent, response.status) + } + + @Test + fun testAccessWithMissingOneOfMultipleScopes() = testApplication { + setupTestApp() + + // Has documenten.bijwerken but missing documenten.admin + val token = generateToken("documenten.bijwerken") + + val response = client.delete("/documents/123") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun testAccessPublicRouteWithAnyScope() = testApplication { + setupTestApp() + + val token = generateToken("any:scope") + + val response = client.get("/public") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun testWildcardScopeMatching() = testApplication { + setupTestApp() + + // User has wildcard scope + val token = generateToken("documenten.*") + + val response = client.get("/documents") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun testScopeAsArray() = testApplication { + application { + install(ContentNegotiation) { + json() + } + + install(Authentication) { + jwt("auth-jwt") { + verifier( + JWT.require(Algorithm.HMAC256(jwtSecret)) + .withIssuer(jwtIssuer) + .build() + ) + validate { credential -> + if (credential.payload.subject != null) { + JWTPrincipal(credential.payload) + } else { + null + } + } + } + } + + routing { + authenticate("auth-jwt") { + route("/documents") { + get { + call.checkScope("documenten.lezen") + call.respond(HttpStatusCode.OK, mapOf("message" to "success")) + } + } + } + } + } + + // Create token with array-style scopes + val token = JWT.create() + .withIssuer(jwtIssuer) + .withSubject("testuser") + .withArrayClaim("scope", arrayOf("documenten.lezen", "documenten.bijwerken")) + .sign(Algorithm.HMAC256(jwtSecret)) + + val response = client.get("/documents") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun testAccessWithoutToken() = testApplication { + setupTestApp() + + val response = client.get("/documents") + + assertEquals(HttpStatusCode.Unauthorized, response.status) + } +} diff --git a/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt index fa2b1058..c2a4d9a1 100644 --- a/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt @@ -33,6 +33,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test POST enkelvoudiginformatieobjecten with taal and bestandsnaam`() = testApplication { application { setup() } + val client = authenticatedClient() val request = generateTestDocument(taal = "dut", bestandsnaam = "test.pdf") val response = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -61,6 +62,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list includes url in each result`() = testApplication { application { setup() } + val client = authenticatedClient() // Create two documents val req1 = generateTestDocument(taal = "dut", bestandsnaam = "doc1.pdf") @@ -99,6 +101,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET enkelvoudiginformatieobjecten with invalid UUID`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT/invalid-uuid") @@ -113,6 +116,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET enkelvoudiginformatieobjecten with missing UUID parameter`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT/") @@ -122,6 +126,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET enkelvoudiginformatieobjecten with valid UUID after creation`() = testApplication { application { setup() } + val client = authenticatedClient() val request = generateTestDocument(taal = "dut", bestandsnaam = "test.pdf") val postResponse = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -146,6 +151,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `HEAD enkelvoudiginformatieobjecten returns 200 for existing resource`() = testApplication { application { setup() } + val client = authenticatedClient() // create val created = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -164,6 +170,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `HEAD enkelvoudiginformatieobjecten returns 404 for missing resource`() = testApplication { application { setup() } + val client = authenticatedClient() val resp = client.head("$API_BASE/$RESOURCE_SEGMENT/${UUID.randomUUID()}") assertEquals(HttpStatusCode.NotFound, resp.status) @@ -172,6 +179,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `HEAD enkelvoudiginformatieobjecten returns 400 for invalid UUID`() = testApplication { application { setup() } + val client = authenticatedClient() val resp = client.head("$API_BASE/$RESOURCE_SEGMENT/not-a-uuid") assertEquals(HttpStatusCode.BadRequest, resp.status) @@ -180,6 +188,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `DELETE enkelvoudiginformatieobjecten returns 204 and removes resource`() = testApplication { application { setup() } + val client = authenticatedClient() // create val created = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -201,6 +210,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `DELETE enkelvoudiginformatieobjecten returns 404 for missing resource`() = testApplication { application { setup() } + val client = authenticatedClient() val del = client.delete("$API_BASE/$RESOURCE_SEGMENT/${UUID.randomUUID()}") assertEquals(HttpStatusCode.NotFound, del.status) @@ -209,6 +219,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `DELETE enkelvoudiginformatieobjecten returns 400 for invalid UUID`() = testApplication { application { setup() } + val client = authenticatedClient() val del = client.delete("$API_BASE/$RESOURCE_SEGMENT/not-a-uuid") assertEquals(HttpStatusCode.BadRequest, del.status) @@ -217,6 +228,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `DELETE enkelvoudiginformatieobjecten returns 409 for locked resource`() = testApplication { application { setup() } + val client = authenticatedClient() // create val created = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -243,6 +255,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET enkelvoudiginformatieobjecten with valid UUID returns JSON UTF-8`() = testApplication { application { setup() } + val client = authenticatedClient() val request = generateTestDocument(taal = "dut", bestandsnaam = "test.pdf") val postResponse = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -267,6 +280,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `lock returns 200 with lock token, second lock returns 409`() = testApplication { application { setup() } + val client = authenticatedClient() // Create an object first val createReq = generateTestDocument() @@ -293,6 +307,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `unlock with correct token returns 204 and subsequent unlock returns 409`() = testApplication { application { setup() } + val client = authenticatedClient() // Create val createReq = generateTestDocument() @@ -327,6 +342,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `unlock with invalid token returns 409 Conflict`() = testApplication { application { setup() } + val client = authenticatedClient() // Create and lock val createReq = generateTestDocument() @@ -350,9 +366,12 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `lock and unlock with unknown UUID return 404`() = testApplication { application { setup() } + val client = authenticatedClient() val unknownId = "00000000-0000-0000-0000-000000000001" + client.prepareDelete("$API_BASE/$RESOURCE_SEGMENT/$unknownId") + val lockResp = client.post("$API_BASE/$RESOURCE_SEGMENT/$unknownId/lock") assertEquals(HttpStatusCode.NotFound, lockResp.status) @@ -367,6 +386,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `lock and unlock with invalid UUID return 400`() = testApplication { application { setup() } + val client = authenticatedClient() val invalidId = "not-a-uuid" @@ -384,6 +404,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test zoek endpoint with uuid_In`() = testApplication { application { setup() } + val client = authenticatedClient() // Create two documents val req1 = generateTestDocument(identificatie = "DOC-001") @@ -429,6 +450,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list paging`() = testApplication { application { setup() } + val client = authenticatedClient() // Create 12 documents for (i in 1..12) { @@ -472,6 +494,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test zoek endpoint paging`() = testApplication { application { setup() } + val client = authenticatedClient() val createdIds = mutableListOf() // Create 12 documents @@ -523,6 +546,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list paging with custom pageSize`() = testApplication { application { setup() } + val client = authenticatedClient() // Create 12 documents for (i in 1..12) { @@ -560,6 +584,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list paging preserves filters`() = testApplication { application { setup() } + val client = authenticatedClient() val bronOrganisatie = "999999999" // Create 12 documents with specific bronorganisatie @@ -587,6 +612,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list with experimental object filters`() = testApplication { application { setup() } + val client = authenticatedClient() // Create an EIO val resEio = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -629,6 +655,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test POST zoek with experimental object filters`() = testApplication { application { setup() } + val client = authenticatedClient() // Create an EIO val resEio = client.post("$API_BASE/$RESOURCE_SEGMENT") { diff --git a/src/test/kotlin/api/documenten/routes/GlobalExceptionHandlerTest.kt b/src/test/kotlin/api/documenten/routes/GlobalExceptionHandlerTest.kt index c739ccc7..e847d52d 100644 --- a/src/test/kotlin/api/documenten/routes/GlobalExceptionHandlerTest.kt +++ b/src/test/kotlin/api/documenten/routes/GlobalExceptionHandlerTest.kt @@ -24,6 +24,7 @@ class GlobalExceptionHandlerTest : TestBase("global_exception_test") { @Test fun `test malformed JSON returns ProblemDetailsResponse`() = testApplication { application { setup() } + val client = authenticatedClient() val malformedJson = "{ \"identificatie\": \"test\", \"bronorganisatie\": \"012345678\", " // Missing closing brace and other fields @@ -42,6 +43,7 @@ class GlobalExceptionHandlerTest : TestBase("global_exception_test") { @Test fun `test invalid objectType value in JSON returns specific error message`() = testApplication { application { setup() } + val client = authenticatedClient() // OIO request with an objectType containing spaces, which is invalid per SubjectType validation val invalidJson = """ @@ -78,6 +80,7 @@ class GlobalExceptionHandlerTest : TestBase("global_exception_test") { } } } + val client = authenticatedClient() val response = client.get("/test-serialization-error") diff --git a/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt index 5054b432..baf96f40 100644 --- a/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt @@ -56,6 +56,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test list empty objectinformatieobjecten returns empty array`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT") @@ -69,6 +70,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject returns 201 with location header`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( @@ -102,6 +104,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create duplicate objectinformatieobject returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( @@ -128,6 +131,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test get objectinformatieobject by id returns 200`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val createRequest = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", @@ -157,6 +161,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test get non-existent objectinformatieobject returns 404`() = testApplication { application { setup() } + val client = authenticatedClient() val nonExistentId = UUID.randomUUID() val response = client.get("$API_BASE/$RESOURCE_SEGMENT/$nonExistentId") @@ -167,6 +172,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test head existing objectinformatieobject returns 200`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val createRequest = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", @@ -190,6 +196,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test head non-existent objectinformatieobject returns 404`() = testApplication { application { setup() } + val client = authenticatedClient() val nonExistentId = UUID.randomUUID() val response = client.head("$API_BASE/$RESOURCE_SEGMENT/$nonExistentId") @@ -200,6 +207,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test delete objectinformatieobject returns 204`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val createRequest = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", @@ -226,6 +234,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test delete non-existent objectinformatieobject returns 404`() = testApplication { application { setup() } + val client = authenticatedClient() val nonExistentId = UUID.randomUUID() val response = client.delete("$API_BASE/$RESOURCE_SEGMENT/$nonExistentId") @@ -236,6 +245,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test filter by informatieobject returns matching relations`() = testApplication { application { setup() } + val client = authenticatedClient() val sharedEioId = createTestEIO() val sharedEioUrl = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$sharedEioId" @@ -302,6 +312,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test filter by object returns matching relations`() = testApplication { application { setup() } + val client = authenticatedClient() val sharedObjectUrl = "https://example.com/zaken/api/v1/zaken/87654321-4321-4321-4321-210987654321" @@ -364,6 +375,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject with different object types`() = testApplication { application { setup() } + val client = authenticatedClient() // Create an actual EIO via API val eioId = createTestEIO() @@ -408,6 +420,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test get objectinformatieobject with invalid uuid returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT/not-a-uuid") @@ -419,6 +432,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject with invalid json returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.post("$API_BASE/$RESOURCE_SEGMENT") { contentType(ContentType.Application.Json) @@ -435,6 +449,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test head objectinformatieobject returns 200 for existing`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( @@ -457,6 +472,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test head objectinformatieobject returns 404 for non-existing`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.head("$API_BASE/$RESOURCE_SEGMENT/${UUID.randomUUID()}") assertEquals(HttpStatusCode.NotFound, response.status) diff --git a/src/test/kotlin/api/documenten/routes/SubjectInformatieObjectenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/SubjectInformatieObjectenRoutesTest.kt index 5cabbce8..44fe458e 100644 --- a/src/test/kotlin/api/documenten/routes/SubjectInformatieObjectenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/SubjectInformatieObjectenRoutesTest.kt @@ -53,6 +53,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test list empty subjectinformatieobjecten returns paginated empty results`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT") @@ -69,6 +70,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test create subjectinformatieobject returns 201 with location header`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( @@ -93,6 +95,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test subjectinformatieobjecten list paging`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() // Create 12 relations @@ -131,6 +134,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test get subjectinformatieobject by id`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", @@ -154,6 +158,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test delete subjectinformatieobject`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", diff --git a/src/test/kotlin/api/documenten/routes/TestBase.kt b/src/test/kotlin/api/documenten/routes/TestBase.kt index 8f5e7747..969a345e 100644 --- a/src/test/kotlin/api/documenten/routes/TestBase.kt +++ b/src/test/kotlin/api/documenten/routes/TestBase.kt @@ -2,15 +2,26 @@ // Copyright (C) 2026 Gemeente Utrecht package com.baseflow.api.documenten.routes +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm import com.baseflow.api.admin.adminModule import com.baseflow.api.apiJsonConfig import com.baseflow.api.documenten.documentenApiModule +import com.baseflow.config.OpenZaakConfig import com.baseflow.config.appModule import com.baseflow.services.StorageService import com.baseflow.tooling.AllTables +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.Application import io.ktor.server.application.install +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.response.* +import io.ktor.server.testing.* import io.ktor.server.plugins.contentnegotiation.* import io.mockk.every import io.mockk.mockk @@ -30,6 +41,30 @@ open class TestBase(dbNamePrefix: String) { lateinit var mockStorageService: StorageService protected set + companion object { + const val TEST_JWT_SECRET = "test-secret-key-for-testing-only" + const val TEST_JWT_ISSUER = "test-issuer" + + // All scopes used in the application + val ALL_SCOPES = listOf( + "documenten.lezen", + "documenten.aanmaken", + "documenten.bijwerken", + "documenten.verwijderen", + "documenten.lock", + "documenten.geforceerd-unlock" + ).joinToString(" ") + + fun generateTestToken(scopes: String = ALL_SCOPES): String { + return JWT.create() + .withIssuer(TEST_JWT_ISSUER) + .withSubject("testuser") + .withClaim("scope", scopes) + .withClaim("username", "testuser") + .sign(Algorithm.HMAC256(TEST_JWT_SECRET)) + } + } + fun connectDb() { Database.connect( "jdbc:h2:mem:$dbName;DB_CLOSE_DELAY=-1;", @@ -71,7 +106,65 @@ open class TestBase(dbNamePrefix: String) { install(ContentNegotiation) { json(apiJsonConfig()) } - documentenApiModule(useAuthentication = false) + + // Install test JWT authentication - register both providers used by documentenApiModule + install(Authentication) { + jwt("auth-jwt") { + verifier( + JWT.require(Algorithm.HMAC256(TEST_JWT_SECRET)) + .withIssuer(TEST_JWT_ISSUER) + .build() + ) + validate { credential -> + if (credential.payload.getClaim("username").asString() != "") { + JWTPrincipal(credential.payload) + } else { + null + } + } + challenge { _, _ -> + call.respondText( + text = "Unauthorized", + status = HttpStatusCode.Unauthorized + ) + } + } + // Also register auth-zgw with the same config for tests + jwt("auth-zgw") { + verifier( + JWT.require(Algorithm.HMAC256(TEST_JWT_SECRET)) + .withIssuer(TEST_JWT_ISSUER) + .build() + ) + validate { credential -> + if (credential.payload.getClaim("username").asString() != "") { + JWTPrincipal(credential.payload) + } else { + null + } + } + challenge { _, _ -> + call.respondText( + text = "Unauthorized", + status = HttpStatusCode.Unauthorized + ) + } + } + } + + val openZaakConfig = OpenZaakConfig(validationEnabled = false) + documentenApiModule(useAuthentication = true, openZaakConfig = openZaakConfig) + } + + /** + * Creates an HTTP client with a default Bearer token containing all scopes. + */ + fun ApplicationTestBuilder.authenticatedClient(): HttpClient { + return createClient { + install(DefaultRequest) { + header(HttpHeaders.Authorization, "Bearer ${generateTestToken()}") + } + } adminModule(useAuthentication = false) } } From bc830b7921a87a90d1d015943744aa17da1915b7 Mon Sep 17 00:00:00 2001 From: Wouter Pot Date: Thu, 7 May 2026 18:24:07 +0200 Subject: [PATCH 2/2] CGDMF-48: Validate API scopes --- .bruno/CG-DMF/collection.bru | 1 + docker-compose.yml | 2 +- .../api/documenten/routes/AuditTrailRoutes.kt | 1 - .../api/documenten/routes/CheckScope.kt | 21 ++++------ .../EnkelvoudigInformatieObjectenRoutes.kt | 34 ++++++++-------- .../routes/ObjectInformatieObjectenRoutes.kt | 4 +- .../api/middleware/GlobalExceptionHandler.kt | 4 +- .../documenten/middleware/CheckScopeTest.kt | 21 +++++----- .../routes/BestandsDelenRoutesTest.kt | 7 ++++ ...EnkelvoudigInformatieObjectenRoutesTest.kt | 1 + .../ObjectInformatieObjectenRoutesTest.kt | 4 ++ .../kotlin/api/documenten/routes/TestBase.kt | 40 ++++++++----------- 12 files changed, 68 insertions(+), 72 deletions(-) diff --git a/.bruno/CG-DMF/collection.bru b/.bruno/CG-DMF/collection.bru index fb0f7061..7efc9788 100644 --- a/.bruno/CG-DMF/collection.bru +++ b/.bruno/CG-DMF/collection.bru @@ -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" }); diff --git a/docker-compose.yml b/docker-compose.yml index 7ffe2cee..78008997 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt b/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt index 9a124acc..2628e6d0 100644 --- a/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt @@ -122,4 +122,3 @@ fun Route.auditTrailRoutes() { private val RoutingContext.service: AuditTrailService get() = call.attributes[RequestScopeKey].get() - diff --git a/src/main/kotlin/api/documenten/routes/CheckScope.kt b/src/main/kotlin/api/documenten/routes/CheckScope.kt index fba139ee..841b8d83 100644 --- a/src/main/kotlin/api/documenten/routes/CheckScope.kt +++ b/src/main/kotlin/api/documenten/routes/CheckScope.kt @@ -1,6 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 // Copyright (C) 2026 Gemeente Utrecht - package com.baseflow.api.routes import io.ktor.server.application.* @@ -26,16 +25,11 @@ class ScopeAuthorizationConfig { var wildcardEnabled: Boolean = true } -fun ApplicationCall.checkScope( - vararg requiredScopes: String, -) { +fun ApplicationCall.checkScope(vararg requiredScopes: String) { checkScope(requiredScopes.toSet()) } -fun ApplicationCall.checkScope( - requiredScopes: Set, - config: ScopeAuthorizationConfig = ScopeAuthorizationConfig() -) { +fun ApplicationCall.checkScope(requiredScopes: Set, config: ScopeAuthorizationConfig = ScopeAuthorizationConfig()) { if (requiredScopes.isEmpty()) { return } @@ -46,7 +40,7 @@ fun ApplicationCall.checkScope( if (principal == null) { logger.warn("No JWT principal found while checking required scopes {}, denying access", requiredScopes) throw ScopeAuthorizationException( - requiredScopes = requiredScopes + requiredScopes = requiredScopes, ) } @@ -71,10 +65,10 @@ fun ApplicationCall.checkScope( logger.warn( "Access denied. User missing scopes: {}. User has: {}", missingScopes, - userScopes + userScopes, ) throw ScopeAuthorizationException( - requiredScopes = requiredScopes + requiredScopes = requiredScopes, ) } else { logger.debug("Scope check passed") @@ -135,6 +129,5 @@ private fun matchScope(userScope: String, requiredScope: String, wildcardEnabled /** * Exception thrown when a user lacks required scopes. */ -class ScopeAuthorizationException( - val requiredScopes: Set -) : Exception("Access denied. Required scopes: ${requiredScopes.joinToString(", ")}") +class ScopeAuthorizationException(val requiredScopes: Set) : + Exception("Access denied. Required scopes: ${requiredScopes.joinToString(", ")}") diff --git a/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt b/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt index 50fa3daa..91a569b5 100644 --- a/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt @@ -92,12 +92,12 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { summary = "Alle (enkelvoudige) INFORMATIEOBJECTen opvragen." description = "Geeft een gepagineerde lijst van ENKELVOUDIGINFORMATIEOBJECTen. " + - "Alleen de laatste versie van elk INFORMATIEOBJECT wordt getoond." + "Alleen de laatste versie van elk INFORMATIEOBJECT wordt getoond." parameters { query("bronorganisatie") { description = "Het RSIN van de Niet-natuurlijk persoon zijnde de organisatie die het INFORMATIEOBJECT " + - "heeft gecreëerd of heeft ontvangen en als eerste in een samenwerkingsketen heeft vastgelegd." + "heeft gecreëerd of heeft ontvangen en als eerste in een samenwerkingsketen heeft vastgelegd." } query("identificatie") { description = "Een binnen een gegeven context ondubbelzinnige referentie naar het INFORMATIEOBJECT." @@ -114,7 +114,7 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { query("informatieobjecttype") { description = "**EXPERIMENTEEL** URL-referentie naar de gerelateerde INFORMATIEOBJECTTYPE " + - "(in deze of een andere API)." + "(in deze of een andere API)." } query("vertrouwelijkheidaanduiding") { description = "**EXPERIMENTEEL** De vertrouwelijkheidaanduiding van het INFORMATIEOBJECT. " + @@ -124,27 +124,27 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { query("titel") { description = "**EXPERIMENTEEL** De titel van het INFORMATIEOBJECT " + - "(bevat de gegeven waarde, hoofdletterongevoelig)." + "(bevat de gegeven waarde, hoofdletterongevoelig)." } query("auteur") { description = "**EXPERIMENTEEL** De persoon of organisatie die dit INFORMATIEOBJECT heeft aangemaakt " + - "(bevat de gegeven waarde, hoofdletterongevoelig)." + "(bevat de gegeven waarde, hoofdletterongevoelig)." } query("status") { description = "**EXPERIMENTEEL** Filter op de status van het INFORMATIEOBJECT. " + - "Mogelijke waarden: in_bewerking, ter_vaststelling, definitief, gearchiveerd." + "Mogelijke waarden: in_bewerking, ter_vaststelling, definitief, gearchiveerd." } query("beschrijving") { description = "**EXPERIMENTEEL** De beschrijving van het INFORMATIEOBJECT " + - "(bevat de gegeven waarde, hoofdletterongevoelig)." + "(bevat de gegeven waarde, hoofdletterongevoelig)." } query("trefwoorden__overlap") { description = "**EXPERIMENTEEL** Een lijst van trefwoorden gescheiden door komma's, " + - "geeft alle EnkelvoudigInformatieObjecten terug die ten minste een van de opgegeven trefwoorden hebben." + "geeft alle EnkelvoudigInformatieObjecten terug die ten minste een van de opgegeven trefwoorden hebben." } query("locked") { description = "**EXPERIMENTEEL** Filter op vergrendeld (true) of ontgrendeld (false)." @@ -152,29 +152,29 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { query("creatiedatum__gte") { description = "**EXPERIMENTEEL** De aanmakingsdatum van het INFORMATIEOBJECT " + - "(groter of gelijk aan de gegeven datum, formaat: YYYY-MM-DD)." + "(groter of gelijk aan de gegeven datum, formaat: YYYY-MM-DD)." } query("creatiedatum__lte") { description = "**EXPERIMENTEEL** De aanmakingsdatum van het INFORMATIEOBJECT " + - "(kleiner of gelijk aan de gegeven datum, formaat: YYYY-MM-DD)." + "(kleiner of gelijk aan de gegeven datum, formaat: YYYY-MM-DD)." } query("registratiedatum__gte") { description = "**EXPERIMENTEEL** De registratiedatum (`beginRegistratie`) van het INFORMATIEOBJECT " + - "(groter of gelijk aan de gegeven datum/tijd, formaat: date-time, bijv. 2025-01-01T00:00:00)." + "(groter of gelijk aan de gegeven datum/tijd, formaat: date-time, bijv. 2025-01-01T00:00:00)." } query("registratiedatum__lte") { description = "**EXPERIMENTEEL** De registratiedatum (`beginRegistratie`) van het INFORMATIEOBJECT " + - "(kleiner of gelijk aan de gegeven datum/tijd, formaat: date-time, bijv. 2025-01-01T00:00:00)." + "(kleiner of gelijk aan de gegeven datum/tijd, formaat: date-time, bijv. 2025-01-01T00:00:00)." } query("ordering") { description = "**EXPERIMENTEEL** Sorteer op één of meer velden (komma-gescheiden). " + - "Gebruik een `-` prefix voor aflopende volgorde. " + - "Mogelijke waarden: auteur, bestandsomvang, creatiedatum, formaat, status, titel, " + - "vertrouwelijkheidaanduiding (en hun `-`-varianten)." + "Gebruik een `-` prefix voor aflopende volgorde. " + + "Mogelijke waarden: auteur, bestandsomvang, creatiedatum, formaat, status, titel, " + + "vertrouwelijkheidaanduiding (en hun `-`-varianten)." } query("objectinformatieobjecten__object") { description = @@ -361,7 +361,7 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { header("If-None-Match") { description = "Conditioneel GET: geef de ETag-waarde van de eerder ontvangen response mee. " + - "De server antwoordt met 304 Not Modified als de resource niet gewijzigd is." + "De server antwoordt met 304 Not Modified als de resource niet gewijzigd is." required = false } } @@ -500,7 +500,7 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { summary = "Verwijder een ENKELVOUDIGINFORMATIEOBJECT." description = "Verwijdert het INFORMATIEOBJECT en alle bijbehorende versies. " + - "Alleen mogelijk als er geen OBJECTINFORMATIEOBJECTen aan gerelateerd zijn." + "Alleen mogelijk als er geen OBJECTINFORMATIEOBJECTen aan gerelateerd zijn." parameters { path("uuid") { description = "Unieke resource identifier (UUID4)." } } diff --git a/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt b/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt index e818f301..e0bb226c 100644 --- a/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt @@ -132,7 +132,7 @@ open class ObjectInformatieObjectenRoutes( summary = "Maak een ${resourceSegment.title} relatie aan." description = "LET OP: Dit endpoint hoor je als consumer niet zelf aan te spreken. " + - "Andere API's gebruiken dit endpoint bij het synchroniseren van relaties." + "Andere API's gebruiken dit endpoint bij het synchroniseren van relaties." requestBody { required = true description = "Gegevens van de aan te maken relatie." @@ -250,7 +250,7 @@ open class ObjectInformatieObjectenRoutes( summary = "Verwijder een ${resourceSegment.title} relatie." description = "LET OP: Dit endpoint hoor je als consumer niet zelf aan te spreken. " + - "Andere API's gebruiken dit endpoint bij het synchroniseren van relaties." + "Andere API's gebruiken dit endpoint bij het synchroniseren van relaties." parameters { path("uuid") { description = "Unieke resource identifier (UUID4)." } } diff --git a/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt b/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt index 0ceeaf81..b57392a7 100644 --- a/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt +++ b/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt @@ -30,8 +30,8 @@ fun Application.configureStatusPages() { title = "Insufficient permissions", status = HttpStatusCode.Forbidden.value, detail = "Required scopes: ${cause.requiredScopes.joinToString(", ")}", - instance = call.request.path() - ) + instance = call.request.path(), + ), ) } diff --git a/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt b/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt index 205e5c01..0bb91019 100644 --- a/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt +++ b/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt @@ -1,6 +1,5 @@ // SPDX-License-Identifier: EUPL-1.2 // Copyright (C) 2026 Gemeente Utrecht - package com.baseflow.api.middleware import com.auth0.jwt.JWT @@ -25,13 +24,11 @@ class CheckScopeTest { private val jwtSecret = "test-secret-key-for-testing-only" private val jwtIssuer = "test-issuer" - private fun generateToken(scopes: String): String { - return JWT.create() - .withIssuer(jwtIssuer) - .withSubject("testuser") - .withClaim("scope", scopes) - .sign(Algorithm.HMAC256(jwtSecret)) - } + private fun generateToken(scopes: String): String = JWT.create() + .withIssuer(jwtIssuer) + .withSubject("testuser") + .withClaim("scope", scopes) + .sign(Algorithm.HMAC256(jwtSecret)) private fun ApplicationTestBuilder.setupTestApp() { application { @@ -46,8 +43,8 @@ class CheckScopeTest { mapOf( "error" to "Insufficient permissions", "detail" to "Required scopes: ${cause.requiredScopes.joinToString(", ")}", - "code" to "insufficient_scope" - ) + "code" to "insufficient_scope", + ), ) } } @@ -57,7 +54,7 @@ class CheckScopeTest { verifier( JWT.require(Algorithm.HMAC256(jwtSecret)) .withIssuer(jwtIssuer) - .build() + .build(), ) validate { credential -> if (credential.payload.subject != null) { @@ -189,7 +186,7 @@ class CheckScopeTest { verifier( JWT.require(Algorithm.HMAC256(jwtSecret)) .withIssuer(jwtIssuer) - .build() + .build(), ) validate { credential -> if (credential.payload.subject != null) { diff --git a/src/test/kotlin/api/documenten/routes/BestandsDelenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/BestandsDelenRoutesTest.kt index 9831e9d4..24b8e599 100644 --- a/src/test/kotlin/api/documenten/routes/BestandsDelenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/BestandsDelenRoutesTest.kt @@ -91,6 +91,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with valid uuid and correct lock returns 200`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, lock) = createBestandsdeelInDb() @@ -121,6 +122,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel without inhoud field still succeeds when lock is present`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, lock) = createBestandsdeelInDb() @@ -145,6 +147,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with invalid UUID returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.put("$BESTANDSDELEN_PATH/not-a-valid-uuid") { setBody( @@ -164,6 +167,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel without lock field returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, _) = createBestandsdeelInDb() @@ -192,6 +196,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with blank lock value returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, _) = createBestandsdeelInDb() @@ -215,6 +220,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with wrong lock token returns 403`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, _) = createBestandsdeelInDb() @@ -240,6 +246,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with unknown UUID returns 404`() = testApplication { application { setup() } + val client = authenticatedClient() val unknownUuid = UUID.randomUUID() diff --git a/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt index c2a4d9a1..5da9c901 100644 --- a/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt @@ -699,6 +699,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `GET download returns 200 with content headers for an EIO that has content`() = testApplication { application { setup() } + val client = authenticatedClient() val created = client.post("$API_BASE/$RESOURCE_SEGMENT") { contentType(ContentType.Application.Json) diff --git a/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt index baf96f40..e9cee371 100644 --- a/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt @@ -481,6 +481,8 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject with non-standard objectType returns 201`() = testApplication { application { setup() } + val client = authenticatedClient() + val eioId = createTestEIO() // A type that is not zaak, verzoek or besluit but is a valid format should be accepted @@ -502,6 +504,8 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject with invalid objectType format returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() + val eioId = createTestEIO() // Inject raw invalid JSON to bypass Kotlin-side SubjectType validation diff --git a/src/test/kotlin/api/documenten/routes/TestBase.kt b/src/test/kotlin/api/documenten/routes/TestBase.kt index 969a345e..7b28e6d7 100644 --- a/src/test/kotlin/api/documenten/routes/TestBase.kt +++ b/src/test/kotlin/api/documenten/routes/TestBase.kt @@ -7,7 +7,6 @@ import com.auth0.jwt.algorithms.Algorithm import com.baseflow.api.admin.adminModule import com.baseflow.api.apiJsonConfig import com.baseflow.api.documenten.documentenApiModule -import com.baseflow.config.OpenZaakConfig import com.baseflow.config.appModule import com.baseflow.services.StorageService import com.baseflow.tooling.AllTables @@ -20,9 +19,9 @@ import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* +import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.response.* import io.ktor.server.testing.* -import io.ktor.server.plugins.contentnegotiation.* import io.mockk.every import io.mockk.mockk import org.jetbrains.exposed.v1.jdbc.Database @@ -52,17 +51,15 @@ open class TestBase(dbNamePrefix: String) { "documenten.bijwerken", "documenten.verwijderen", "documenten.lock", - "documenten.geforceerd-unlock" + "documenten.geforceerd-unlock", ).joinToString(" ") - fun generateTestToken(scopes: String = ALL_SCOPES): String { - return JWT.create() - .withIssuer(TEST_JWT_ISSUER) - .withSubject("testuser") - .withClaim("scope", scopes) - .withClaim("username", "testuser") - .sign(Algorithm.HMAC256(TEST_JWT_SECRET)) - } + fun generateTestToken(scopes: String = ALL_SCOPES): String = JWT.create() + .withIssuer(TEST_JWT_ISSUER) + .withSubject("testuser") + .withClaim("scope", scopes) + .withClaim("username", "testuser") + .sign(Algorithm.HMAC256(TEST_JWT_SECRET)) } fun connectDb() { @@ -113,7 +110,7 @@ open class TestBase(dbNamePrefix: String) { verifier( JWT.require(Algorithm.HMAC256(TEST_JWT_SECRET)) .withIssuer(TEST_JWT_ISSUER) - .build() + .build(), ) validate { credential -> if (credential.payload.getClaim("username").asString() != "") { @@ -125,7 +122,7 @@ open class TestBase(dbNamePrefix: String) { challenge { _, _ -> call.respondText( text = "Unauthorized", - status = HttpStatusCode.Unauthorized + status = HttpStatusCode.Unauthorized, ) } } @@ -134,7 +131,7 @@ open class TestBase(dbNamePrefix: String) { verifier( JWT.require(Algorithm.HMAC256(TEST_JWT_SECRET)) .withIssuer(TEST_JWT_ISSUER) - .build() + .build(), ) validate { credential -> if (credential.payload.getClaim("username").asString() != "") { @@ -146,25 +143,22 @@ open class TestBase(dbNamePrefix: String) { challenge { _, _ -> call.respondText( text = "Unauthorized", - status = HttpStatusCode.Unauthorized + status = HttpStatusCode.Unauthorized, ) } } } - val openZaakConfig = OpenZaakConfig(validationEnabled = false) - documentenApiModule(useAuthentication = true, openZaakConfig = openZaakConfig) + documentenApiModule(useAuthentication = true) + adminModule(useAuthentication = false) } /** * Creates an HTTP client with a default Bearer token containing all scopes. */ - fun ApplicationTestBuilder.authenticatedClient(): HttpClient { - return createClient { - install(DefaultRequest) { - header(HttpHeaders.Authorization, "Bearer ${generateTestToken()}") - } + fun ApplicationTestBuilder.authenticatedClient(): HttpClient = createClient { + install(DefaultRequest) { + header(HttpHeaders.Authorization, "Bearer ${generateTestToken()}") } - adminModule(useAuthentication = false) } }