From f3e90b8c0ebcf793eb9d3eb6dcbaa9e0b0027f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Thu, 25 Jun 2026 11:33:43 +0200 Subject: [PATCH 1/2] feat(security-core): product-agnostic ConfigurableTenantResolver Default TenantResolverPort: reads the tenant id from validated token claims at configurable dot-paths (default tenant_ids), single-tenant fail-closed (emits the id only when the token carries exactly one, empty for 0 or >1 so the caller fails closed). Dev version 26.06.03-SNAPSHOT; api/spi pinned to the 26.06.02 release. --- pom.xml | 9 ++- .../tenant/ConfigurableTenantResolver.java | 55 +++++++++++++++ .../ConfigurableTenantResolverTest.java | 67 +++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolver.java create mode 100644 src/test/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolverTest.java diff --git a/pom.xml b/pom.xml index b25e48f..7869227 100644 --- a/pom.xml +++ b/pom.xml @@ -12,22 +12,25 @@ fireflyframework-security-core - 26.06.02 + + 26.06.03-SNAPSHOT jar Firefly Framework - Security Core Framework-neutral security engine: authority mapping, the @Secure authorization evaluator, the embedded policy decision point, introspection caching and principal projection + org.fireflyframework fireflyframework-security-api - ${project.version} + 26.06.02 org.fireflyframework fireflyframework-security-spi - ${project.version} + 26.06.02 diff --git a/src/main/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolver.java b/src/main/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolver.java new file mode 100644 index 0000000..994aecc --- /dev/null +++ b/src/main/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolver.java @@ -0,0 +1,55 @@ +/* + * 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.core.tenant; + +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.security.core.authority.ClaimPaths; +import org.fireflyframework.security.spi.TenantResolverPort; +import reactor.core.publisher.Mono; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Default {@link TenantResolverPort}: reads the tenant id from the validated token claims at the + * configured dot-paths (default {@code tenant_ids}, coalesced across paths). + * + *

Single-tenant and fail-closed: it emits the id only when the token carries + * exactly one tenant, and {@link Mono#empty()} for 0 or >1 so the caller decides how to fail + * (the header bridge turns empty into a 403 when a tenant is required). Picking one silently is + * never an option — that would risk acting on the wrong tenant. The future multi-tenant change + * (active tenant chosen from the request and validated against the set) is a change to the caller, + * not to this single-valued port.

+ */ +public class ConfigurableTenantResolver implements TenantResolverPort { + + private final List claimPaths; + + public ConfigurableTenantResolver(List claimPaths) { + this.claimPaths = List.copyOf(claimPaths); + } + + @Override + public Mono resolveTenant(SecurityPrincipal principal) { + Set tenants = new LinkedHashSet<>(); + for (String path : claimPaths) { + tenants.addAll(ClaimPaths.readStringSet(principal.claims(), path)); + } + return tenants.size() == 1 ? Mono.just(tenants.iterator().next()) : Mono.empty(); + } +} diff --git a/src/test/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolverTest.java b/src/test/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolverTest.java new file mode 100644 index 0000000..35bfeb5 --- /dev/null +++ b/src/test/java/org/fireflyframework/security/core/tenant/ConfigurableTenantResolverTest.java @@ -0,0 +1,67 @@ +/* + * 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.core.tenant; + +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.List; +import java.util.Map; + +class ConfigurableTenantResolverTest { + + private final ConfigurableTenantResolver resolver = new ConfigurableTenantResolver(List.of("tenant_ids")); + + private static SecurityPrincipal principalWith(Object tenantClaim) { + return SecurityPrincipal.builder() + .subject("user-1") + .claims(tenantClaim == null ? Map.of() : Map.of("tenant_ids", tenantClaim)) + .build(); + } + + @Test + void emitsTheSingleTenant() { + StepVerifier.create(resolver.resolveTenant(principalWith(List.of("tenant-a")))) + .expectNext("tenant-a") + .verifyComplete(); + } + + @Test + void emptyWhenNoTenant() { + StepVerifier.create(resolver.resolveTenant(principalWith(null))) + .verifyComplete(); + } + + @Test + void emptyWhenMoreThanOneTenantFailClosed() { + StepVerifier.create(resolver.resolveTenant(principalWith(List.of("tenant-a", "tenant-b")))) + .verifyComplete(); + } + + @Test + void readsNestedClaimPath() { + ConfigurableTenantResolver nested = new ConfigurableTenantResolver(List.of("org.tenant")); + SecurityPrincipal principal = SecurityPrincipal.builder() + .subject("user-1") + .claims(Map.of("org", Map.of("tenant", "tenant-x"))) + .build(); + StepVerifier.create(nested.resolveTenant(principal)) + .expectNext("tenant-x") + .verifyComplete(); + } +} From a3f3ce427ab9162ac71b0ac820f6184bb4261531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Fri, 26 Jun 2026 09:27:42 +0200 Subject: [PATCH 2/2] chore: normalize version to the 26.06.02 baseline ahead of the 26.06.03 framework release Drop the hand-rolled pre-release SNAPSHOT version pin and the dependencyManagement neutralization; the module returns to a clean 26.06.02 pom (the code changes stay). flywork fwversion bump will take the whole framework to 26.06.03 uniformly. --- pom.xml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index 7869227..b25e48f 100644 --- a/pom.xml +++ b/pom.xml @@ -12,25 +12,22 @@ fireflyframework-security-core - - 26.06.03-SNAPSHOT + 26.06.02 jar Firefly Framework - Security Core Framework-neutral security engine: authority mapping, the @Secure authorization evaluator, the embedded policy decision point, introspection caching and principal projection - org.fireflyframework fireflyframework-security-api - 26.06.02 + ${project.version} org.fireflyframework fireflyframework-security-spi - 26.06.02 + ${project.version}