diff --git a/authentik/admin/templates/administration/flow/import.html b/authentik/admin/templates/administration/flow/import.html deleted file mode 100644 index 14eca092a..000000000 --- a/authentik/admin/templates/administration/flow/import.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends base_template|default:"generic/form.html" %} - -{% load i18n %} - -{% block above_form %} -

-{% trans 'Import Flow' %} -

-{% endblock %} - -{% block action %} -{% trans 'Import Flow' %} -{% endblock %} diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py index 03ae5b98f..6402e6efa 100644 --- a/authentik/admin/urls.py +++ b/authentik/admin/urls.py @@ -2,7 +2,6 @@ from django.urls import path from authentik.admin.views import ( - flows, outposts, outposts_service_connections, policies, @@ -99,27 +98,6 @@ urlpatterns = [ stages_invitations.InvitationCreateView.as_view(), name="stage-invitation-create", ), - # Flows - path( - "flows/create/", - flows.FlowCreateView.as_view(), - name="flow-create", - ), - path( - "flows/import/", - flows.FlowImportView.as_view(), - name="flow-import", - ), - path( - "flows//update/", - flows.FlowUpdateView.as_view(), - name="flow-update", - ), - path( - "flows//execute/", - flows.FlowDebugExecuteView.as_view(), - name="flow-execute", - ), # Property Mappings path( "property-mappings/create/", diff --git a/authentik/admin/views/flows.py b/authentik/admin/views/flows.py deleted file mode 100644 index 01d1b54fa..000000000 --- a/authentik/admin/views/flows.py +++ /dev/null @@ -1,108 +0,0 @@ -"""authentik Flow administration""" -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.http import HttpRequest, HttpResponse -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import DetailView, FormView, UpdateView -from guardian.mixins import PermissionRequiredMixin - -from authentik.flows.exceptions import FlowNonApplicableException -from authentik.flows.forms import FlowForm, FlowImportForm -from authentik.flows.models import Flow -from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER -from authentik.flows.transfer.importer import FlowImporter -from authentik.flows.views import SESSION_KEY_PLAN, FlowPlanner -from authentik.lib.utils.urls import redirect_with_qs -from authentik.lib.views import CreateAssignPermView, bad_request_message - - -class FlowCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Flow""" - - model = Flow - form_class = FlowForm - permission_required = "authentik_flows.add_flow" - - template_name = "generic/create.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully created Flow") - - -class FlowUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update flow""" - - model = Flow - form_class = FlowForm - permission_required = "authentik_flows.change_flow" - - template_name = "generic/update.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully updated Flow") - - -class FlowDebugExecuteView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): - """Debug exectue flow, setting the current user as pending user""" - - model = Flow - permission_required = "authentik_flows.view_flow" - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, pk: str) -> HttpResponse: - """Debug exectue flow, setting the current user as pending user""" - flow: Flow = self.get_object() - planner = FlowPlanner(flow) - planner.use_cache = False - try: - plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user}) - self.request.session[SESSION_KEY_PLAN] = plan - except FlowNonApplicableException as exc: - return bad_request_message( - request, - _( - "Flow not applicable to current user/request: %(messages)s" - % {"messages": str(exc)} - ), - ) - return redirect_with_qs( - "authentik_core:if-flow", - self.request.GET, - flow_slug=flow.slug, - ) - - -class FlowImportView(LoginRequiredMixin, FormView): - """Import flow from JSON Export; only allowed for superusers - as these flows can contain python code""" - - form_class = FlowImportForm - template_name = "administration/flow/import.html" - success_url = reverse_lazy("authentik_core:if-admin") - - def dispatch(self, request, *args, **kwargs): - if not request.user.is_superuser: - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form: FlowImportForm) -> HttpResponse: - importer = FlowImporter(form.cleaned_data["flow"].read().decode()) - successful = importer.apply() - if not successful: - messages.error(self.request, _("Failed to import flow.")) - else: - messages.success(self.request, _("Successfully imported flow.")) - return super().form_valid(form) diff --git a/authentik/api/v2/config.py b/authentik/api/v2/config.py index e878f024e..858f7f5e2 100644 --- a/authentik/api/v2/config.py +++ b/authentik/api/v2/config.py @@ -11,7 +11,7 @@ from rest_framework.viewsets import ViewSet from authentik.lib.config import CONFIG -class LinkSerializer(Serializer): +class FooterLinkSerializer(Serializer): """Links returned in Config API""" href = CharField(read_only=True) @@ -29,7 +29,7 @@ class ConfigSerializer(Serializer): branding_logo = CharField(read_only=True) branding_title = CharField(read_only=True) - ui_footer_links = ListField(child=LinkSerializer(), read_only=True) + ui_footer_links = ListField(child=FooterLinkSerializer(), read_only=True) error_reporting_enabled = BooleanField(read_only=True) error_reporting_environment = CharField(read_only=True) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 1584a6c50..396b8f390 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -13,6 +13,7 @@ from rest_framework.viewsets import ModelViewSet from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h from authentik.api.decorators import permission_required +from authentik.core.api.utils import LinkSerializer from authentik.core.middleware import ( SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER, @@ -57,18 +58,6 @@ class SessionUserSerializer(Serializer): raise NotImplementedError -class UserRecoverySerializer(Serializer): - """Recovery link for a user to reset their password""" - - link = CharField() - - def create(self, validated_data: dict) -> Model: - raise NotImplementedError - - def update(self, instance: Model, validated_data: dict) -> Model: - raise NotImplementedError - - class UserMetricsSerializer(Serializer): """User Metrics""" @@ -142,7 +131,7 @@ class UserViewSet(ModelViewSet): @permission_required("authentik_core.reset_user_password") @swagger_auto_schema( - responses={"200": UserRecoverySerializer(many=False)}, + responses={"200": LinkSerializer(many=False)}, ) @action(detail=True) # pylint: disable=invalid-name, unused-argument diff --git a/authentik/core/api/utils.py b/authentik/core/api/utils.py index 438efa2df..5b39c0cc4 100644 --- a/authentik/core/api/utils.py +++ b/authentik/core/api/utils.py @@ -49,3 +49,15 @@ class CacheSerializer(Serializer): def update(self, instance: Model, validated_data: dict) -> Model: raise NotImplementedError + + +class LinkSerializer(Serializer): + """Returns a single link""" + + link = CharField() + + def create(self, validated_data: dict) -> Model: + raise NotImplementedError + + def update(self, instance: Model, validated_data: dict) -> Model: + raise NotImplementedError diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index bf541b13c..c27878e9a 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from django.core.cache import cache from django.db.models import Model from django.http.response import HttpResponseBadRequest, JsonResponse +from django.urls import reverse +from django.utils.translation import gettext as _ from drf_yasg import openapi from drf_yasg.utils import no_body, swagger_auto_schema from guardian.shortcuts import get_objects_for_user @@ -21,11 +23,16 @@ from rest_framework.viewsets import ModelViewSet from structlog.stdlib import get_logger from authentik.api.decorators import permission_required -from authentik.core.api.utils import CacheSerializer +from authentik.core.api.utils import CacheSerializer, LinkSerializer +from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.models import Flow -from authentik.flows.planner import cache_key +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key from authentik.flows.transfer.common import DataclassEncoder from authentik.flows.transfer.exporter import FlowExporter +from authentik.flows.transfer.importer import FlowImporter +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.lib.utils.urls import redirect_with_qs +from authentik.lib.views import bad_request_message LOGGER = get_logger() @@ -57,7 +64,7 @@ class FlowSerializer(ModelSerializer): class FlowDiagramSerializer(Serializer): - """response of the flow's /diagram/ action""" + """response of the flow's diagram action""" diagram = CharField(read_only=True) @@ -89,14 +96,14 @@ class FlowViewSet(ModelViewSet): search_fields = ["name", "slug", "designation", "title"] filterset_fields = ["flow_uuid", "name", "slug", "designation"] - @permission_required("authentik_flows.view_flow_cache") + @permission_required(None, ["authentik_flows.view_flow_cache"]) @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) @action(detail=False) def cache_info(self, request: Request) -> Response: """Info about cached flows""" return Response(data={"count": len(cache.keys("flow_*"))}) - @permission_required("authentik_flows.clear_flow_cache") + @permission_required(None, ["authentik_flows.clear_flow_cache"]) @swagger_auto_schema( request_body=no_body, responses={204: "Successfully cleared cache", 400: "Bad request"}, @@ -109,7 +116,61 @@ class FlowViewSet(ModelViewSet): LOGGER.debug("Cleared flow cache", keys=len(keys)) return Response(status=204) - @permission_required("authentik_flows.export_flow") + @permission_required( + None, + [ + "authentik_flows.add_flow", + "authentik_flows.change_flow", + "authentik_flows.add_flowstagebinding", + "authentik_flows.change_flowstagebinding", + "authentik_flows.add_stage", + "authentik_flows.change_stage", + "authentik_policies.add_policy", + "authentik_policies.change_policy", + "authentik_policies.add_policybinding", + "authentik_policies.change_policybinding", + "authentik_stages_prompt.add_prompt", + "authentik_stages_prompt.change_prompt", + ], + ) + @swagger_auto_schema( + request_body=no_body, + manual_parameters=[ + openapi.Parameter( + name="file", + in_=openapi.IN_FORM, + type=openapi.TYPE_FILE, + required=True, + ) + ], + responses={204: "Successfully imported flow", 400: "Bad request"}, + ) + @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,)) + def import_flow(self, request: Request) -> Response: + """Import flow from .akflow file""" + file = request.FILES.get("file", None) + if not file: + return HttpResponseBadRequest() + importer = FlowImporter(file.read().decode()) + valid = importer.validate() + if not valid: + return HttpResponseBadRequest() + successful = importer.apply() + if not successful: + return Response(status=204) + return HttpResponseBadRequest() + + @permission_required( + "authentik_flows.export_flow", + [ + "authentik_flows.view_flow", + "authentik_flows.view_flowstagebinding", + "authentik_flows.view_stage", + "authentik_policies.view_policy", + "authentik_policies.view_policybinding", + "authentik_stages_prompt.view_prompt", + ], + ) @swagger_auto_schema( responses={ "200": openapi.Response( @@ -220,3 +281,32 @@ class FlowViewSet(ModelViewSet): app.background = icon app.save() return Response({}) + + @swagger_auto_schema( + responses={200: LinkSerializer(many=False)}, + ) + @action(detail=True) + # pylint: disable=unused-argument + def execute(self, request: Request, slug: str): + """Execute flow for current user""" + flow: Flow = self.get_object() + planner = FlowPlanner(flow) + planner.use_cache = False + try: + plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user}) + self.request.session[SESSION_KEY_PLAN] = plan + except FlowNonApplicableException as exc: + return bad_request_message( + request, + _( + "Flow not applicable to current user/request: %(messages)s" + % {"messages": str(exc)} + ), + ) + return Response( + { + "link": request._request.build_absolute_uri( + reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) + ) + } + ) diff --git a/authentik/flows/forms.py b/authentik/flows/forms.py index 2e8ca9d14..f8082aa67 100644 --- a/authentik/flows/forms.py +++ b/authentik/flows/forms.py @@ -1,35 +1,10 @@ """Flow and Stage forms""" - from django import forms -from django.core.validators import FileExtensionValidator -from django.forms import ValidationError -from django.utils.translation import gettext_lazy as _ -from authentik.flows.models import Flow, FlowStageBinding, Stage -from authentik.flows.transfer.importer import FlowImporter +from authentik.flows.models import FlowStageBinding, Stage from authentik.lib.widgets import GroupedModelChoiceField -class FlowForm(forms.ModelForm): - """Flow Form""" - - class Meta: - - model = Flow - fields = [ - "name", - "title", - "slug", - "designation", - "background", - ] - widgets = { - "name": forms.TextInput(), - "title": forms.TextInput(), - "background": forms.FileInput(), - } - - class FlowStageBindingForm(forms.ModelForm): """FlowStageBinding Form""" @@ -56,20 +31,3 @@ class FlowStageBindingForm(forms.ModelForm): widgets = { "name": forms.TextInput(), } - - -class FlowImportForm(forms.Form): - """Form used for flow importing""" - - flow = forms.FileField( - validators=[FileExtensionValidator(allowed_extensions=["akflow"])] - ) - - def clean_flow(self): - """Check if the flow is valid and rewind the file to the start""" - flow = self.cleaned_data["flow"].read() - valid = FlowImporter(flow.decode()).validate() - if not valid: - raise ValidationError(_("Flow invalid.")) - self.cleaned_data["flow"].seek(0) - return self.cleaned_data["flow"] diff --git a/swagger.yaml b/swagger.yaml index ea5f770bc..6acf37ae3 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -2267,7 +2267,7 @@ paths: '200': description: '' schema: - $ref: '#/definitions/UserRecovery' + $ref: '#/definitions/Link' '403': description: Authentication credentials were invalid, absent or insufficient. schema: @@ -3898,6 +3898,29 @@ paths: tags: - flows parameters: [] + /flows/instances/import_flow/: + post: + operationId: flows_instances_import_flow + description: Import flow from .akflow file + parameters: + - name: file + in: formData + required: true + type: file + responses: + '204': + description: Successfully imported flow + '400': + description: Bad request + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + consumes: + - multipart/form-data + tags: + - flows + parameters: [] /flows/instances/{slug}/: get: operationId: flows_instances_read @@ -4033,6 +4056,35 @@ paths: type: string format: slug pattern: ^[-a-zA-Z0-9_]+$ + /flows/instances/{slug}/execute/: + get: + operationId: flows_instances_execute + description: Execute flow for current user + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/Link' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - flows + parameters: + - name: slug + in: path + description: Visible in the URL. + required: true + type: string + format: slug + pattern: ^[-a-zA-Z0-9_]+$ /flows/instances/{slug}/export/: get: operationId: flows_instances_export @@ -14908,7 +14960,7 @@ definitions: items: $ref: '#/definitions/Coordinate' readOnly: true - UserRecovery: + Link: required: - link type: object @@ -17035,7 +17087,7 @@ definitions: title: Metadata type: string readOnly: true - Link: + FooterLink: type: object properties: href: @@ -17064,7 +17116,7 @@ definitions: ui_footer_links: type: array items: - $ref: '#/definitions/Link' + $ref: '#/definitions/FooterLink' readOnly: true error_reporting_enabled: title: Error reporting enabled diff --git a/web/src/pages/applications/ApplicationForm.ts b/web/src/pages/applications/ApplicationForm.ts index 7806daa61..25a1bd943 100644 --- a/web/src/pages/applications/ApplicationForm.ts +++ b/web/src/pages/applications/ApplicationForm.ts @@ -26,7 +26,7 @@ export class ApplicationForm extends Form { } } - send = (data: Application): Promise => { + send = (data: Application): Promise => { let writeOp: Promise; if (this.application) { writeOp = new CoreApi(DEFAULT_CONFIG).coreApplicationsUpdate({