RbacViewMermaidFlowchartGenerator.java

package net.hostsharing.hsadminng.rbac.generator;

import lombok.SneakyThrows;
import net.hostsharing.hsadminng.rbac.generator.RbacSpec.CaseDef;
import org.apache.commons.lang3.StringUtils;

import java.nio.file.*;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.joining;
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.RbacGrantDefinition.GrantType.*;

public class RbacViewMermaidFlowchartGenerator {

    public static final String HOSTSHARING_DARK_ORANGE = "#dd4901";
    public static final String HOSTSHARING_LIGHT_ORANGE = "#feb28c";
    public static final String HOSTSHARING_DARK_BLUE = "#274d6e";
    public static final String HOSTSHARING_LIGHT_BLUE = "#99bcdb";
    private final RbacSpec rbacDef;

    private final List<RbacSpec.EntityAlias> usedEntityAliases;

    private final CaseDef forCase;
    private final StringWriter flowchart = new StringWriter();

    public RbacViewMermaidFlowchartGenerator(final RbacSpec rbacDef, final CaseDef forCase) {
        this.rbacDef = rbacDef;
        this.forCase = forCase;

        usedEntityAliases = rbacDef.getGrantDefs().stream()
                        .flatMap(g -> Stream.of(
                                g.getSuperRoleDef() != null ? g.getSuperRoleDef().getEntityAlias() : null,
                                g.getSubRoleDef() != null ? g.getSubRoleDef().getEntityAlias() : null,
                                g.getPermDef() != null ? g.getPermDef().getEntityAlias() : null))
                        .filter(Objects::nonNull)
                        .sorted(comparing(RbacSpec.EntityAlias::aliasName))
                        .distinct()
                        .filter(rbacDef::renderInDiagram)
                        .collect(Collectors.toList());

        flowchart.writeLn("""
                %%{init:{'flowchart':{'htmlLabels':false}}}%%
                flowchart TB
                """);
        renderEntitySubgraphs();
        renderGrants();
    }

    public RbacViewMermaidFlowchartGenerator(final RbacSpec rbacDef) {
       this(rbacDef, null);
    }
    private void renderEntitySubgraphs() {
        usedEntityAliases.stream()
                .filter(entityAlias -> !rbacDef.isEntityAliasProxy(entityAlias))
                .filter(entityAlias -> !entityAlias.isPlaceholder())
                .filter(rbacDef::renderInDiagram)
                .forEach(this::renderEntitySubgraph);
    }

    private void renderEntitySubgraph(final RbacSpec.EntityAlias entity) {
        if (!rbacDef.renderInDiagram(entity)) {
            return;
        }

        final var color = rbacDef.isRootEntityAlias(entity) ? HOSTSHARING_DARK_ORANGE
                : entity.isSubEntity() ? HOSTSHARING_LIGHT_ORANGE
                : HOSTSHARING_LIGHT_BLUE;
        flowchart.writeLn("""            
            subgraph %{aliasName}["`**%{aliasName}**`"]
                direction TB
                style %{aliasName} fill:%{fillColor},stroke:%{strokeColor},stroke-width:8px
            """
            .replace("%{aliasName}", entity.aliasName())
            .replace("%{fillColor}", color )
            .replace("%{strokeColor}", HOSTSHARING_DARK_BLUE ));

        flowchart.indented( () -> {
            usedEntityAliases.stream()
                    .filter(e -> e.aliasName().startsWith(entity.aliasName() + ":"))
                    .forEach(this::renderEntitySubgraph);

            wrapOutputInSubgraph(entity.aliasName() + ":roles", color,
                    rbacDef.getRoleDefs().stream()
                    .filter(r -> r.getEntityAlias() == entity)
                    .map(this::roleDef)
                    .collect(joining("\n")));

            wrapOutputInSubgraph(entity.aliasName() + ":permissions", color,
                rbacDef.getPermDefs().stream()
                        .filter(p -> p.getEntityAlias() == entity)
                        .map(this::permDef)
                        .collect(joining("\n")));

            if (rbacDef.isRootEntityAlias(entity) && rbacDef.getRootEntityAliasProxy() != null ) {
                renderEntitySubgraph(rbacDef.getRootEntityAliasProxy());
            }

        });
        flowchart.chopEmptyLines();
        flowchart.writeLn("end");
        flowchart.writeLn();
    }

