|
| 1 | +--- |
| 2 | +status: Accepted |
| 3 | +--- |
| 4 | + |
| 5 | +# ADR-0024: `CamundaSecurityScopeProvider` SPI for host-contributed path-scoped API chains |
| 6 | + |
| 7 | +**Deciders**: Sebastian Bathke (CSL DRI); aligned with the OC/identity squad (2026-06-05) |
| 8 | + |
| 9 | +## Status |
| 10 | + |
| 11 | +Accepted |
| 12 | + |
| 13 | +## Context |
| 14 | + |
| 15 | +CSL owns the standard API and webapp security filter chains for every Camunda host application |
| 16 | +(see [ADR-0006](0006-central-security-filter-chains.md)). Those chains cover the host's |
| 17 | +primary API surface (e.g. `/v2/**`) with a single, shared `JwtDecoder` and `ClientRegistration`. |
| 18 | + |
| 19 | +Certain host deployments need to expose additional path-scoped API surfaces — each with its own |
| 20 | +isolated provider set — protected with the same kind of enforcement CSL already applies to the |
| 21 | +primary chain (OIDC bearer-token validation, or HTTP Basic). The isolation that matters is the |
| 22 | +provider set: a token issued for one scope's providers must not authenticate against a scope that |
| 23 | +carries only different providers. This is a security property; a single shared decoder that merged |
| 24 | +all providers would weaken it. |
| 25 | + |
| 26 | +CSL builds each scope's chain from the authentication method declared on its descriptor, and is |
| 27 | +agnostic to how that method relates to the primary chain or to other scopes. If a deployment |
| 28 | +requires a single consistent method across all its scopes, that is a host-side constraint the host |
| 29 | +is responsible for (for example, via configuration validation) — CSL neither assumes nor enforces |
| 30 | +it. Keeping CSL out of that concern keeps the SPI simple. |
| 31 | + |
| 32 | +Before this change, a host that needed such a surface had to assemble a `SecurityFilterChain` |
| 33 | +bean by hand, duplicating the CSL chain shape, the hardened HTTP-header defaults, CSRF wiring, and |
| 34 | +the decoder selection logic. Each duplication is a future drift risk: improvements to CSL's chain |
| 35 | +assembly (new headers, CSRF rule changes, logging) do not automatically propagate to hand-rolled |
| 36 | +host chains. |
| 37 | + |
| 38 | +CSL is scope-agnostic: it should not learn what a scope *means* to the host — that is a host |
| 39 | +concern. What CSL can own is the chain-assembly mechanics. |
| 40 | + |
| 41 | +The question this ADR answers: what SPI shape lets hosts contribute path-scoped API chains while |
| 42 | +keeping CSL agnostic of scope semantics, and owning chain assembly as a single source of truth? |
| 43 | + |
| 44 | +## Decision |
| 45 | + |
| 46 | +### SPI and descriptor |
| 47 | + |
| 48 | +A new inbound SPI `CamundaSecurityScopeProvider` is added to `api/context/`: |
| 49 | + |
| 50 | +```java |
| 51 | +public interface CamundaSecurityScopeProvider { |
| 52 | + List<ScopedSecurityDescriptor> get(); |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +The descriptor lives in `api/model/config/`: |
| 57 | + |
| 58 | +```java |
| 59 | +public record ScopedSecurityDescriptor( |
| 60 | + String basePath, AuthenticationConfiguration authentication) { ... } |
| 61 | +``` |
| 62 | + |
| 63 | +`basePath` is the scope's path prefix; CSL derives the chain's security matchers by prefixing |
| 64 | +each entry from `SecurityPathPort.apiPaths()` (and `SecurityPathPort.unprotectedApiPaths()`) with |
| 65 | +`basePath`. The API surface is therefore host-defined: when a host's `apiPaths()` is `{"/v2/**"}`, |
| 66 | +the scoped matcher becomes `basePath + "/v2/**"`; a host with `{"/api/**"}` produces |
| 67 | +`basePath + "/api/**"` instead. This keeps the descriptor surface-agnostic: if a future webapp |
| 68 | +surface is needed for the same scope, the descriptor is reused unchanged and CSL assembles a |
| 69 | +different chain type from it. |
| 70 | + |
| 71 | +The compact constructor rejects a null/blank `basePath` and a null `authentication` at |
| 72 | +construction time. |
| 73 | + |
| 74 | +### Reusable builders (single source of truth) |
| 75 | + |
| 76 | +Chain assembly is factored into three reusable helpers in `spring-boot-starter`: |
| 77 | + |
| 78 | +- **`ScopedClientRegistrationFactory`** — flattens an `AuthenticationConfiguration` (flat `oidc.*` |
| 79 | + block and/or `providers.oidc.*` map) into `ClientRegistration` instances using the same merge |
| 80 | + rule as `OidcAuthenticationConfigurationRepository`. This is the single authoritative |
| 81 | + implementation of that merge; `OidcAuthenticationConfigurationRepository` delegates here instead |
| 82 | + of duplicating it. |
| 83 | + |
| 84 | +- **`ScopedJwtDecoderFactory`** — builds a `JwtDecoder` from an `AuthenticationConfiguration` by |
| 85 | + delegating to `ScopedClientRegistrationFactory` and `OidcAccessTokenDecoderFactory`. When the |
| 86 | + configuration carries a single provider, a single-issuer decoder is produced; when it carries |
| 87 | + multiple providers, the issuer-aware decoder from [ADR-0020](0020-issuer-aware-jwt-decoder.md) |
| 88 | + is selected. A scope-specific `TokenValidatorFactory` is built from the scope's merged provider |
| 89 | + map and threaded into the decoder, so both issuer and audience validation are enforced using the |
| 90 | + scope's own configuration. Structural isolation is the result: a token whose `iss` claim matches |
| 91 | + no provider in the scope fails with a `BadJwtException`; a token whose `aud` claim does not |
| 92 | + satisfy the scope's configured audiences is also rejected — even when two scopes share the same |
| 93 | + issuer (shared-IdP / physical-tenant isolation). The unknown-issuer message names the offending |
| 94 | + issuer to aid diagnosis. |
| 95 | + |
| 96 | +- **`ScopedApiSecurityChainBuilder`** — the single source of truth for the CSL API chain shape. |
| 97 | + It exposes three methods: |
| 98 | + - `buildOidcApiChain(HttpSecurity, matchers, unprotectedMatchers, JwtDecoder)` — the OIDC |
| 99 | + resource-server chain: session stateless, no form login, no anonymous, CSRF and secure-headers |
| 100 | + applied. |
| 101 | + - `buildBasicApiChain(HttpSecurity, matchers, unprotectedMatchers)` — the HTTP Basic chain with |
| 102 | + the same stateless/secure-headers baseline. |
| 103 | + - `buildScopedApiChain(HttpSecurity, basePath, AuthenticationConfiguration, Supplier<JwtDecoder>)` |
| 104 | + — selects OIDC or Basic by `authentication.getMethod()` and derives the chain's security |
| 105 | + matchers by prefixing each entry from `SecurityPathPort.apiPaths()` and |
| 106 | + `SecurityPathPort.unprotectedApiPaths()` with `basePath`. The API surface is host-defined, |
| 107 | + not fixed to `/v2/**`. |
| 108 | + |
| 109 | + CSL's own `OidcApiSecurityConfiguration` and `BasicAuthApiSecurityConfiguration` are re-based on |
| 110 | + `ScopedApiSecurityChainBuilder`. Any future change to the chain shape (new headers, CSRF rule, |
| 111 | + logging) is made once and propagates to both the primary chains and every scope-contributed chain. |
| 112 | + |
| 113 | +### Collector: `ScopedApiSecurityConfiguration` |
| 114 | + |
| 115 | +A new `@Configuration` class `ScopedApiSecurityConfiguration` in `spring-boot-starter` collects |
| 116 | +all `CamundaSecurityScopeProvider` beans and registers one `SecurityFilterChain` bean definition |
| 117 | +per descriptor. |
| 118 | + |
| 119 | +The collector uses a `BeanDefinitionRegistryPostProcessor` (BDRPP) declared as a `static @Bean`. |
| 120 | +The `static` declaration is required: it causes Spring to instantiate the post-processor before |
| 121 | +the enclosing `@Configuration` class is constructed, avoiding the "configuration class created |
| 122 | +too early" warning. At that point, host `@Configuration` parsing has already completed and |
| 123 | +provider beans are registered in the registry, so the BDRPP can call `getBean` on each provider |
| 124 | +to enumerate descriptors. |
| 125 | + |
| 126 | +**Important caveat (also documented in the adopter guide):** because the BDRPP calls `getBean` |
| 127 | +on each `CamundaSecurityScopeProvider` during bean-definition registration, a provider declared |
| 128 | +as a non-static `@Bean` on a `@Configuration` with inter-`@Bean` method references will be |
| 129 | +instantiated before CGLIB enhancement completes. Spring logs: |
| 130 | + |
| 131 | +> "Cannot enhance @Configuration bean definition ... created too early" |
| 132 | +
|
| 133 | +and the configuration class loses CGLIB proxy behaviour — inter-`@Bean` calls will not route |
| 134 | +through the Spring container. Hosts **must** declare their `CamundaSecurityScopeProvider` as one |
| 135 | +of: |
| 136 | + |
| 137 | +- a `static @Bean` on the host `@Configuration`, or |
| 138 | +- a `@Bean` on a `@Configuration(proxyBeanMethods = false)` class, or |
| 139 | +- a standalone `@Component` / `@Service` bean without inter-bean method references. |
| 140 | + |
| 141 | +### `OrderedSecurityFilterChainWrapper` and chain ordering |
| 142 | + |
| 143 | +`DefaultSecurityFilterChain` (the type Spring Security's `HttpSecurity.build()` returns) does not |
| 144 | +implement `Ordered`, and registering bean-definition order attributes does not affect how |
| 145 | +`FilterChainProxy` sequences chains at request time — Spring Security sorts chains by the |
| 146 | +`Ordered` interface, not by bean-registration order. A chain with no order sorts last, behind the |
| 147 | +catch-all deny chain, which would make every contributed request 404/denied. |
| 148 | + |
| 149 | +To give host-contributed chains a defined position, each registered chain is wrapped in |
| 150 | +`OrderedSecurityFilterChainWrapper implements SecurityFilterChain, Ordered`. The wrapper returns |
| 151 | +`ORDER_WEBAPP_API` from `getOrder()` — contributed chains **reuse the primary API order rather than |
| 152 | +a dedicated band**. This is deliberate: their base paths are structurally disjoint from CSL's own |
| 153 | +matchers (a request matches at most one chain regardless of relative order), so the only ordering |
| 154 | +requirement is that they sort before the `ORDER_UNHANDLED` catch-all deny chain. Introducing a |
| 155 | +separate order value would add a constant without buying any correctness. |
| 156 | + |
| 157 | +The filter chain order is: |
| 158 | + |
| 159 | +| Constant | Value | Chain | |
| 160 | +|---|---|---| |
| 161 | +| `ORDER_UNPROTECTED` | `0` | Permit-all unprotected-paths chain | |
| 162 | +| `ORDER_WEBAPP_API` | `1` | Primary API and webapp chains, and host-contributed scope chains | |
| 163 | +| `ORDER_UNHANDLED` | `2` | Catch-all deny chain | |
| 164 | + |
| 165 | +### Activation |
| 166 | + |
| 167 | +`ScopedApiSecurityConfiguration` is added to the umbrella |
| 168 | +`CamundaSecurityAutoConfiguration`'s `@Import` list (see [ADR-0008](0008-no-spring-boot-auto-configuration.md)). |
| 169 | +No action is required when no `CamundaSecurityScopeProvider` bean is present — the BDRPP finds |
| 170 | +zero providers and registers nothing. Hub and single-scope OC hosts are unchanged. |
| 171 | + |
| 172 | +Hosts that do not use the umbrella but import CSL configurations individually must add |
| 173 | +`ScopedApiSecurityConfiguration.class` to their `@Import` list if they want scope-contributed |
| 174 | +chains. |
| 175 | + |
| 176 | +## Consequences |
| 177 | + |
| 178 | +**Positive** |
| 179 | + |
| 180 | +- CSL is the single source of truth for API chain assembly. CSRF rules, HTTP security headers, and |
| 181 | + auth-failure handling are updated in one place and propagate to all chains — host-contributed |
| 182 | + and CSL-owned — without any host change. |
| 183 | +- Structural token isolation is the default. Each scope's decoder is built from a per-scope |
| 184 | + `TokenValidatorFactory` seeded with that scope's providers. A wrong issuer fails at decode with |
| 185 | + an informative message; a mismatched audience fails too — including when two scopes share the |
| 186 | + same issuer but configure different audiences (shared-IdP / physical-tenant isolation). |
| 187 | +- Additive and backward-compatible. Hosts without a `CamundaSecurityScopeProvider` bean are |
| 188 | + unaffected; the BDRPP is a no-op. No renaming or configuration changes to existing deployments. |
| 189 | +- Aligns with [ADR-0008](0008-no-spring-boot-auto-configuration.md): the collector activates only |
| 190 | + through the umbrella or an explicit `@Import` — nothing activates from the Maven dependency |
| 191 | + alone. |
| 192 | +- Reuses the issuer-aware decoder from [ADR-0020](0020-issuer-aware-jwt-decoder.md) for per-scope |
| 193 | + multi-provider validation without duplicating the selection logic. |
| 194 | + |
| 195 | +**Negative / accepted trade-offs** |
| 196 | + |
| 197 | +- Hosts must declare their `CamundaSecurityScopeProvider` as a `static @Bean`, on a |
| 198 | + `@Configuration(proxyBeanMethods = false)`, or as a standalone component to avoid the CGLIB |
| 199 | + "created too early" warning and loss of config-class enhancement. A non-static `@Bean` on a |
| 200 | + `@Configuration` with inter-`@Bean` references will silently lose CGLIB proxy behaviour; no |
| 201 | + exception is thrown, making the issue hard to diagnose without reading Spring's startup logs. |
| 202 | +- One `SecurityFilterChain` bean definition is registered per descriptor. A host with N scopes |
| 203 | + contributes N additional filter chains. For small N this is negligible; very large N is not a |
| 204 | + known use case. |
| 205 | +- The descriptor calls `getBean` during `postProcessBeanDefinitionRegistry`, which means the |
| 206 | + `CamundaSecurityScopeProvider` bean (and any beans it directly depends on) is instantiated |
| 207 | + earlier in the context lifecycle than normal. Provider implementations must not depend on beans |
| 208 | + that require a fully refreshed context. |
| 209 | + |
| 210 | +## Alternatives Considered |
| 211 | + |
| 212 | +- **Host builds finished chains inline.** Each host that needs scope-isolated chains assembles its |
| 213 | + own `SecurityFilterChain` bean, duplicating the CSL chain shape. Rejected — every copy is a |
| 214 | + future drift risk: hardening applied to CSL's own chains (new HTTP headers, CSRF rule changes, |
| 215 | + failure-logging improvements) does not propagate. The SPI solves this permanently. |
| 216 | + |
| 217 | +- **A single scope-aware dispatching chain instead of N chains.** One CSL-owned chain inspects the |
| 218 | + request path at request time, selects the correct decoder, and dispatches. Deferred — the |
| 219 | + descriptor model already makes this possible as a future optimisation (enumerate descriptors at |
| 220 | + startup, build one chain that routes by path prefix) without changing the host contract. |
| 221 | + N distinct chains are simpler to reason about and audit individually; the deferred approach can |
| 222 | + be adopted later if N grows large enough to warrant it. |
| 223 | + |
| 224 | +- **Ordering via `@Order` or bean-definition order attributes.** Register each `SecurityFilterChain` |
| 225 | + bean with `@Order` or set the bean-definition's `order` attribute. Rejected — |
| 226 | + `FilterChainProxy` orders chains by the `Ordered` interface at runtime, not by bean-definition |
| 227 | + order or `@Order` annotations. `DefaultSecurityFilterChain` does not implement `Ordered`, so |
| 228 | + without the `OrderedSecurityFilterChainWrapper` the chains would not have a predictable position |
| 229 | + relative to the catch-all deny chain. |
0 commit comments