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