    private void wrapOutputInSubgraph(final String name, final String color, final String content) {
        if (!StringUtils.isEmpty(content)) {
            flowchart.ensureSingleEmptyLine();
            flowchart.writeLn("subgraph " + name + "[ ]\n");
            flowchart.indented(() -> {
                flowchart.writeLn("style %{aliasName} fill:%{fillColor},stroke:white"
                            .replace("%{aliasName}", name)
                            .replace("%{fillColor}", color));
                flowchart.writeLn();
                flowchart.writeLn(content);
            });
            flowchart.chopEmptyLines();
            flowchart.writeLn("end");
            flowchart.writeLn();
        }
    }

    private void renderGrants() {
        renderGrants(ROLE_TO_USER, "%% granting roles to users");
        renderGrants(ROLE_TO_ROLE, "%% granting roles to roles");
        renderGrants(PERM_TO_ROLE, "%% granting permissions to roles");
    }

    private void renderGrants(final RbacSpec.RbacGrantDefinition.GrantType grantType, final String comment) {
        final var grantsOfRequestedType = rbacDef.getGrantDefs().stream()
                .filter(g -> g.grantType() == grantType)
                .filter(rbacDef::renderInDiagram)
                .filter(this::isToBeRenderedForThisCase)
                .toList();
        if ( !grantsOfRequestedType.isEmpty()) {
            flowchart.ensureSingleEmptyLine();
            flowchart.writeLn(comment);
            grantsOfRequestedType.forEach(g -> flowchart.writeLn(grantDef(g)));
        }
    }

    private boolean isToBeRenderedForThisCase(final RbacSpec.RbacGrantDefinition g) {
        if ( g.grantType() == ROLE_TO_USER )
            return true;
        if ( forCase == null && !g.isConditional() )
            return true;
        final var isToBeRenderedInThisGraph = g.getForCases() == null || g.getForCases().contains(forCase);
        return isToBeRenderedInThisGraph;
    }

    private String grantDef(final RbacSpec.RbacGrantDefinition grant) {
        final var arrow = (grant.isToCreate() ? " ==>" : " -.->")
                        + (grant.isAssumed() ? " " : "|XX| ");
        final var grantDef = switch (grant.grantType()) {
            case ROLE_TO_USER ->
                // TODO: other user types not implemented yet
                    "user:creator" + arrow + roleId(grant.getSubRoleDef());
            case ROLE_TO_ROLE ->
                    roleId(grant.getSuperRoleDef()) + arrow + roleId(grant.getSubRoleDef());
            case PERM_TO_ROLE -> roleId(grant.getSuperRoleDef()) + arrow + permId(grant.getPermDef());
        };
        return grantDef;
    }

    private String permDef(final RbacSpec.RbacPermissionDefinition perm) {
        return permId(perm) + "{{" + perm.getEntityAlias().aliasName() + perm.getPermission() + "}}";
    }

    private static String permId(final RbacSpec.RbacPermissionDefinition permDef) {
        return "perm:" + permDef.getEntityAlias().aliasName() + permDef.getPermission();
    }

    private String roleDef(final RbacSpec.RbacRoleDefinition roleDef) {
        return roleId(roleDef) + "[[" + roleDef.getEntityAlias().aliasName() + roleDef.getRole() + "]]";
    }

    private static String roleId(final RbacSpec.RbacRoleDefinition r) {
        return "role:" + r.getEntityAlias().aliasName() + r.getRole();
    }

    @Override
    public String toString() {
        return flowchart.toString();
    }

    @SneakyThrows
    public void generateToMarkdownFile(final Path path) {
        Files.writeString(
                path,
                """
                    ### rbac %{entityAlias}%{case}
    
                    This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
    
                    ```mermaid
                    %{flowchart}
                    ```
                    """
                    .replace("%{entityAlias}", rbacDef.getRootEntityAlias().aliasName())
                    .replace("%{flowchart}", flowchart.toString())
                    .replace("%{case}", forCase == null ? "" : " " + forCase),
                StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
    }
}