From 8999f937df4cab460b58872879b0210316ebb66f Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Mon, 15 Jun 2026 20:26:17 -0300 Subject: [PATCH 1/2] feat(ledger): enforce per-endpoint keycloak scope authorization map the jwt scope claim to authorities and require ledger:read on read endpoints and ledger:write on mutating /v1 endpoints. a valid token with the wrong scope now returns 403, which activates the dormant denied audit path. configure the resource server issuer-uri and update the existing tests to carry the required scopes. Closes #47 Closes #48 --- .../fincore/ledger/api/AccountListingIT.kt | 1 + .../com/fincore/ledger/api/ErrorContractIT.kt | 1 + .../com/fincore/ledger/api/FailureAuditIT.kt | 1 + .../fincore/ledger/api/LedgerApiSmokeIT.kt | 8 + .../ledger/api/ScopeAuthorizationMatrixIT.kt | 141 ++++++++++++++++++ .../ledger/api/TransactionListingIT.kt | 1 + .../fincore/ledger/config/DeniedAuditIT.kt | 20 +-- .../fincore/ledger/config/SecurityConfig.kt | 20 ++- .../ledger/src/main/resources/application.yml | 5 + .../ledger/api/AccountControllerTest.kt | 65 ++++++-- .../ledger/api/TransactionControllerTest.kt | 55 +++++-- 11 files changed, 270 insertions(+), 48 deletions(-) create mode 100644 services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ScopeAuthorizationMatrixIT.kt diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/AccountListingIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/AccountListingIT.kt index a89b736..cea3d91 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/AccountListingIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/AccountListingIT.kt @@ -47,6 +47,7 @@ class AccountListingIT( .withTokenValue(token) .header("alg", "none") .subject("list-it") + .claim("scope", "ledger:read") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) .build() diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ErrorContractIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ErrorContractIT.kt index 2aee2b9..ebe9c08 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ErrorContractIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ErrorContractIT.kt @@ -45,6 +45,7 @@ class ErrorContractIT( .withTokenValue(token) .header("alg", "none") .subject("err-contract-it") + .claim("scope", "ledger:read ledger:write") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) .build() diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/FailureAuditIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/FailureAuditIT.kt index cf69129..233c41b 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/FailureAuditIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/FailureAuditIT.kt @@ -51,6 +51,7 @@ class FailureAuditIT( .withTokenValue(token) .header("alg", "none") .subject(ACTOR) + .claim("scope", "ledger:write") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) .build() diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt index ade9251..943bb99 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt @@ -41,6 +41,7 @@ class LedgerApiSmokeIT( .withTokenValue(token) .header("alg", "none") .subject("smoke-user") + .claim("scope", "ledger:read ledger:write") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) .build() @@ -58,6 +59,13 @@ class LedgerApiSmokeIT( body shouldContain "BUSL-1.1" } + @Test + fun `should serve the actuator health endpoint without a token`() { + val response = rest.getForEntity("/actuator/health", String::class.java) + + response.statusCode.value() shouldBe 200 + } + @Test fun `should replay an identical response for a repeated create with the same key and body`() { val headers = diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ScopeAuthorizationMatrixIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ScopeAuthorizationMatrixIT.kt new file mode 100644 index 0000000..513a235 --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ScopeAuthorizationMatrixIT.kt @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api + +import com.fincore.core.AccountId +import com.fincore.core.TransactionId +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.api.observability.CorrelationIdAttributes +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.assertions.withClue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.time.Instant +import java.util.UUID + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith(PostgresContainerExtension::class) +@Import(ScopeAuthorizationMatrixIT.ScopeFromTokenSecurity::class) +class ScopeAuthorizationMatrixIT( + @Autowired private val rest: TestRestTemplate, +) { + @TestConfiguration + class ScopeFromTokenSecurity { + @Bean + fun jwtDecoder(): JwtDecoder = + JwtDecoder { token -> + Jwt + .withTokenValue(token) + .header("alg", "none") + .subject("scope-matrix-it") + .claim("scope", token) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) + .build() + } + } + + private enum class Access { READ, WRITE } + + private data class Endpoint( + val method: HttpMethod, + val path: String, + val access: Access, + ) + + private val accountId = AccountId.generate().toString() + private val transactionId = TransactionId.generate().toString() + + private val endpoints = + listOf( + Endpoint(HttpMethod.POST, "/v1/accounts", Access.WRITE), + Endpoint(HttpMethod.GET, "/v1/accounts", Access.READ), + Endpoint(HttpMethod.GET, "/v1/accounts/$accountId", Access.READ), + Endpoint(HttpMethod.GET, "/v1/accounts/$accountId/balance", Access.READ), + Endpoint(HttpMethod.GET, "/v1/accounts/$accountId/entries", Access.READ), + Endpoint(HttpMethod.POST, "/v1/transactions", Access.WRITE), + Endpoint(HttpMethod.GET, "/v1/transactions", Access.READ), + Endpoint(HttpMethod.GET, "/v1/transactions/$transactionId", Access.READ), + Endpoint(HttpMethod.POST, "/v1/transactions/$transactionId/reverse", Access.WRITE), + ) + + @Test + fun `should pass authorization for every endpoint when the token carries the matching scope`() { + endpoints.forEach { endpoint -> + val response = call(endpoint, scopeOf(endpoint.access)) + withClue("${endpoint.method} ${endpoint.path} with ${endpoint.access}") { + response.statusCode.value() shouldNotBe FORBIDDEN + response.statusCode.value() shouldNotBe UNAUTHORIZED + } + } + } + + @Test + fun `should return 403 for every endpoint when the token carries only the opposite scope`() { + endpoints.forEach { endpoint -> + val opposite = if (endpoint.access == Access.READ) Access.WRITE else Access.READ + val response = call(endpoint, scopeOf(opposite)) + withClue("${endpoint.method} ${endpoint.path} with $opposite") { + response.statusCode.value() shouldBe FORBIDDEN + } + } + } + + private fun call( + endpoint: Endpoint, + scope: String, + ): ResponseEntity { + val headers = + HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + setBearerAuth(scope) + set(CorrelationIdAttributes.HEADER, UUID.randomUUID().toString()) + if (endpoint.method == HttpMethod.POST) set(IdempotencyAttributes.HEADER, idemKey()) + } + val body = if (endpoint.method == HttpMethod.POST) "{}" else null + return rest.exchange(endpoint.path, endpoint.method, HttpEntity(body, headers), String::class.java) + } + + private fun scopeOf(access: Access): String = if (access == Access.READ) "ledger:read" else "ledger:write" + + private companion object { + const val EXPIRY_SECONDS = 3600L + const val KEY_LENGTH = 40 + const val FORBIDDEN = 403 + const val UNAUTHORIZED = 401 + private var counter = 0 + + fun idemKey(): String { + val suffix = (++counter).toString() + return "m".repeat(KEY_LENGTH - suffix.length) + suffix + } + + @JvmStatic + @DynamicPropertySource + fun datasourceProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl } + registry.add("spring.datasource.username") { PostgresContainerExtension.username } + registry.add("spring.datasource.password") { PostgresContainerExtension.password } + registry.add("spring.datasource.hikari.maximum-pool-size") { "2" } + registry.add("spring.jpa.hibernate.ddl-auto") { "none" } + } + } +} diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/TransactionListingIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/TransactionListingIT.kt index 0ee55c2..0c970af 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/TransactionListingIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/TransactionListingIT.kt @@ -44,6 +44,7 @@ class TransactionListingIT( .withTokenValue(token) .header("alg", "none") .subject("tx-list-it") + .claim("scope", "ledger:read") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) .build() diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/config/DeniedAuditIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/config/DeniedAuditIT.kt index ff78d9a..19bc7a5 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/config/DeniedAuditIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/config/DeniedAuditIT.kt @@ -18,16 +18,12 @@ import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import -import org.springframework.core.annotation.Order import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.MediaType -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.JwtDecoder -import org.springframework.security.web.SecurityFilterChain import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import java.time.Instant @@ -42,21 +38,6 @@ class DeniedAuditIT( ) { @TestConfiguration class DenyWritesSecurity { - @Bean - @Order(1) - fun deniedAccountsChain( - http: HttpSecurity, - accessDeniedHandler: AuditingAccessDeniedHandler, - ): SecurityFilterChain = - http - .securityMatcher("/v1/accounts") - .csrf { it.disable() } - .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .authorizeHttpRequests { it.anyRequest().denyAll() } - .exceptionHandling { it.accessDeniedHandler(accessDeniedHandler) } - .oauth2ResourceServer { it.jwt {} } - .build() - @Bean fun jwtDecoder(): JwtDecoder = JwtDecoder { token -> @@ -64,6 +45,7 @@ class DeniedAuditIT( .withTokenValue(token) .header("alg", "none") .subject(ACTOR) + .claim("scope", "ledger:read") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) .build() diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt index 391ba4a..42b2cfd 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt @@ -5,9 +5,12 @@ package com.fincore.ledger.config import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter import org.springframework.security.web.SecurityFilterChain @Configuration @@ -25,13 +28,26 @@ class SecurityConfig { it .requestMatchers(*PUBLIC_PATHS) .permitAll() + .requestMatchers(HttpMethod.GET, LEDGER_PATHS) + .hasAuthority(SCOPE_READ) + .requestMatchers(LEDGER_PATHS) + .hasAuthority(SCOPE_WRITE) .anyRequest() .authenticated() }.exceptionHandling { it.accessDeniedHandler(accessDeniedHandler) } - .oauth2ResourceServer { it.jwt {} } - .build() + .oauth2ResourceServer { resource -> + resource.jwt { it.jwtAuthenticationConverter(jwtAuthenticationConverter()) } + }.build() + + private fun jwtAuthenticationConverter(): JwtAuthenticationConverter = + JwtAuthenticationConverter().apply { + setJwtGrantedAuthoritiesConverter(JwtGrantedAuthoritiesConverter()) + } private companion object { + const val LEDGER_PATHS = "/v1/**" + const val SCOPE_READ = "SCOPE_ledger:read" + const val SCOPE_WRITE = "SCOPE_ledger:write" val PUBLIC_PATHS = arrayOf( "/v3/api-docs/**", diff --git a/services/ledger/src/main/resources/application.yml b/services/ledger/src/main/resources/application.yml index 10bf2ad..740a1ad 100644 --- a/services/ledger/src/main/resources/application.yml +++ b/services/ledger/src/main/resources/application.yml @@ -1,6 +1,11 @@ spring: application: name: ledger-service + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${KEYCLOAK_ISSUER_URI} springdoc: api-docs: path: /v3/api-docs diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt index 04ffea6..2218a6d 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt @@ -48,6 +48,7 @@ import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt import org.springframework.test.web.servlet.MockMvc @@ -108,7 +109,7 @@ class AccountControllerTest( mockMvc .perform( post("/v1/accounts") - .with(jwt().jwt { it.subject("user-123") }) + .with(writeJwt()) .header(IdempotencyAttributes.HEADER, key) .contentType(MediaType.APPLICATION_JSON) .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), @@ -127,7 +128,7 @@ class AccountControllerTest( every { accountService.get(any()) } throws AccountNotFoundException(id) mockMvc - .perform(get("/v1/accounts/$id").with(jwt())) + .perform(get("/v1/accounts/$id").with(readJwt())) .andExpect(status().isNotFound) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.detail", not(containsString("Exception")))) @@ -136,7 +137,7 @@ class AccountControllerTest( @Test fun `should return 400 for a malformed account id without calling the service`() { mockMvc - .perform(get("/v1/accounts/acc_zzz").with(jwt())) + .perform(get("/v1/accounts/acc_zzz").with(readJwt())) .andExpect(status().isBadRequest) verify(exactly = 0) { accountService.get(any()) } @@ -150,7 +151,7 @@ class AccountControllerTest( AccountBalance(id, Money.of(BigDecimal("100.00"), Currency.EUR), Instant.parse("2026-06-13T10:00:00Z")) mockMvc - .perform(get("/v1/accounts/$id/balance").with(jwt())) + .perform(get("/v1/accounts/$id/balance").with(readJwt())) .andExpect(status().isOk) .andExpect(jsonPath("$.accountId").value(id.toString())) .andExpect(jsonPath("$.currency").value("EUR")) @@ -164,7 +165,7 @@ class AccountControllerTest( mockMvc .perform( post("/v1/accounts") - .with(jwt().jwt { it.subject("user-123") }) + .with(writeJwt()) .header(IdempotencyAttributes.HEADER, key) .contentType(MediaType.APPLICATION_JSON) .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), @@ -181,7 +182,7 @@ class AccountControllerTest( mockMvc .perform( post("/v1/accounts") - .with(jwt().jwt { it.subject("user-123") }) + .with(writeJwt()) .header(IdempotencyAttributes.HEADER, key) .contentType(MediaType.APPLICATION_JSON) .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), @@ -193,7 +194,7 @@ class AccountControllerTest( mockMvc .perform( post("/v1/accounts") - .with(jwt().jwt { it.subject("user-123") }) + .with(writeJwt()) .contentType(MediaType.APPLICATION_JSON) .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), ).andExpect(status().isBadRequest) @@ -213,7 +214,7 @@ class AccountControllerTest( mockMvc .perform( post("/v1/accounts") - .with(jwt().jwt { it.subject("user-123") }) + .with(writeJwt()) .header(IdempotencyAttributes.HEADER, key) .contentType(MediaType.APPLICATION_JSON) .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"euro"}"""), @@ -236,7 +237,7 @@ class AccountControllerTest( ) mockMvc - .perform(get("/v1/accounts?page=0&size=20").with(jwt())) + .perform(get("/v1/accounts?page=0&size=20").with(readJwt())) .andExpect(status().isOk) .andExpect(jsonPath("$.items[0].id").value(matchesPattern("^acc_[0-9A-HJKMNP-TV-Z]{26}$"))) .andExpect(jsonPath("$.page").value(0)) @@ -246,7 +247,7 @@ class AccountControllerTest( @Test fun `should reject an oversized page request with 400`() { mockMvc - .perform(get("/v1/accounts?size=101").with(jwt())) + .perform(get("/v1/accounts?size=101").with(readJwt())) .andExpect(status().isBadRequest) verify(exactly = 0) { accountService.list(any(), any()) } @@ -255,7 +256,7 @@ class AccountControllerTest( @Test fun `should reject a negative page index with 400`() { mockMvc - .perform(get("/v1/accounts?page=-1").with(jwt())) + .perform(get("/v1/accounts?page=-1").with(readJwt())) .andExpect(status().isBadRequest) } @@ -280,7 +281,7 @@ class AccountControllerTest( AccountEntryPage(listOf(entry()), "Y3Vyc29y") mockMvc - .perform(get("/v1/accounts/${AccountId.generate()}/entries").with(jwt())) + .perform(get("/v1/accounts/${AccountId.generate()}/entries").with(readJwt())) .andExpect(status().isOk) .andExpect(jsonPath("$.items[0].id").value(matchesPattern("^ent_[0-9A-HJKMNP-TV-Z]{26}$"))) .andExpect(jsonPath("$.items[0].transactionId").value(matchesPattern("^tx_[0-9A-HJKMNP-TV-Z]{26}$"))) @@ -292,7 +293,7 @@ class AccountControllerTest( @Test fun `should return 400 for a malformed account id on the entries endpoint`() { mockMvc - .perform(get("/v1/accounts/acc_zzz/entries").with(jwt())) + .perform(get("/v1/accounts/acc_zzz/entries").with(readJwt())) .andExpect(status().isBadRequest) verify(exactly = 0) { entryQueryService.listAccountEntries(any(), any(), any(), any(), any()) } @@ -301,7 +302,7 @@ class AccountControllerTest( @Test fun `should return 400 for a malformed from timestamp`() { mockMvc - .perform(get("/v1/accounts/${AccountId.generate()}/entries?from=not-a-date").with(jwt())) + .perform(get("/v1/accounts/${AccountId.generate()}/entries?from=not-a-date").with(readJwt())) .andExpect(status().isBadRequest) verify(exactly = 0) { entryQueryService.listAccountEntries(any(), any(), any(), any(), any()) } @@ -313,7 +314,7 @@ class AccountControllerTest( IllegalArgumentException("range must span at most 90 days") mockMvc - .perform(get("/v1/accounts/${AccountId.generate()}/entries?limit=300").with(jwt())) + .perform(get("/v1/accounts/${AccountId.generate()}/entries?limit=300").with(readJwt())) .andExpect(status().isBadRequest) } @@ -323,7 +324,7 @@ class AccountControllerTest( AccountNotFoundException(AccountId.generate()) mockMvc - .perform(get("/v1/accounts/${AccountId.generate()}/entries").with(jwt())) + .perform(get("/v1/accounts/${AccountId.generate()}/entries").with(readJwt())) .andExpect(status().isNotFound) } @@ -333,4 +334,36 @@ class AccountControllerTest( .perform(get("/v1/accounts/${AccountId.generate()}/entries")) .andExpect(status().isUnauthorized) } + + @Test + fun `should return 403 when creating an account with only the read scope`() { + mockMvc + .perform( + post("/v1/accounts") + .with(readJwt()) + .header(IdempotencyAttributes.HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), + ).andExpect(status().isForbidden) + + verify(exactly = 0) { accountService.create(any()) } + } + + @Test + fun `should return 403 when reading an account with only the write scope`() { + mockMvc + .perform(get("/v1/accounts/${AccountId.generate()}").with(writeJwt())) + .andExpect(status().isForbidden) + + verify(exactly = 0) { accountService.get(any()) } + } + + private fun readJwt() = jwt().authorities(SimpleGrantedAuthority(SCOPE_READ)) + + private fun writeJwt() = jwt().jwt { it.subject("user-123") }.authorities(SimpleGrantedAuthority(SCOPE_WRITE)) + + private companion object { + const val SCOPE_READ = "SCOPE_ledger:read" + const val SCOPE_WRITE = "SCOPE_ledger:write" + } } diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt index ceddf9c..91cd436 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt @@ -44,6 +44,7 @@ import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt import org.springframework.test.web.servlet.MockMvc @@ -101,7 +102,7 @@ class TransactionControllerTest( private fun postBalanced(correlationId: String? = null) = mockMvc.perform( post("/v1/transactions") - .with(jwt().jwt { it.subject("user-123") }) + .with(writeJwt()) .header(IdempotencyAttributes.HEADER, key) .apply { if (correlationId != null) header("X-Correlation-Id", correlationId) } .contentType(MediaType.APPLICATION_JSON) @@ -166,7 +167,7 @@ class TransactionControllerTest( mockMvc .perform( post("/v1/transactions") - .with(jwt().jwt { it.subject("user-123") }) + .with(writeJwt()) .header(IdempotencyAttributes.HEADER, key) .contentType(MediaType.APPLICATION_JSON) .content("""{"reference":"r","currency":"EUR","entries":[{"accountId":"$accountA","direction":"DEBIT","amount":1}]}"""), @@ -190,7 +191,7 @@ class TransactionControllerTest( ) mockMvc - .perform(get("/v1/transactions?page=0&size=20").with(jwt())) + .perform(get("/v1/transactions?page=0&size=20").with(readJwt())) .andExpect(status().isOk) .andExpect(jsonPath("$.items[0].id").value(matchesPattern("^tx_[0-9A-HJKMNP-TV-Z]{26}$"))) .andExpect(jsonPath("$.items[0].status").value("POSTED")) @@ -204,7 +205,7 @@ class TransactionControllerTest( every { transactionService.list(0, 20) } returns TransactionPage(emptyList(), 0, 20, 0, 0) mockMvc - .perform(get("/v1/transactions").with(jwt())) + .perform(get("/v1/transactions").with(readJwt())) .andExpect(status().isOk) .andExpect(jsonPath("$.items").isEmpty) .andExpect(jsonPath("$.totalElements").value(0)) @@ -214,7 +215,7 @@ class TransactionControllerTest( @Test fun `should reject an oversized page request with 400`() { mockMvc - .perform(get("/v1/transactions?size=101").with(jwt())) + .perform(get("/v1/transactions?size=101").with(readJwt())) .andExpect(status().isBadRequest) verify(exactly = 0) { transactionService.list(any(), any()) } @@ -223,7 +224,7 @@ class TransactionControllerTest( @Test fun `should reject a zero page size with 400`() { mockMvc - .perform(get("/v1/transactions?size=0").with(jwt())) + .perform(get("/v1/transactions?size=0").with(readJwt())) .andExpect(status().isBadRequest) verify(exactly = 0) { transactionService.list(any(), any()) } @@ -232,7 +233,7 @@ class TransactionControllerTest( @Test fun `should reject a negative page index with 400`() { mockMvc - .perform(get("/v1/transactions?page=-1").with(jwt())) + .perform(get("/v1/transactions?page=-1").with(readJwt())) .andExpect(status().isBadRequest) verify(exactly = 0) { transactionService.list(any(), any()) } @@ -262,7 +263,7 @@ class TransactionControllerTest( ) mockMvc - .perform(get("/v1/transactions/$txId").with(jwt())) + .perform(get("/v1/transactions/$txId").with(readJwt())) .andExpect(status().isOk) .andExpect(jsonPath("$.id").value(txId.toString())) .andExpect(jsonPath("$.id").value(matchesPattern("^tx_[0-9A-HJKMNP-TV-Z]{26}$"))) @@ -279,7 +280,7 @@ class TransactionControllerTest( every { transactionService.get(any()) } throws TransactionNotFoundException(TransactionId.generate()) mockMvc - .perform(get("/v1/transactions/${TransactionId.generate()}").with(jwt())) + .perform(get("/v1/transactions/${TransactionId.generate()}").with(readJwt())) .andExpect(status().isNotFound) .andExpect(jsonPath("$.status").value(404)) } @@ -287,7 +288,7 @@ class TransactionControllerTest( @Test fun `should return 400 for a malformed transaction id without calling the service`() { mockMvc - .perform(get("/v1/transactions/tx_zzz").with(jwt())) + .perform(get("/v1/transactions/tx_zzz").with(readJwt())) .andExpect(status().isBadRequest) verify(exactly = 0) { transactionService.get(any()) } @@ -299,7 +300,7 @@ class TransactionControllerTest( authenticated: Boolean = true, ) = mockMvc.perform( post("/v1/transactions/$id/reverse") - .apply { if (authenticated) with(jwt().jwt { it.subject("user-123") }) } + .apply { if (authenticated) with(writeJwt()) } .apply { if (withKey) header(IdempotencyAttributes.HEADER, key) } .contentType(MediaType.APPLICATION_JSON), ) @@ -344,4 +345,36 @@ class TransactionControllerTest( fun `should reject an unauthenticated reverse with 401`() { reverse(TransactionId.generate().toString(), authenticated = false).andExpect(status().isUnauthorized) } + + @Test + fun `should return 403 when posting a transaction with only the read scope`() { + mockMvc + .perform( + post("/v1/transactions") + .with(readJwt()) + .header(IdempotencyAttributes.HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(balancedBody()), + ).andExpect(status().isForbidden) + + verify(exactly = 0) { transactionService.post(any()) } + } + + @Test + fun `should return 403 when listing transactions with only the write scope`() { + mockMvc + .perform(get("/v1/transactions").with(writeJwt())) + .andExpect(status().isForbidden) + + verify(exactly = 0) { transactionService.list(any(), any()) } + } + + private fun readJwt() = jwt().authorities(SimpleGrantedAuthority(SCOPE_READ)) + + private fun writeJwt() = jwt().jwt { it.subject("user-123") }.authorities(SimpleGrantedAuthority(SCOPE_WRITE)) + + private companion object { + const val SCOPE_READ = "SCOPE_ledger:read" + const val SCOPE_WRITE = "SCOPE_ledger:write" + } } From e5e8dda4d413232450a148ef062a5d897aebc7b1 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Mon, 15 Jun 2026 20:38:11 -0300 Subject: [PATCH 2/2] fix(ledger): green the scope matrix it on ci the scope matrix it used the scope string as the bearer token value, but a colon is not a valid bearer token character so the resolver rejected it and every request returned 401. use colon-free token values and build the scope claim in the decoder. also raise the shared test postgres max_connections so the added springboottest context does not exhaust the connection ceiling. --- .../test/containers/PostgresContainerExtension.kt | 2 ++ .../fincore/ledger/api/ScopeAuthorizationMatrixIT.kt | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/libs/fincore-test-support/src/main/kotlin/com/fincore/test/containers/PostgresContainerExtension.kt b/libs/fincore-test-support/src/main/kotlin/com/fincore/test/containers/PostgresContainerExtension.kt index fabc113..9e56d38 100644 --- a/libs/fincore-test-support/src/main/kotlin/com/fincore/test/containers/PostgresContainerExtension.kt +++ b/libs/fincore-test-support/src/main/kotlin/com/fincore/test/containers/PostgresContainerExtension.kt @@ -14,6 +14,7 @@ class PostgresContainerExtension : BeforeAllCallback { companion object { private const val IMAGE = "postgres:17-alpine" + private const val MAX_CONNECTIONS = 200 @Volatile private var container: PostgreSQLContainer<*>? = null @@ -27,6 +28,7 @@ class PostgresContainerExtension : BeforeAllCallback { .withDatabaseName("fincore_test") .withUsername("fincore") .withPassword("fincore") + .withCommand("postgres", "-c", "max_connections=$MAX_CONNECTIONS") .withReuse(true) fresh.start() container = fresh diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ScopeAuthorizationMatrixIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ScopeAuthorizationMatrixIT.kt index 513a235..f4129af 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ScopeAuthorizationMatrixIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/ScopeAuthorizationMatrixIT.kt @@ -46,7 +46,7 @@ class ScopeAuthorizationMatrixIT( .withTokenValue(token) .header("alg", "none") .subject("scope-matrix-it") - .claim("scope", token) + .claim("scope", "ledger:$token") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) .build() @@ -80,7 +80,7 @@ class ScopeAuthorizationMatrixIT( @Test fun `should pass authorization for every endpoint when the token carries the matching scope`() { endpoints.forEach { endpoint -> - val response = call(endpoint, scopeOf(endpoint.access)) + val response = call(endpoint, endpoint.access) withClue("${endpoint.method} ${endpoint.path} with ${endpoint.access}") { response.statusCode.value() shouldNotBe FORBIDDEN response.statusCode.value() shouldNotBe UNAUTHORIZED @@ -92,7 +92,7 @@ class ScopeAuthorizationMatrixIT( fun `should return 403 for every endpoint when the token carries only the opposite scope`() { endpoints.forEach { endpoint -> val opposite = if (endpoint.access == Access.READ) Access.WRITE else Access.READ - val response = call(endpoint, scopeOf(opposite)) + val response = call(endpoint, opposite) withClue("${endpoint.method} ${endpoint.path} with $opposite") { response.statusCode.value() shouldBe FORBIDDEN } @@ -101,12 +101,12 @@ class ScopeAuthorizationMatrixIT( private fun call( endpoint: Endpoint, - scope: String, + access: Access, ): ResponseEntity { val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON - setBearerAuth(scope) + setBearerAuth(bearerFor(access)) set(CorrelationIdAttributes.HEADER, UUID.randomUUID().toString()) if (endpoint.method == HttpMethod.POST) set(IdempotencyAttributes.HEADER, idemKey()) } @@ -114,7 +114,7 @@ class ScopeAuthorizationMatrixIT( return rest.exchange(endpoint.path, endpoint.method, HttpEntity(body, headers), String::class.java) } - private fun scopeOf(access: Access): String = if (access == Access.READ) "ledger:read" else "ledger:write" + private fun bearerFor(access: Access): String = if (access == Access.READ) "read" else "write" private companion object { const val EXPIRY_SECONDS = 3600L