flows: add diagrams (#415)

* flows: initial diagram implementation

* web: install flowchart.js, add flow diagram page

* web: adjust diagram colours for dark mode

* flows: add permission checks for diagram

* flows: fix formatting

* web: fix formatting for web

* flows: add fix when last stage has policy

* flows: add test for diagram

* web: flows/diagram: add support for light mode

* flows: make Flows's Diagram API return json, add more tests and fix swagger response
This commit is contained in:
Jens L 2020-12-26 17:05:11 +01:00 committed by GitHub
parent 33f5169f36
commit a9336f069c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 321 additions and 25 deletions

View file

@ -1,9 +1,17 @@
"""Flow API Views""" """Flow API Views"""
from dataclasses import dataclass
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model
from django.shortcuts import get_object_or_404
from drf_yasg2.utils import swagger_auto_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin from rest_framework.mixins import ListModelMixin
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ( from rest_framework.serializers import (
CharField,
ModelSerializer, ModelSerializer,
Serializer, Serializer,
SerializerMethodField, SerializerMethodField,
@ -40,6 +48,30 @@ class FlowSerializer(ModelSerializer):
] ]
class FlowDiagramSerializer(Serializer):
"""response of the flow's /diagram/ action"""
diagram = CharField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
@dataclass
class DiagramElement:
"""Single element used in a diagram"""
identifier: str
type: str
rest: str
def __str__(self) -> str:
return f"{self.identifier}=>{self.type}: {self.rest}"
class FlowViewSet(ModelViewSet): class FlowViewSet(ModelViewSet):
"""Flow Viewset""" """Flow Viewset"""
@ -47,6 +79,78 @@ class FlowViewSet(ModelViewSet):
serializer_class = FlowSerializer serializer_class = FlowSerializer
lookup_field = "slug" lookup_field = "slug"
@swagger_auto_schema(responses={200: FlowDiagramSerializer()})
@action(detail=True, methods=["get"])
def diagram(self, request: Request, slug: str) -> Response:
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
flow = get_object_or_404(
get_objects_for_user(request.user, "authentik_flows.view_flow").filter(
slug=slug
)
)
header = [
DiagramElement("st", "start", "Start"),
]
body: list[DiagramElement] = []
footer = []
# First, collect all elements we need
for s_index, stage_binding in enumerate(
get_objects_for_user(request.user, "authentik_flows.view_flowstagebinding")
.filter(target=flow)
.order_by("order")
):
body.append(
DiagramElement(
f"stage_{s_index}",
"operation",
f"Stage\n{stage_binding.stage.name}",
)
)
for p_index, policy_binding in enumerate(
get_objects_for_user(
request.user, "authentik_policies.view_policybinding"
)
.filter(target=stage_binding)
.order_by("order")
):
body.append(
DiagramElement(
f"stage_{s_index}_policy_{p_index}",
"condition",
f"Policy\n{policy_binding.policy.name}",
)
)
# If the 2nd last element is a policy, we need to have an item to point to
# for a negative case
body.append(
DiagramElement("e", "end", "End|future"),
)
if len(body) == 1:
footer.append("st(right)->e")
else:
# Actual diagram flow
footer.append(f"st(right)->{body[0].identifier}")
for index in range(len(body) - 1):
element: DiagramElement = body[index]
if element.type == "condition":
# Policy passes, link policy yes to next stage
footer.append(
f"{element.identifier}(yes, right)->{body[index + 1].identifier}"
)
# Policy doesn't pass, go to stage after next stage
no_element = body[index + 1]
if no_element.type != "end":
no_element = body[index + 2]
footer.append(
f"{element.identifier}(no, bottom)->{no_element.identifier}"
)
elif element.type == "operation":
footer.append(
f"{element.identifier}(bottom)->{body[index + 1].identifier}"
)
diagram = "\n".join([str(x) for x in header + body + footer])
return Response({"diagram": diagram})
class StageSerializer(ModelSerializer): class StageSerializer(ModelSerializer):
"""Stage Serializer""" """Stage Serializer"""

View file

@ -0,0 +1,92 @@
"""API flow tests"""
from django.shortcuts import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.flows.api import StageSerializer, StageViewSet
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
from authentik.stages.dummy.models import DummyStage
DIAGRAM_EXPECTED = """st=>start: Start
stage_0=>operation: Stage
dummy1
stage_1=>operation: Stage
dummy2
stage_1_policy_0=>condition: Policy
None
e=>end: End|future
st(right)->stage_0
stage_0(bottom)->stage_1
stage_1(bottom)->stage_1_policy_0
stage_1_policy_0(yes, right)->e
stage_1_policy_0(no, bottom)->e"""
DIAGRAM_SHORT_EXPECTED = """st=>start: Start
e=>end: End|future
st(right)->e"""
class TestFlowsAPI(APITestCase):
"""API tests"""
def test_models(self):
"""Test that ui_user_settings returns none"""
self.assertIsNone(Stage().ui_user_settings)
def test_api_serializer(self):
"""Test that stage serializer returns the correct type"""
obj = DummyStage()
self.assertEqual(StageSerializer().get_type(obj), "dummy")
self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
def test_api_viewset(self):
"""Test that stage serializer returns the correct type"""
dummy = DummyStage.objects.create()
self.assertIn(dummy, StageViewSet().get_queryset())
def test_api_diagram(self):
"""Test flow diagram."""
user = User.objects.get(username="akadmin")
self.client.force_login(user)
flow = Flow.objects.create(
name="test-default-context",
slug="test-default-context",
designation=FlowDesignation.AUTHENTICATION,
)
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
FlowStageBinding.objects.create(
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
)
binding2 = FlowStageBinding.objects.create(
target=flow,
stage=DummyStage.objects.create(name="dummy2"),
order=1,
re_evaluate_policies=True,
)
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
response = self.client.get(
reverse("authentik_api:flow-diagram", kwargs={"slug": flow.slug})
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(response.content, {"diagram": DIAGRAM_EXPECTED})
def test_api_diagram_no_stages(self):
"""Test flow diagram with no stages."""
user = User.objects.get(username="akadmin")
self.client.force_login(user)
flow = Flow.objects.create(
name="test-default-context",
slug="test-default-context",
designation=FlowDesignation.AUTHENTICATION,
)
response = self.client.get(
reverse("authentik_api:flow-diagram", kwargs={"slug": flow.slug})
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(response.content, {"diagram": DIAGRAM_SHORT_EXPECTED})

View file

@ -1,25 +0,0 @@
"""miscellaneous flow tests"""
from django.test import TestCase
from authentik.flows.api import StageSerializer, StageViewSet
from authentik.flows.models import Stage
from authentik.stages.dummy.models import DummyStage
class TestFlowsMisc(TestCase):
"""miscellaneous tests"""
def test_models(self):
"""Test that ui_user_settings returns none"""
self.assertIsNone(Stage().ui_user_settings)
def test_api_serializer(self):
"""Test that stage serializer returns the correct type"""
obj = DummyStage()
self.assertEqual(StageSerializer().get_type(obj), "dummy")
self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
def test_api_viewset(self):
"""Test that stage serializer returns the correct type"""
dummy = DummyStage.objects.create()
self.assertIn(dummy, StageViewSet().get_queryset())

View file

@ -1317,6 +1317,27 @@ paths:
type: string type: string
format: slug format: slug
pattern: ^[-a-zA-Z0-9_]+$ pattern: ^[-a-zA-Z0-9_]+$
/flows/instances/{slug}/diagram/:
get:
operationId: flows_instances_diagram
description: Return diagram for flow with slug `slug`, in the format used by
flowchart.js
parameters: []
responses:
'200':
description: response of the flow's /diagram/ action
schema:
$ref: '#/definitions/FlowDiagram'
tags:
- flows
parameters:
- name: slug
in: path
description: Visible in the URL.
required: true
type: string
format: slug
pattern: ^[-a-zA-Z0-9_]+$
/outposts/outposts/: /outposts/outposts/:
get: get:
operationId: outposts_outposts_list operationId: outposts_outposts_list
@ -7176,6 +7197,15 @@ definitions:
title: Cache count title: Cache count
type: string type: string
readOnly: true readOnly: true
FlowDiagram:
description: response of the flow's /diagram/ action
type: object
properties:
diagram:
title: Diagram
type: string
readOnly: true
minLength: 1
Outpost: Outpost:
description: Outpost Serializer description: Outpost Serializer
required: required:

21
web/package-lock.json generated
View file

@ -1335,6 +1335,11 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true "dev": true
}, },
"eve-raphael": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/eve-raphael/-/eve-raphael-0.5.0.tgz",
"integrity": "sha1-F8dUt5K+7z+maE15z1pHxjxM2jA="
},
"expand-brackets": { "expand-brackets": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
@ -1562,6 +1567,14 @@
"integrity": "sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==", "integrity": "sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==",
"dev": true "dev": true
}, },
"flowchart.js": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/flowchart.js/-/flowchart.js-1.15.0.tgz",
"integrity": "sha512-IyCVUFfHPLPgKLynw3NCkZ7CvKJdc/bAu0aHm+2AxKhtSBCiUC1kcTX1KautC3HOp1A2JS1IOcYxDTmcMkx5nQ==",
"requires": {
"raphael": "2.3.0"
}
},
"for-in": { "for-in": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -2580,6 +2593,14 @@
"safe-buffer": "^5.1.0" "safe-buffer": "^5.1.0"
} }
}, },
"raphael": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/raphael/-/raphael-2.3.0.tgz",
"integrity": "sha512-w2yIenZAQnp257XUWGni4bLMVxpUpcIl7qgxEgDIXtmSypYtlNxfXWpOBxs7LBTps5sDwhRnrToJrMUrivqNTQ==",
"requires": {
"eve-raphael": "0.5.0"
}
},
"regenerator-runtime": { "regenerator-runtime": {
"version": "0.13.7", "version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",

View file

@ -16,6 +16,7 @@
"chart.js": "^2.9.4", "chart.js": "^2.9.4",
"codemirror": "^5.59.0", "codemirror": "^5.59.0",
"construct-style-sheets-polyfill": "^2.4.3", "construct-style-sheets-polyfill": "^2.4.3",
"flowchart.js": "^1.15.0",
"lit-element": "^2.4.0", "lit-element": "^2.4.0",
"lit-html": "^1.3.0", "lit-html": "^1.3.0",
"rollup": "^2.35.1", "rollup": "^2.35.1",

View file

@ -30,6 +30,10 @@ export class Flow {
return DefaultClient.fetch<Flow>(["flows", "instances", slug]); return DefaultClient.fetch<Flow>(["flows", "instances", slug]);
} }
static diagram(slug: string): Promise<{ diagram: string }> {
return DefaultClient.fetch<{ diagram: string }>(["flows", "instances", slug, "diagram"]);
}
static list(filter?: QueryArguments): Promise<PBResponse<Flow>> { static list(filter?: QueryArguments): Promise<PBResponse<Flow>> {
return DefaultClient.fetch<PBResponse<Flow>>(["flows", "instances"], filter); return DefaultClient.fetch<PBResponse<Flow>>(["flows", "instances"], filter);
} }

View file

@ -0,0 +1,62 @@
import { customElement, html, LitElement, property, TemplateResult } from "lit-element";
import FlowChart from "flowchart.js";
import { Flow } from "../../api/Flows";
import { loading } from "../../utils";
export const FONT_COLOUR_DARK_MODE = "#fafafa";
export const FONT_COLOUR_LIGHT_MODE = "#151515";
export const FILL_DARK_MODE = "#18191a";
export const FILL_LIGHT_MODE = "#f0f0f0";
@customElement("ak-flow-diagram")
export class FlowDiagram extends LitElement {
@property()
set flowSlug(value: string) {
Flow.diagram(value).then((data) => {
this.diagram = FlowChart.parse(data.diagram);
});
}
@property({attribute: false})
diagram?: FlowChart.Instance;
@property()
fontColour: string = FONT_COLOUR_DARK_MODE;
@property()
fill: string = FILL_DARK_MODE;
createRenderRoot(): Element | ShadowRoot {
return this;
}
constructor() {
super();
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", (ev) => {
if (ev.matches) {
this.fontColour = FONT_COLOUR_LIGHT_MODE;
this.fill = FILL_LIGHT_MODE;
} else {
this.fontColour = FONT_COLOUR_DARK_MODE;
this.fill = FILL_DARK_MODE;
}
});
}
render(): TemplateResult {
if (this.diagram) {
this.diagram.drawSVG(this, {
"font-color": this.fontColour,
"line-color": "#bebebe",
"element-color": "#bebebe",
"fill": this.fill,
"yes-text": "Policy passes",
"no-text": "Policy denies",
});
return html``;
}
return loading(this.diagram, html``);
}
}

