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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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", "ledger:$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, 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, opposite)
withClue("${endpoint.method} ${endpoint.path} with $opposite") {
response.statusCode.value() shouldBe FORBIDDEN
}
}
}

private fun call(
endpoint: Endpoint,
access: Access,
): ResponseEntity<String> {
val headers =
HttpHeaders().apply {
contentType = MediaType.APPLICATION_JSON
setBearerAuth(bearerFor(access))
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 bearerFor(access: Access): String = if (access == Access.READ) "read" else "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" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,28 +38,14 @@ 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 ->
Jwt
.withTokenValue(token)
.header("alg", "none")
.subject(ACTOR)
.claim("scope", "ledger:read")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS))
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/**",
Expand Down
5 changes: 5 additions & 0 deletions services/ledger/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading