From 0a9fd9f83bf358bbd28e9d1618a3aa918d478a9f Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 6 Mar 2024 15:45:39 +0100 Subject: [PATCH] add RbacGrantsDiagramService --- .../rbac/rbacgrant/RawRbacGrantEntity.java | 9 +- .../rbacgrant/RawRbacGrantRepository.java | 4 + .../rbacgrant/RbacGrantsDiagramService.java | 204 ++++++++++++++++++ ...acGrantsDiagramServiceIntegrationTest.java | 136 ++++++++++++ 4 files changed, 351 insertions(+), 2 deletions(-) rename src/{test => main}/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java (89%) rename src/{test => main}/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java (67%) create mode 100644 src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java create mode 100644 src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramServiceIntegrationTest.java diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java similarity index 89% rename from src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java rename to src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java index 6dc8d1ce..f7b3cdf4 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java @@ -1,13 +1,13 @@ package net.hostsharing.hsadminng.rbac.rbacgrant; import lombok.*; -import org.jetbrains.annotations.NotNull; import org.springframework.data.annotation.Immutable; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.UUID; @@ -20,7 +20,7 @@ import java.util.UUID; @Immutable @NoArgsConstructor @AllArgsConstructor -public class RawRbacGrantEntity { +public class RawRbacGrantEntity implements Comparable { @Id private UUID uuid; @@ -64,4 +64,9 @@ public class RawRbacGrantEntity { // TODO: remove .distinct() once partner.person + partner.contact are removed return roles.stream().map(RawRbacGrantEntity::toDisplay).sorted().distinct().toList(); } + + @Override + public int compareTo(final Object o) { + return uuid.compareTo(((RawRbacGrantEntity)o).uuid); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java similarity index 67% rename from src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java rename to src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java index c7ac60ab..37828bdf 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantRepository.java @@ -8,4 +8,8 @@ import java.util.UUID; public interface RawRbacGrantRepository extends Repository { List findAll(); + + List findByAscendingUuid(UUID ascendingUuid); + + List findByDescendantUuid(UUID refUuid); } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java new file mode 100644 index 00000000..68189137 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java @@ -0,0 +1,204 @@ +package net.hostsharing.hsadminng.rbac.rbacgrant; + +import net.hostsharing.hsadminng.context.Context; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.validation.constraints.NotNull; +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.util.*; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.joining; +import static net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService.Include.*; + +// TODO: cleanup - this code was 'hacked' to quickly fix a specific problem, needs refactoring +@Service +public class RbacGrantsDiagramService { + + public static void writeToFile(final String title, final String graph, final String fileName) { + + try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) { + writer.write(""" + ### all grants to %s + + ```mermaid + %s + ``` + """.formatted(title, graph)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public enum Include { + DETAILS, + USERS, + PERMISSIONS, + NOT_ASSUMED, + TEST_ENTITIES, + NON_TEST_ENTITIES + } + + @Autowired + private Context context; + + @Autowired + private RawRbacGrantRepository rawGrantRepo; + + @PersistenceContext + private EntityManager em; + + public String allGrantsToCurrentUser(final EnumSet includes) { + final var graph = new HashSet(); + for ( UUID subjectUuid: context.currentSubjectsUuids() ) { + traverseGrantsTo(graph, subjectUuid, includes); + } + return toMermaidFlowchart(graph, includes); + } + + private void traverseGrantsTo(final Set graph, final UUID refUuid, final EnumSet includes) { + final var grants = rawGrantRepo.findByAscendingUuid(refUuid); + grants.forEach(g -> { + if (!includes.contains(PERMISSIONS) && g.getDescendantIdName().startsWith("perm ")) { + return; + } + if (!includes.contains(TEST_ENTITIES) && g.getDescendantIdName().contains(" test_")) { + return; + } + if (!includes.contains(NON_TEST_ENTITIES) && !g.getDescendantIdName().contains(" test_")) { + return; + } + graph.add(g); + if (includes.contains(NOT_ASSUMED) || g.isAssumed()) { + traverseGrantsTo(graph, g.getDescendantUuid(), includes); + } + }); + } + + public String allGrantsFrom(final UUID targetObject, final String op, final EnumSet includes) { + final var refUuid = (UUID) em.createNativeQuery("SELECT uuid FROM rbacpermission WHERE objectuuid=:targetObject AND op=:op") + .setParameter("targetObject", targetObject) + .setParameter("op", op) + .getSingleResult(); + final var graph = new HashSet(); + traverseGrantsFrom(graph, refUuid, includes); + return toMermaidFlowchart(graph, includes); + } + + private void traverseGrantsFrom(final Set graph, final UUID refUuid, final EnumSet option) { + final var grants = rawGrantRepo.findByDescendantUuid(refUuid); + grants.forEach(g -> { + if (!option.contains(USERS) && g.getAscendantIdName().startsWith("user ")) { + return; + } + graph.add(g); + if (option.contains(NOT_ASSUMED) || g.isAssumed()) { + traverseGrantsFrom(graph, g.getAscendingUuid(), option); + } + }); + } + + private String toMermaidFlowchart(final HashSet graph, final EnumSet includes) { + final var entities = + includes.contains(DETAILS) + ? graph.stream() + .flatMap(g -> Stream.of( + new Node(g.getAscendantIdName(), g.getAscendingUuid()), + new Node(g.getDescendantIdName(), g.getDescendantUuid())) + ) + .collect(groupingBy(RbacGrantsDiagramService::renderEntityIdName)) + .entrySet().stream() + .map(entity -> "subgraph " + quoted(entity.getKey()) + renderSubgraph(entity.getKey()) + "\n\n " + + entity.getValue().stream() + .map(n -> renderNode(n.idName(), n.uuid()).replace("\n", "\n ")) + .sorted() + .distinct() + .collect(joining("\n\n "))) + .collect(joining("\n\nend\n\n")) + + "\n\nend\n\n" + : ""; + + final var grants = graph.stream() + .map(g -> quoted(g.getAscendantIdName()) + + (g.isAssumed() ? " --> " : " -.-> ") + + quoted(g.getDescendantIdName())) + .sorted() + .collect(joining("\n")); + + final var avoidCroppedNodeLabels = "%%{init:{'flowchart':{'htmlLabels':false}}}%%\n\n"; + return (includes.contains(DETAILS) ? avoidCroppedNodeLabels : "") + + "flowchart TB\n\n" + + entities + + grants; + } + + private String renderSubgraph(final String entityId) { + // this does not work according to Mermaid bug https://github.com/mermaid-js/mermaid/issues/3806 + // if (entityId.contains("#")) { + // final var parts = entityId.split("#"); + // final var table = parts[0]; + // final var entity = parts[1]; + // if (table.equals("entity")) { + // return "[" + entity "]"; + // } + // return "[" + table + "\n" + entity + "]"; + // } + return "[" + entityId + "]"; + } + + private static String renderEntityIdName(final Node node) { + final var refType = refType(node.idName()); + if (refType.equals("user")) { + return "users"; + } + if (refType.equals("perm")) { + return node.idName().split(" ", 4)[3]; + } + if (refType.equals("role")) { + final var withoutRolePrefix = node.idName().substring("role:".length()); + return withoutRolePrefix.substring(0, withoutRolePrefix.lastIndexOf('.')); + } + throw new IllegalArgumentException("unknown refType '" + refType + "' in '" + node.idName() + "'"); + } + + private String renderNode(final String idName, final UUID uuid) { + return quoted(idName) + renderNodeContent(idName, uuid); + } + + private String renderNodeContent(final String idName, final UUID uuid) { + final var refType = refType(idName); + + if (refType.equals("user")) { + final var displayName = idName.substring(refType.length()+1); + return "(" + displayName + "\nref:" + uuid + ")"; + } + if (refType.equals("role")) { + final var roleType = idName.substring(idName.lastIndexOf('.') + 1); + return "[" + roleType + "\nref:" + uuid + "]"; + } + if (refType.equals("perm")) { + final var roleType = idName.split(" ")[1]; + return "{{" + roleType + "\nref:" + uuid + "}}"; + } + return ""; + } + + private static String refType(final String idName) { + return idName.split(" ", 2)[0]; + } + + @NotNull + private static String quoted(final String idName) { + return idName.replace(" ", ":").replaceAll("@.*", ""); + } +} + +record Node(String idName, UUID uuid) { + +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramServiceIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramServiceIntegrationTest.java new file mode 100644 index 00000000..442f4979 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramServiceIntegrationTest.java @@ -0,0 +1,136 @@ +package net.hostsharing.hsadminng.rbac.rbacgrant; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService.Include; +import net.hostsharing.test.JpaAttempt; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.EnumSet; +import java.util.UUID; + +import static java.lang.String.join; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import( { Context.class, JpaAttempt.class, RbacGrantsDiagramService.class}) +class RbacGrantsDiagramServiceIntegrationTest extends ContextBasedTestWithCleanup { + + @Autowired + RbacGrantsDiagramService grantsMermaidService; + + @MockBean + HttpServletRequest request; + + @Test + void allGrantsToCurrentUser() { + context("superuser-alex@hostsharing.net", "test_domain#xxx00-aaaa.owner"); + final var graph = grantsMermaidService.allGrantsToCurrentUser(EnumSet.of(Include.TEST_ENTITIES)); + + assertThat(graph).isEqualTo(""" + flowchart TB + + role:test_package#xxx00.tenant[ + test_package + xxx00.t + tenant] --> role:test_customer#xxx.tenant[ + test_customer + xxx.t + tenant] + role:test_domain#xxx00-aaaa.owner[ + test_domain + xxx00-aaaa.o + owner] --> role:test_domain#xxx00-aaaa.admin[ + test_domain + xxx00-aaaa.a + admin] + role:test_domain#xxx00-aaaa.admin[ + test_domain + xxx00-aaaa.a + admin] --> role:test_package#xxx00.tenant[ + test_package + xxx00.t + tenant] + """.trim()); + } + + @Test + void allGrantsToCurrentUserIncludingPermissions() { + context("superuser-alex@hostsharing.net", "test_domain#xxx00-aaaa.owner"); + final var graph = grantsMermaidService.allGrantsToCurrentUser(EnumSet.of(Include.TEST_ENTITIES, Include.PERMISSIONS)); + + assertThat(graph).isEqualTo(""" + flowchart TB + + role:test_domain#xxx00-aaaa.owner[ + test_domain + xxx00-aaaa.o + owner] --> perm:*:on:test_domain#xxx00-aaaa{{ + test_domain + xxx00-aaaa + *}} + role:test_customer#xxx.tenant[ + test_customer + xxx.t + tenant] --> perm:view:on:test_customer#xxx{{ + test_customer + xxx + view}} + role:test_domain#xxx00-aaaa.admin[ + test_domain + xxx00-aaaa.a + admin] --> perm:edit:on:test_domain#xxx00-aaaa{{ + test_domain + xxx00-aaaa + edit}} + role:test_package#xxx00.tenant[ + test_package + xxx00.t + tenant] --> role:test_customer#xxx.tenant[ + test_customer + xxx.t + tenant] + role:test_domain#xxx00-aaaa.owner[ + test_domain + xxx00-aaaa.o + owner] --> role:test_domain#xxx00-aaaa.admin[ + test_domain + xxx00-aaaa.a + admin] + role:test_package#xxx00.tenant[ + test_package + xxx00.t + tenant] --> perm:view:on:test_package#xxx00{{ + test_package + xxx00 + view}} + role:test_domain#xxx00-aaaa.admin[ + test_domain + xxx00-aaaa.a + admin] --> role:test_package#xxx00.tenant[ + test_package + xxx00.t + tenant] + """.trim()); + } + + @Test +// @Disabled + void print() throws IOException { + //context("superuser-alex@hostsharing.net", "hs_office_person#FirbySusan.admin"); + context("superuser-alex@hostsharing.net"); + + //final var graph = grantsMermaidService.allGrantsToCurrentUser(EnumSet.of(Include.NON_TEST_ENTITIES, Include.PERMISSIONS)); + + final var targetObject = (UUID) em.createNativeQuery("SELECT uuid FROM hs_office_coopassetstransaction WHERE reference='ref 1000101-1'").getSingleResult(); + final var graph = grantsMermaidService.allGrantsFrom(targetObject, "view", EnumSet.of(Include.USERS)); + + RbacGrantsDiagramService.writeToFile(join(";", context.getAssumedRoles()), graph, "doc/all-grants.md"); + } +}