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:
parent
33f5169f36
commit
a9336f069c
|
@ -1,9 +1,17 @@
|
|||
"""Flow API Views"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
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.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import (
|
||||
CharField,
|
||||
ModelSerializer,
|
||||
Serializer,
|
||||
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):
|
||||
"""Flow Viewset"""
|
||||
|
||||
|
@ -47,6 +79,78 @@ class FlowViewSet(ModelViewSet):
|
|||
serializer_class = FlowSerializer
|
||||
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):
|
||||
"""Stage Serializer"""
|
||||
|
|
92
authentik/flows/tests/test_api.py
Normal file
92
authentik/flows/tests/test_api.py
Normal 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})
|
|
@ -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())
|
30
swagger.yaml
30
swagger.yaml
|
@ -1317,6 +1317,27 @@ paths:
|
|||
type: string
|
||||
format: slug
|
||||
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/:
|
||||
get:
|
||||
operationId: outposts_outposts_list
|
||||
|
@ -7176,6 +7197,15 @@ definitions:
|
|||
title: Cache count
|
||||
type: string
|
||||
readOnly: true
|
||||
FlowDiagram:
|
||||
description: response of the flow's /diagram/ action
|
||||
type: object
|
||||
properties:
|
||||
diagram:
|
||||
title: Diagram
|
||||
type: string
|
||||
readOnly: true
|
||||
minLength: 1
|
||||
Outpost:
|
||||
description: Outpost Serializer
|
||||
required:
|
||||
|
|
21
web/package-lock.json
generated
21
web/package-lock.json
generated
|
@ -1335,6 +1335,11 @@
|
|||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"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": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
|
||||
|
@ -1562,6 +1567,14 @@
|
|||
"integrity": "sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
|
||||
|
@ -2580,6 +2593,14 @@
|
|||
"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": {
|
||||
"version": "0.13.7",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"chart.js": "^2.9.4",
|
||||
"codemirror": "^5.59.0",
|
||||
"construct-style-sheets-polyfill": "^2.4.3",
|
||||
"flowchart.js": "^1.15.0",
|
||||
"lit-element": "^2.4.0",
|
||||
"lit-html": "^1.3.0",
|
||||
"rollup": "^2.35.1",
|
||||
|
|
|
@ -30,6 +30,10 @@ export class Flow {
|
|||
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>> {
|
||||
return DefaultClient.fetch<PBResponse<Flow>>(["flows", "instances"], filter);
|
||||
}
|
||||
|
|
62
web/src/pages/flows/FlowDiagram.ts
Normal file
62
web/src/pages/flows/FlowDiagram.ts
Normal 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``);
|
||||
}
|
||||
|
||||
}
|
|
@ -9,6 +9,7 @@ import "../../elements/buttons/ModalButton";
|
|||
import "../../elements/buttons/SpinnerButton";
|
||||
import "../../elements/policies/BoundPoliciesList";
|
||||
import "./BoundStagesList";
|
||||
import "./FlowDiagram";
|
||||
|
||||
@customElement("ak-flow-view")
|
||||
export class FlowViewPage extends LitElement {
|
||||
|
@ -49,6 +50,12 @@ export class FlowViewPage extends LitElement {
|
|||
</div>
|
||||
</section>
|
||||
<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 class="pf-c-card">
|
||||
<ak-bound-stages-list .target=${this.flow.pk}>
|
||||
|
|
Reference in a new issue