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
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@
<version>${project.version}</version>
</dependency>

<!-- Boot autoconfigure for the opt-in header-bridge auto-configuration (off by default).
Compile-scope, matching every other security module that ships an autoconfig
(method-policy, oauth2-client, resource-server, ...). -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2024-2026 Firefly Software Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.security.webflux.bridge;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.ArrayList;
import java.util.List;

/**
* Configuration for the principal&rarr;header bridge. Disabled by default so existing consumers are
* untouched; opting in publishes the trusted tenant/user/roles headers derived from the validated
* token.
*/
@Data
@ConfigurationProperties(prefix = "firefly.security.webflux.header-bridge")
public class PrincipalHeaderBridgeProperties {

/** Master switch; {@code false} by default so no existing consumer changes behaviour. */
private boolean enabled = false;

/** Header set to the resolved tenant id. */
private String tenantHeader = "X-Tenant-Id";

/** Header set to the authenticated subject. */
private String userHeader = "X-User-Id";

/** Header set to the comma-joined authorities. */
private String rolesHeader = "X-User-Roles";

/** When {@code true}, an unresolved tenant fails the request with 403 (fail-closed). */
private boolean tenantRequired = true;

/** Claim dot-paths inspected for the tenant id (passed to the default resolver). */
private List<String> tenantClaimPaths = new ArrayList<>(List.of("tenant_ids"));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright 2024-2026 Firefly Software Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.security.webflux.bridge;

import org.fireflyframework.security.api.domain.SecurityPrincipal;
import org.fireflyframework.security.spi.SecurityContextPort;
import org.fireflyframework.security.spi.TenantResolverPort;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

/**
* Derives trusted internal request headers from the <strong>validated token</strong>, never from
* the client.
*
* <p>Runs right after Spring Security has authenticated the request (order {@value #ORDER}): it
* reads the principal, resolves the tenant via {@link TenantResolverPort} and
* <strong>overwrites</strong> the configured tenant/user/roles headers on the downstream request
* before it reaches a controller, so any client-supplied value is discarded. Unauthenticated
* (permit-all) requests pass through untouched. When a tenant is required but unresolved, the
* request fails closed with 403.</p>
*/
public class PrincipalHeaderBridgeWebFilter implements WebFilter, Ordered {

/** After Spring Security's {@code WebFilterChainProxy} (-100), before the controller. */
public static final int ORDER = -90;

private final SecurityContextPort securityContext;
private final TenantResolverPort tenantResolver;
private final PrincipalHeaderBridgeProperties properties;

public PrincipalHeaderBridgeWebFilter(SecurityContextPort securityContext,
TenantResolverPort tenantResolver,
PrincipalHeaderBridgeProperties properties) {
this.securityContext = securityContext;
this.tenantResolver = tenantResolver;
this.properties = properties;
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// Map the principal to a mutated exchange, default to the original when there is no principal
// (permit-all paths), then run the chain exactly once. Flat-mapping onto chain.filter (a
// Mono<Void> that completes empty) would re-trigger defaultIfEmpty and run the chain twice.
// The inner Mono fails closed (403) on an unresolvable required tenant.
return securityContext.currentPrincipal()
.flatMap(principal -> withTrustedHeaders(exchange, principal))
.defaultIfEmpty(exchange)
.flatMap(chain::filter);
}

private Mono<ServerWebExchange> withTrustedHeaders(ServerWebExchange exchange, SecurityPrincipal principal) {
return tenantResolver.resolveTenant(principal)
.map(tenant -> mutate(exchange, principal, tenant))
.switchIfEmpty(Mono.defer(() -> properties.isTenantRequired()
? Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN,
"tenant_resolution_failed: token does not carry exactly one tenant"))
: Mono.just(mutate(exchange, principal, null))));
}

private ServerWebExchange mutate(ServerWebExchange exchange, SecurityPrincipal principal, String tenant) {
return exchange.mutate()
.request(r -> r.headers(headers -> {
if (tenant != null) {
headers.set(properties.getTenantHeader(), tenant);
} else {
headers.remove(properties.getTenantHeader());
}
headers.set(properties.getUserHeader(), principal.subject());
headers.set(properties.getRolesHeader(), String.join(",", principal.authorities()));
}))
.build();
}

