Skip to content

Commit 225ad53

Browse files
megglosclaude
andcommitted
docs(auth): ADR-0024 and adopter guide for CamundaSecurityScopeProvider SPI
ADR-0024 records the SPI and descriptor, the reusable per-scope builders, the BDRPP + OrderedSecurityFilterChainWrapper registration mechanism and the chain-ordering rationale, the structural isolation guarantee (per-scope decoder and per-scope token validator enforce both issuer and audience — including shared-IdP / physical-tenant scenarios), and the adopter caveat that the provider must be declared as a static @bean to avoid premature @configuration instantiation. The adopter guide gains an "Extension hooks" subsection covering how a host contributes scoped chains, the no-op-when-absent guarantee, the static-@bean requirement, and the per-scope isolation behaviour. (Numbered 0024 — 0022/0023 were taken on main by the resource-access-control and bearer-tokens-on-API-chain ADRs.) It also documents that OIDC-scoped descriptors require the CSL OIDC infrastructure beans (present in global OIDC mode), and the startup error when they are absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3fa57b3 commit 225ad53

2 files changed

Lines changed: 289 additions & 1 deletion

File tree

docs/adopters/security-filter-chains.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,66 @@ Many library-supplied infrastructure beans intended to be overridden (including
419419

420420
## Extension hooks
421421

422-
Two extension points let hosts customise specific OAuth2/OIDC concerns without replacing entire chains. Host-specific filter wiring (authorization filters, header rewrites, matcher tweaks) will be addressed in a follow-up PR with a more focused approach than a generic `HttpSecurity` mutator.
422+
Three extension points let hosts contribute additional path-scoped API chains or customise specific OAuth2/OIDC concerns without replacing entire chains. Host-specific filter wiring (authorization filters, header rewrites, matcher tweaks) will be addressed in a follow-up PR with a more focused approach than a generic `HttpSecurity` mutator.
423+
424+
### `CamundaSecurityScopeProvider` — contribute path-scoped API chains
425+
426+
When a host needs to expose additional path-scoped API surfaces — each with its own isolated set of OIDC providers or a per-scope basic-auth authority — it implements `CamundaSecurityScopeProvider` and registers it as a bean. CSL builds one `SecurityFilterChain` per returned descriptor alongside its own chains; no action is required when no provider bean is present.
427+
428+
```java
429+
public interface CamundaSecurityScopeProvider {
430+
List<ScopedSecurityDescriptor> get();
431+
}
432+
```
433+
434+
**The descriptor.** `ScopedSecurityDescriptor(String basePath, AuthenticationConfiguration authentication)` carries two fields:
435+
436+
- `basePath` — the scope's path prefix. CSL derives the chain's security matchers by prefixing each entry from `SecurityPathPort.apiPaths()` (and `SecurityPathPort.unprotectedApiPaths()`) with `basePath`. The API surface is host-defined: when the host's `apiPaths()` is `{"/v2/**"}`, a `basePath` of `/scopes/abc` produces a matcher of `/scopes/abc/v2/**`; a host with `apiPaths()={"/api/**"}` produces `/scopes/abc/api/**` instead. Keeping the descriptor surface-agnostic means that if a future surface type (e.g. a scope-specific webapp) is added, the same descriptor record is reused without changing the host contract.
437+
- `authentication` — a (merged) `AuthenticationConfiguration` carrying only the OIDC providers or auth method for this scope. CSL builds a dedicated `JwtDecoder` from this configuration via `ScopedJwtDecoderFactory.buildIssuerAwareDecoder(AuthenticationConfiguration)`. The isolation is structural: a per-scope `TokenValidatorFactory` is built from the scope's own merged provider configuration, so both issuer and audience validation are enforced using the scope's values. Concretely: a token whose `iss` claim matches none of the scope's providers fails with an informative `BadJwtException`; a token whose `aud` claim does not include any of the scope's configured audiences is also rejected, even when two scopes share the same issuer (shared-IdP / physical-tenant isolation). The auth method (OIDC resource-server or HTTP Basic) is selected from `authentication.getMethod()`.
438+
439+
**Declaring the provider bean.** The collector that enumerates descriptors runs during Spring's bean-definition registration phase, before the enclosing `@Configuration` class is constructed. It calls `getBean` on each `CamundaSecurityScopeProvider` at that point. If the provider is a non-static `@Bean` on a `@Configuration` class that uses inter-`@Bean` method references (CGLIB enhancement), Spring will instantiate the configuration class too early and log:
440+
441+
> "Cannot enhance @Configuration bean definition ... created too early"
442+
443+
The configuration class then loses CGLIB proxy behaviour silently — inter-`@Bean` calls will not route through the Spring container. To avoid this, declare the provider as one of:
444+
445+
- a **`static @Bean`** on the host `@Configuration` (preferred), or
446+
- a `@Bean` on a **`@Configuration(proxyBeanMethods = false)`** class, or
447+
- a **standalone `@Component`** or `@Service` bean without inter-bean method references.
448+
449+
**Example.**
450+
451+
```java
452+
@Configuration
453+
public class HostScopeConfiguration {
454+
455+
// IMPORTANT: declare as static @Bean to avoid the "created too early" CGLIB warning.
456+
@Bean
457+
public static CamundaSecurityScopeProvider hostScopeProvider(final MyScopes myScopes) {
458+
return () ->
459+
myScopes.all().stream()
460+
.map(
461+
scope ->
462+
new ScopedSecurityDescriptor(
463+
"/scopes/" + scope.id(), scope.authenticationConfiguration()))
464+
.toList();
465+
}
466+
}
467+
```
468+
469+
How each scope's `AuthenticationConfiguration` is assembled (which providers it carries, per-provider overrides, the auth method) is entirely the host's concern — CSL only consumes the finished configuration.
470+
471+
**OIDC infrastructure requirement.** When a descriptor uses `authentication.method=oidc`, CSL
472+
builds a per-scope `JwtDecoder` via `ScopedJwtDecoderFactory`. That factory — along with the
473+
decoder and client-registration factories it depends on — is only present when the cluster is
474+
running in global OIDC mode (`camunda.security.authentication.method=oidc`, so
475+
`OidcBeansConfiguration` is active). Hosts that contribute an OIDC-scoped descriptor without the
476+
global OIDC infrastructure will see a startup failure with an actionable message naming the
477+
missing bean and pointing to the fix. If the host must run with OIDC-scoped chains but a
478+
non-OIDC global mode, it must provide the OIDC infrastructure beans itself (e.g. by importing
479+
`OidcBeansConfiguration` directly, regardless of the global auth method).
480+
481+
**Activation.** `ScopedApiSecurityConfiguration` is part of the `CamundaSecurityAutoConfiguration` umbrella — no additional `@Import` is needed when a host uses the umbrella. Hosts that `@Import` individual CSL configurations must add `ScopedApiSecurityConfiguration.class` to their `@Import` list. See [ADR-0024](../adr/0024-camunda-security-scope-provider-spi.md) for the design rationale.
423482

424483
### `OidcResourceServerCustomizer` — customise the OAuth2 resource-server DSL
425484

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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

Comments
 (0)