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(); + } +}