@Override
public int getOrder() {
return ORDER;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2024-2026 Firefly Software Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.security.webflux.bridge;

import org.fireflyframework.security.core.tenant.ConfigurableTenantResolver;
import org.fireflyframework.security.spi.SecurityContextPort;
import org.fireflyframework.security.spi.TenantResolverPort;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

/**
* Opt-in auto-configuration of the principal&rarr;header bridge. Disabled by default
* ({@code firefly.security.webflux.header-bridge.enabled=false}) so no existing consumer is
* affected; when enabled it contributes a default {@link TenantResolverPort} (unless the product
* supplies its own) and the {@link PrincipalHeaderBridgeWebFilter}. Both beans are
* {@link ConditionalOnMissingBean} so applications can override either piece.
*/
@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnProperty(prefix = "firefly.security.webflux.header-bridge", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(PrincipalHeaderBridgeProperties.class)
public class WebFluxBridgeAutoConfiguration {

@Bean
@ConditionalOnMissingBean
public TenantResolverPort fireflyTenantResolverPort(PrincipalHeaderBridgeProperties properties) {
return new ConfigurableTenantResolver(properties.getTenantClaimPaths());
}

@Bean
@ConditionalOnMissingBean
public PrincipalHeaderBridgeWebFilter fireflyPrincipalHeaderBridgeWebFilter(
SecurityContextPort securityContext,
TenantResolverPort tenantResolver,
PrincipalHeaderBridgeProperties properties) {
return new PrincipalHeaderBridgeWebFilter(securityContext, tenantResolver, properties);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.fireflyframework.security.webflux.bridge.WebFluxBridgeAutoConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright 2024-2026 Firefly Software Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.security.webflux.bridge;

import org.fireflyframework.security.api.domain.SecurityPrincipal;
import org.fireflyframework.security.core.tenant.ConfigurableTenantResolver;
import org.fireflyframework.security.spi.SecurityContextPort;
import org.fireflyframework.security.spi.TenantResolverPort;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import static org.assertj.core.api.Assertions.assertThat;

class PrincipalHeaderBridgeWebFilterTest {

private final PrincipalHeaderBridgeProperties properties = new PrincipalHeaderBridgeProperties();
private final TenantResolverPort tenantResolver = new ConfigurableTenantResolver(List.of("tenant_ids"));

/** Records how many times it ran and the exchange it saw — to assert single execution + mutation. */
private static final class CapturingChain implements WebFilterChain {
final AtomicInteger calls = new AtomicInteger();
final AtomicReference<ServerWebExchange> captured = new AtomicReference<>();

@Override
public Mono<Void> filter(ServerWebExchange exchange) {
calls.incrementAndGet();
captured.set(exchange);
return Mono.empty();
}
}

private static SecurityContextPort principalPort(SecurityPrincipal principal) {
return () -> principal == null ? Mono.empty() : Mono.just(principal);
}

private PrincipalHeaderBridgeWebFilter filter(SecurityContextPort port) {
return new PrincipalHeaderBridgeWebFilter(port, tenantResolver, properties);
}

@Test
void overwritesTrustedHeadersFromToken() {
SecurityPrincipal principal = SecurityPrincipal.builder()
.subject("user-1")
.authorities(Set.of("idp-admin"))
.claims(Map.of("tenant_ids", List.of("tenant-a")))
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/api/x").header("X-Tenant-Id", "evil")); // client spoof
CapturingChain chain = new CapturingChain();

StepVerifier.create(filter(principalPort(principal)).filter(exchange, chain)).verifyComplete();

assertThat(chain.calls).hasValue(1);
HttpHeaders headers = chain.captured.get().getRequest().getHeaders();
assertThat(headers.getFirst("X-Tenant-Id")).isEqualTo("tenant-a"); // spoof overwritten
assertThat(headers.getFirst("X-User-Id")).isEqualTo("user-1");
assertThat(headers.getFirst("X-User-Roles")).isEqualTo("idp-admin");
}

@Test
void failsClosedWhenTenantUnresolved() {
SecurityPrincipal principal = SecurityPrincipal.builder()
.subject("user-1")
.claims(Map.of("tenant_ids", List.of("a", "b"))) // >1 -> empty -> 403
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/x"));
CapturingChain chain = new CapturingChain();

StepVerifier.create(filter(principalPort(principal)).filter(exchange, chain))
.expectErrorSatisfies(e -> assertThat(((ResponseStatusException) e).getStatusCode().value()).isEqualTo(403))
.verify();

assertThat(chain.calls).hasValue(0); // fail-closed: chain never runs
}

@Test
void passesThroughUntouchedWhenUnauthenticated() {
MockServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get("/actuator/health").header("X-Tenant-Id", "client"));
CapturingChain chain = new CapturingChain();

StepVerifier.create(filter(principalPort(null)).filter(exchange, chain)).verifyComplete();

assertThat(chain.calls).hasValue(1);
assertThat(chain.captured.get().getRequest().getHeaders().getFirst("X-Tenant-Id")).isEqualTo("client");
}
}
Loading