diff --git a/src/main/java/app/quickcase/sdk/spring/acl/Acl.java b/src/main/java/app/quickcase/sdk/spring/acl/Acl.java new file mode 100644 index 0000000..3e0406a --- /dev/null +++ b/src/main/java/app/quickcase/sdk/spring/acl/Acl.java @@ -0,0 +1,59 @@ +package app.quickcase.sdk.spring.acl; + +import java.util.Arrays; +import java.util.Map; +import java.util.Set; + +import lombok.NonNull; + +/** + * Defines binary role-based ACL model and implement permission evaluation. + */ +public final class Acl { + public static int CREATE = 0b1000; + public static int READ = 0b0100; + public static int UPDATE = 0b0010; + public static int DELETE = 0b0001; + public static int CRUD = CREATE | READ | UPDATE | DELETE; + + public static Map LETTERS = Map.of( + 'C', CREATE, + 'R', READ, + 'U', UPDATE, + 'D', DELETE + ); + + public static int fromFlags(boolean create, boolean read, boolean update, boolean delete) { + return (create ? CREATE : 0) | (read ? READ : 0) | (update ? UPDATE : 0) | (delete ? DELETE : 0); + } + + public static int fromString(String permission) { + return permission.chars() + .reduce(0, (acc, letter) -> LETTERS.getOrDefault((char) letter, 0) | acc); + } + + private final Map acl; + + public Acl(Map acl) { + this.acl = acl; + } + + public boolean check(@NonNull Set roles, int verb) { + return roles.stream() + .anyMatch(role -> (acl.getOrDefault(role, 0) & verb) > 0); + } + + public boolean checkAny(@NonNull Set roles, int... verbs) { + return check(roles, combine(verbs)); + } + + public boolean checkAll(@NonNull Set roles, int... verbs) { + var combinedVerbs = combine(verbs); + var cumulatedPermissions = roles.stream().reduce(0, (acc, role) -> (acl.getOrDefault(role, 0) | acc), (a, b) -> a | b); + return (combinedVerbs & cumulatedPermissions) == combinedVerbs; + } + + private static int combine(int[] verbs) { + return Arrays.stream(verbs).reduce(0, (acc, verb) -> acc | verb); + } +} diff --git a/src/main/java/app/quickcase/sdk/spring/definition/model/DataField.java b/src/main/java/app/quickcase/sdk/spring/definition/model/DataField.java index 1c10fbd..cd71d83 100644 --- a/src/main/java/app/quickcase/sdk/spring/definition/model/DataField.java +++ b/src/main/java/app/quickcase/sdk/spring/definition/model/DataField.java @@ -20,7 +20,7 @@ public record DataField( @Singular Map members, Validation validation, Field.Display display, - @NonNull @Singular("acl") Map acl, + @NonNull @Singular("acl") Map acl, @NonNull String classification ) implements Field { @Builder diff --git a/src/test/java/app/quickcase/sdk/spring/acl/AclTest.java b/src/test/java/app/quickcase/sdk/spring/acl/AclTest.java new file mode 100644 index 0000000..1a6b144 --- /dev/null +++ b/src/test/java/app/quickcase/sdk/spring/acl/AclTest.java @@ -0,0 +1,165 @@ +package app.quickcase.sdk.spring.acl; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static app.quickcase.sdk.spring.acl.Acl.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class AclTest { + + @Nested + class FromFlags { + @ParameterizedTest + @MethodSource + @DisplayName("should convert permission flags to binary") + void shouldConvertPermissionFlagsToBinary(boolean create, boolean read, boolean update, boolean delete, int expected) { + assertThat(Acl.fromFlags(create, read, update, delete), is(expected)); + } + + static Stream shouldConvertPermissionFlagsToBinary() { + return Stream.of( + Arguments.of(true, false, false, false, CREATE), + Arguments.of(false, true, false, false, READ), + Arguments.of(false, false, true, false, UPDATE), + Arguments.of(false, false, false, true, DELETE), + Arguments.of(true, true, false, false, CREATE | READ), + Arguments.of(false, true, true, false, READ | UPDATE), + Arguments.of(true, true, true, true, CRUD) + ); + } + } + + @Nested + class FromString { + @ParameterizedTest + @MethodSource + @DisplayName("should convert permission string to binary") + void shouldConvertPermissionStringToBinary(String permission, int expected) { + assertThat(Acl.fromString(permission), is(expected)); + } + + static Stream shouldConvertPermissionStringToBinary() { + return Stream.of( + Arguments.of("C", CREATE), + Arguments.of("R", READ), + Arguments.of("U", UPDATE), + Arguments.of("D", DELETE), + Arguments.of("CR", CREATE | READ), + Arguments.of("RU", READ | UPDATE), + Arguments.of("CRUD", CRUD), + Arguments.of("DUCR", CRUD), + Arguments.of("", 0) + ); + } + } + + @Nested + class Check { + @Test + @DisplayName("should be true when any role is granted requested verb") + void shouldBeTrueWhenAnyRoleGranted() { + var acl = new Acl(Map.of( + "role-1", CREATE, + "role-2", CREATE | READ, + "role-3", DELETE + )); + + assertThat( + acl.check(Set.of("role-2", "role-3", "role-4"), READ), + is(true) + ); + } + + @Test + @DisplayName("should be false when no role is granted requested verb") + void shouldBefalseWhenNoRoleGranted() { + var acl = new Acl(Map.of( + "role-1", CREATE | UPDATE, + "role-2", CREATE | READ, + "role-3", DELETE + )); + + assertThat( + acl.check(Set.of("role-2", "role-3", "role-4"), UPDATE), + is(false) + ); + } + } + + @Nested + class CheckAny { + @Test + @DisplayName("should be true when any role is granted any of requested verbs") + void shouldBeTrueWhenAnyRoleGranted() { + var acl = new Acl(Map.of( + "role-1", CREATE, + "role-2", CREATE | READ, + "role-3", DELETE + )); + + assertThat( + acl.checkAny(Set.of("role-2", "role-3", "role-4"), READ, UPDATE), + is(true) + ); + } + + @Test + @DisplayName("should be false when no role is any of requested verbs") + void shouldBefalseWhenNoRoleGranted() { + var acl = new Acl(Map.of( + "role-1", CREATE | UPDATE, + "role-2", CREATE | READ, + "role-3", DELETE + )); + + assertThat( + acl.checkAny(Set.of("role-2", "role-4"), UPDATE, DELETE), + is(false) + ); + } + } + + @Nested + class CheckAll { + @Test + @DisplayName("should be true when combined roles are granted all of requested verbs") + void shouldBeTrueWhenAnyRoleGranted() { + var acl = new Acl(Map.of( + "role-1", CREATE, + "role-2", CREATE | READ, + "role-3", DELETE + )); + + assertThat( + acl.checkAll(Set.of("role-2", "role-3", "role-4"), CREATE, READ, DELETE), + is(true) + ); + } + + @Test + @DisplayName("should be false when no role is any of requested verbs") + void shouldBefalseWhenNoRoleGranted() { + var acl = new Acl(Map.of( + "role-1", CREATE | UPDATE, + "role-2", CREATE | READ, + "role-3", DELETE + )); + + assertThat( + acl.checkAll(Set.of("role-1", "role-2"), CREATE, READ, DELETE), + is(false) + ); + } + } + +} \ No newline at end of file