diff --git a/pom.xml b/pom.xml index ad718b8..84a5493 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,14 @@ ${project.version} + + + org.springframework.boot + spring-boot-autoconfigure + + org.springframework.security spring-security-core diff --git a/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeProperties.java b/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeProperties.java new file mode 100644 index 0000000..5b8df22 --- /dev/null +++ b/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeProperties.java @@ -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→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 tenantClaimPaths = new ArrayList<>(List.of("tenant_ids")); +} diff --git a/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilter.java b/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilter.java new file mode 100644 index 0000000..7c67af0 --- /dev/null +++ b/src/main/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilter.java @@ -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 validated token, never from + * the client. + * + *

Runs right after Spring Security has authenticated the request (order {@value #ORDER}): it + * reads the principal, resolves the tenant via {@link TenantResolverPort} and + * overwrites 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.

+ */ +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 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 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 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; + } +} diff --git a/src/main/java/org/fireflyframework/security/webflux/bridge/WebFluxBridgeAutoConfiguration.java b/src/main/java/org/fireflyframework/security/webflux/bridge/WebFluxBridgeAutoConfiguration.java new file mode 100644 index 0000000..1f817ee --- /dev/null +++ b/src/main/java/org/fireflyframework/security/webflux/bridge/WebFluxBridgeAutoConfiguration.java @@ -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→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); + } +} diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..e5a9229 --- /dev/null +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.fireflyframework.security.webflux.bridge.WebFluxBridgeAutoConfiguration diff --git a/src/test/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilterTest.java b/src/test/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilterTest.java new file mode 100644 index 0000000..0aa14e1 --- /dev/null +++ b/src/test/java/org/fireflyframework/security/webflux/bridge/PrincipalHeaderBridgeWebFilterTest.java @@ -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 captured = new AtomicReference<>(); + + @Override + public Mono 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"); + } +}