From 929f5b06e8dee2a1c1b966c86e22b6ceb91493c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:25:41 +0900 Subject: [PATCH 1/2] test(auth): require server-owned tenant role mapping --- .../auth/TenantRoleMappingPolicyTest.java | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/auth/TenantRoleMappingPolicyTest.java diff --git a/src/test/java/com/clearfolio/viewer/auth/TenantRoleMappingPolicyTest.java b/src/test/java/com/clearfolio/viewer/auth/TenantRoleMappingPolicyTest.java new file mode 100644 index 00000000..3f84e42c --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/auth/TenantRoleMappingPolicyTest.java @@ -0,0 +1,124 @@ +package com.clearfolio.viewer.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class TenantRoleMappingPolicyTest { + + @Test + void resolvesOnlyServerMappedPermissionsIntoFixedTenantAuthority() { + Map> rolePermissions = new LinkedHashMap<>(); + Set readerPermissions = new LinkedHashSet<>(Set.of( + TenantPermissions.JOB_READ, + TenantPermissions.VIEWER_READ + )); + rolePermissions.put("document-reader", readerPermissions); + rolePermissions.put("document-uploader", Set.of(TenantPermissions.JOB_CREATE)); + + TenantRoleMappingPolicy policy = new TenantRoleMappingPolicy("tenant-a", rolePermissions); + readerPermissions.add(TenantPermissions.JOB_DELETE); + rolePermissions.put("unexpected-admin", Set.of(TenantPermissions.JOB_DELETE)); + + Optional resolved = policy.resolve( + "employee-007", + Set.of("document-reader", "document-uploader", "token-supplied-permission") + ); + + assertTrue(resolved.isPresent()); + assertEquals("tenant-a", resolved.get().tenantId()); + assertEquals("employee-007", resolved.get().subjectId()); + assertEquals( + Set.of(TenantPermissions.JOB_READ, TenantPermissions.VIEWER_READ, TenantPermissions.JOB_CREATE), + resolved.get().permissions() + ); + assertFalse(resolved.get().permissions().contains(TenantPermissions.JOB_DELETE)); + assertFalse(policy.rolePermissions().containsKey("unexpected-admin")); + assertThrows( + UnsupportedOperationException.class, + () -> policy.rolePermissions().put("admin", Set.of(TenantPermissions.JOB_DELETE)) + ); + } + + @Test + void failsClosedWhenSubjectOrRolesDoNotMap() { + TenantRoleMappingPolicy policy = new TenantRoleMappingPolicy( + "tenant-a", + Map.of("reader", Set.of(TenantPermissions.JOB_READ)) + ); + + assertTrue(policy.resolve("employee-007", Set.of("reader")).isPresent()); + assertTrue(policy.resolve("employee-007", Set.of("reader", "unknown")).isPresent()); + assertTrue(policy.resolve("employee-007", Set.of("unknown")).isEmpty()); + assertTrue(policy.resolve("employee-007", Set.of()).isEmpty()); + assertTrue(policy.resolve("employee-007", null).isEmpty()); + assertTrue(policy.resolve(null, Set.of("reader")).isEmpty()); + assertTrue(policy.resolve(" ", Set.of("reader")).isEmpty()); + assertTrue(policy.resolve(" employee-007 ", Set.of("reader")).isEmpty()); + assertTrue(policy.resolve("employee\u0000-007", Set.of("reader")).isEmpty()); + } + + @Test + void rejectsAmbiguousServerOwnedMappingAuthority() { + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy(null, Map.of( + "reader", Set.of(TenantPermissions.JOB_READ) + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy(" ", Map.of( + "reader", Set.of(TenantPermissions.JOB_READ) + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy(" tenant-a ", Map.of( + "reader", Set.of(TenantPermissions.JOB_READ) + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant\u0000-a", Map.of( + "reader", Set.of(TenantPermissions.JOB_READ) + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", null)); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of())); + + Map> nullRole = new LinkedHashMap<>(); + nullRole.put(null, Set.of(TenantPermissions.JOB_READ)); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", nullRole)); + + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of( + " ", Set.of(TenantPermissions.JOB_READ) + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of( + " reader ", Set.of(TenantPermissions.JOB_READ) + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of( + "reader\u0000", Set.of(TenantPermissions.JOB_READ) + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of( + "reader", Set.of() + ))); + + Map> nullPermissions = new LinkedHashMap<>(); + nullPermissions.put("reader", null); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", nullPermissions)); + + Set nullPermission = new LinkedHashSet<>(); + nullPermission.add(TenantPermissions.JOB_READ); + nullPermission.add(null); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of( + "reader", nullPermission + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of( + "reader", Set.of(" ") + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of( + "reader", Set.of(" job:read ") + ))); + assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", Map.of( + "reader", Set.of("job:\u0000read") + ))); + } +} From f85e71c3a3ba548b123d5d135cb74d344f3556a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:27:31 +0900 Subject: [PATCH 2/2] feat(auth): add server-owned tenant role mapping policy --- .../viewer/auth/TenantRoleMappingPolicy.java | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/main/java/com/clearfolio/viewer/auth/TenantRoleMappingPolicy.java diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantRoleMappingPolicy.java b/src/main/java/com/clearfolio/viewer/auth/TenantRoleMappingPolicy.java new file mode 100644 index 00000000..2313236b --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/auth/TenantRoleMappingPolicy.java @@ -0,0 +1,98 @@ +package com.clearfolio.viewer.auth; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Immutable server-owned mapping from external identity roles to one Clearfolio + * tenant's internal permissions. + * + *

External credentials may present role names, but those role strings never + * become Clearfolio permissions directly. Only permissions configured in this + * policy are emitted into a {@link TenantContext}. Binding the policy to one + * tenant also prevents an untrusted token claim from selecting arbitrary tenant + * authority.

+ * + * @param tenantId fixed Clearfolio tenant controlled by server configuration + * @param rolePermissions immutable role-to-permission mapping controlled by the server + */ +public record TenantRoleMappingPolicy( + String tenantId, + Map> rolePermissions) { + + /** + * Validates and defensively snapshots server-owned mapping authority. + */ + public TenantRoleMappingPolicy { + tenantId = requireCanonicalText(tenantId, "tenant role mapping tenant is required"); + if (rolePermissions == null || rolePermissions.isEmpty()) { + throw new IllegalArgumentException("tenant role mapping entries are required"); + } + + Map> immutableMappings = new LinkedHashMap<>(); + for (Map.Entry> entry : rolePermissions.entrySet()) { + String role = requireCanonicalText(entry.getKey(), "tenant role name is required"); + Set configuredPermissions = entry.getValue(); + if (configuredPermissions == null || configuredPermissions.isEmpty()) { + throw new IllegalArgumentException("tenant role permissions are required"); + } + + Set immutablePermissions = new LinkedHashSet<>(); + for (String permission : configuredPermissions) { + immutablePermissions.add(requireCanonicalText( + permission, + "tenant role permission is required" + )); + } + immutableMappings.put(role, Set.copyOf(immutablePermissions)); + } + rolePermissions = Map.copyOf(immutableMappings); + } + + /** + * Resolves verified external role claims into a Clearfolio tenant context. + * + *

Unknown roles are ignored. Missing or malformed subject authority and a + * role set that grants no configured Clearfolio permission fail closed with + * an empty result.

+ * + * @param subjectId stable verified external subject identifier + * @param externalRoles verified external role claims + * @return mapped tenant context when at least one configured permission applies + */ + public Optional resolve(String subjectId, Set externalRoles) { + if (!isCanonicalText(subjectId) || externalRoles == null || externalRoles.isEmpty()) { + return Optional.empty(); + } + + Set mappedPermissions = new LinkedHashSet<>(); + for (String externalRole : externalRoles) { + Set configuredPermissions = rolePermissions.get(externalRole); + if (configuredPermissions != null) { + mappedPermissions.addAll(configuredPermissions); + } + } + if (mappedPermissions.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(new TenantContext(tenantId, subjectId, mappedPermissions)); + } + + private static String requireCanonicalText(String value, String message) { + if (!isCanonicalText(value)) { + throw new IllegalArgumentException(message); + } + return value; + } + + private static boolean isCanonicalText(String value) { + return value != null + && !value.isBlank() + && value.equals(value.strip()) + && value.indexOf('\u0000') < 0; + } +}