diff --git a/authentik/flows/api.py b/authentik/flows/api.py index f8de34cc4..2ac784c7d 100644 --- a/authentik/flows/api.py +++ b/authentik/flows/api.py @@ -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""" diff --git a/authentik/flows/tests/test_api.py b/authentik/flows/tests/test_api.py new file mode 100644 index 000000000..3dafebfc3 --- /dev/null +++ b/authentik/flows/tests/test_api.py @@ -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}) diff --git a/authentik/flows/tests/test_misc.py b/authentik/flows/tests/test_misc.py deleted file mode 100644 index 7546f4c85..000000000 --- a/authentik/flows/tests/test_misc.py +++ /dev/null @@ -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()) diff --git a/swagger.yaml b/swagger.yaml index cf81771e8..d26429144 100755 --- a/swagger.yaml +++ b/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: diff --git a/web/package-lock.json b/web/package-lock.json index 9fdda5f90..bb7222d7d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index b97d85522..6f4cda537 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/api/Flows.ts b/web/src/api/Flows.ts index c78df9d80..87f6d735c 100644 --- a/web/src/api/Flows.ts +++ b/web/src/api/Flows.ts @@ -30,6 +30,10 @@ export class Flow { return DefaultClient.fetch(["flows", "instances", slug]); } + static diagram(slug: string): Promise<{ diagram: string }> { + return DefaultClient.fetch<{ diagram: string }>(["flows", "instances", slug, "diagram"]); + } + static list(filter?: QueryArguments): Promise> { return DefaultClient.fetch>(["flows", "instances"], filter); } diff --git a/web/src/pages/flows/FlowDiagram.ts b/web/src/pages/flows/FlowDiagram.ts new file mode 100644 index 000000000..d31f60cc1 --- /dev/null +++ b/web/src/pages/flows/FlowDiagram.ts @@ -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``); + } + +} diff --git a/web/src/pages/flows/FlowViewPage.ts b/web/src/pages/flows/FlowViewPage.ts index df4b1e048..15bd2df95 100644 --- a/web/src/pages/flows/FlowViewPage.ts +++ b/web/src/pages/flows/FlowViewPage.ts @@ -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 { +
+
+ + +
+