View file

@ -9,6 +9,7 @@ import "../../elements/buttons/ModalButton";
import "../../elements/buttons/SpinnerButton"; import "../../elements/buttons/SpinnerButton";
import "../../elements/policies/BoundPoliciesList"; import "../../elements/policies/BoundPoliciesList";
import "./BoundStagesList"; import "./BoundStagesList";
import "./FlowDiagram";
@customElement("ak-flow-view") @customElement("ak-flow-view")
export class FlowViewPage extends LitElement { export class FlowViewPage extends LitElement {
@ -49,6 +50,12 @@ export class FlowViewPage extends LitElement {
</div> </div>
</section> </section>
<ak-tabs> <ak-tabs>
<div slot="page-1" data-tab-title="${gettext("Flow Diagram")}" class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
<ak-flow-diagram flowSlug=${this.flow.slug}>
</ak-flow-diagram>
</div>
</div>
<div slot="page-2" data-tab-title="${gettext("Stage Bindings")}" class="pf-c-page__main-section pf-m-no-padding-mobile"> <div slot="page-2" data-tab-title="${gettext("Stage Bindings")}" class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card"> <div class="pf-c-card">
<ak-bound-stages-list .target=${this.flow.pk}> <ak-bound-stages-list .target=${this.flow.pk}>