Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* @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<String, Set<String>> 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<String, Set<String>> immutableMappings = new LinkedHashMap<>();
for (Map.Entry<String, Set<String>> entry : rolePermissions.entrySet()) {
String role = requireCanonicalText(entry.getKey(), "tenant role name is required");
Set<String> configuredPermissions = entry.getValue();
if (configuredPermissions == null || configuredPermissions.isEmpty()) {
throw new IllegalArgumentException("tenant role permissions are required");
}

Set<String> 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.
*
* <p>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.</p>
*
* @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<TenantContext> resolve(String subjectId, Set<String> externalRoles) {
if (!isCanonicalText(subjectId) || externalRoles == null || externalRoles.isEmpty()) {
return Optional.empty();
}

Set<String> mappedPermissions = new LinkedHashSet<>();
for (String externalRole : externalRoles) {
Set<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Set<String>> rolePermissions = new LinkedHashMap<>();
Set<String> 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<TenantContext> 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<String, Set<String>> 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<String, Set<String>> nullPermissions = new LinkedHashMap<>();
nullPermissions.put("reader", null);
assertThrows(IllegalArgumentException.class, () -> new TenantRoleMappingPolicy("tenant-a", nullPermissions));

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