diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 96ff74bdb..e7560e02c 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -187,7 +187,10 @@ class Importer: if "pk" in updated_identifiers: model_instance.pk = updated_identifiers["pk"] serializer_kwargs["instance"] = model_instance - full_data = self.__update_pks_for_attrs(entry.get_attrs(self.__import)) + try: + full_data = self.__update_pks_for_attrs(entry.get_attrs(self.__import)) + except ValueError as exc: + raise EntryInvalidError(exc) from exc full_data.update(updated_identifiers) serializer_kwargs["data"] = full_data diff --git a/authentik/events/utils.py b/authentik/events/utils.py index b81da3314..047ae963d 100644 --- a/authentik/events/utils.py +++ b/authentik/events/utils.py @@ -14,6 +14,7 @@ from django.views.debug import SafeExceptionReporterFilter from geoip2.models import City from guardian.utils import get_anonymous_user +from authentik.blueprints.v1.common import YAMLTag from authentik.core.models import User from authentik.events.geo import GEOIP_READER from authentik.policies.types import PolicyRequest @@ -111,6 +112,10 @@ def sanitize_item(value: Any) -> Any: return GEOIP_READER.city_to_dict(value) if isinstance(value, Path): return str(value) + if isinstance(value, Exception): + return str(value) + if isinstance(value, YAMLTag): + return str(value) if isinstance(value, type): return { "type": value.__name__, diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index 638524236..6a76fcb22 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.decorators import action -from rest_framework.fields import ReadOnlyField +from rest_framework.fields import BooleanField, DictField, ListField, ReadOnlyField from rest_framework.parsers import MultiPartParser from rest_framework.request import Request from rest_framework.response import Response @@ -24,7 +24,9 @@ from authentik.core.api.utils import ( FilePathSerializer, FileUploadSerializer, LinkSerializer, + PassiveSerializer, ) +from authentik.events.utils import sanitize_dict from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.models import Flow @@ -77,6 +79,13 @@ class FlowSerializer(ModelSerializer): } +class FlowImportResultSerializer(PassiveSerializer): + """Logs of an attempted flow import""" + + logs = ListField(child=DictField(), read_only=True) + success = BooleanField(read_only=True) + + class FlowViewSet(UsedByMixin, ModelViewSet): """Flow Viewset""" @@ -130,25 +139,38 @@ class FlowViewSet(UsedByMixin, ModelViewSet): @extend_schema( request={"multipart/form-data": FileUploadSerializer}, responses={ - 204: OpenApiResponse(description="Successfully imported flow"), - 400: OpenApiResponse(description="Bad request"), + 204: FlowImportResultSerializer, + 400: FlowImportResultSerializer, }, ) - @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,)) + @action(url_path="import", detail=False, methods=["POST"], parser_classes=(MultiPartParser,)) def import_flow(self, request: Request) -> Response: """Import flow from .yaml file""" + import_response = FlowImportResultSerializer( + data={ + "logs": [], + "success": False, + } + ) + import_response.is_valid() file = request.FILES.get("file", None) if not file: - return HttpResponseBadRequest() + return Response(data=import_response.initial_data, status=400) + importer = Importer(file.read().decode()) - valid, _logs = importer.validate() - # TODO: return logs + valid, logs = importer.validate() + import_response.initial_data["logs"] = [sanitize_dict(log) for log in logs] + import_response.initial_data["success"] = valid + import_response.is_valid() if not valid: - return HttpResponseBadRequest() + return Response(data=import_response.initial_data, status=200) + successful = importer.apply() + import_response.initial_data["success"] = successful + import_response.is_valid() if not successful: - return HttpResponseBadRequest() - return Response(status=204) + return Response(data=import_response.initial_data, status=200) + return Response(data=import_response.initial_data, status=200) @permission_required( "authentik_flows.export_flow", diff --git a/schema.yml b/schema.yml index c4e940ac5..2330cc223 100644 --- a/schema.yml +++ b/schema.yml @@ -7516,9 +7516,9 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' - /flows/instances/import_flow/: + /flows/instances/import/: post: - operationId: flows_instances_import_flow_create + operationId: flows_instances_import_create description: Import flow from .yaml file tags: - flows @@ -7531,9 +7531,17 @@ paths: - authentik: [] responses: '204': - description: Successfully imported flow + content: + application/json: + schema: + $ref: '#/components/schemas/FlowImportResult' + description: '' '400': - description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/FlowImportResult' + description: '' '403': content: application/json: @@ -27610,6 +27618,22 @@ components: - pending_user_avatar - request_id - type + FlowImportResult: + type: object + description: Logs of an attempted flow import + properties: + logs: + type: array + items: + type: object + additionalProperties: {} + readOnly: true + success: + type: boolean + readOnly: true + required: + - logs + - success FlowInspection: type: object description: Serializer for inspect endpoint diff --git a/web/src/admin/flows/FlowImportForm.ts b/web/src/admin/flows/FlowImportForm.ts index d1060cee5..f1876be3d 100644 --- a/web/src/admin/flows/FlowImportForm.ts +++ b/web/src/admin/flows/FlowImportForm.ts @@ -3,30 +3,103 @@ import { SentryIgnoredError } from "@goauthentik/common/errors"; import { Form } from "@goauthentik/elements/forms/Form"; import "@goauthentik/elements/forms/HorizontalFormElement"; + + import { t } from "@lingui/macro"; -import { TemplateResult, html } from "lit"; -import { customElement } from "lit/decorators.js"; -import { Flow, FlowsApi } from "@goauthentik/api"; + +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; + + + +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; + + + +import { Flow, FlowImportResult, FlowsApi } from "@goauthentik/api"; +import { PFColor } from "@goauthentik/elements/Label"; + @customElement("ak-flow-import-form") export class FlowImportForm extends Form { + @state() + result?: FlowImportResult; + getSuccessMessage(): string { return t`Successfully imported flow.`; } + static get styles(): CSSResult[] { + return super.styles.concat(PFDescriptionList); + } + // eslint-disable-next-line - send = (data: Flow): Promise => { + send = (data: Flow): Promise => { const file = this.getFormFiles()["flow"]; if (!file) { throw new SentryIgnoredError("No form data"); } - return new FlowsApi(DEFAULT_CONFIG).flowsInstancesImportFlowCreate({ - file: file, - }); + return new FlowsApi(DEFAULT_CONFIG) + .flowsInstancesImportCreate({ + file: file, + }) + .then((result) => { + if (!result.success) { + this.result = result; + throw new SentryIgnoredError("Failed to import flow"); + } + return result; + }); }; + renderResult(): TemplateResult { + return html` + +
+
+ + + ${this.result?.success ? t`Yes` : t`No`} + + +
+
+
+ +
+
+
+ ${(this.result?.logs || []).length > 0 + ? this.result?.logs?.map((m) => { + return html`
+
+ ${m.log_level} +
+
+
+ ${m.event} +
+
+
`; + }) + : html`
+
+ ${t`No log messages.`} +
+
`} +
+
+
+
+ `; + } + renderForm(): TemplateResult { return html`
@@ -35,6 +108,7 @@ export class FlowImportForm extends Form { ${t`.yaml files, which can be found on goauthentik.io and can be exported by authentik.`}

+ ${this.result ? this.renderResult() : html``}
`; } } diff --git a/web/src/admin/policies/PolicyTestForm.ts b/web/src/admin/policies/PolicyTestForm.ts index 62438867d..c50885b09 100644 --- a/web/src/admin/policies/PolicyTestForm.ts +++ b/web/src/admin/policies/PolicyTestForm.ts @@ -10,7 +10,7 @@ import YAML from "yaml"; import { t } from "@lingui/macro"; import { CSSResult, TemplateResult, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { until } from "lit/directives/until.js"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; @@ -28,7 +28,7 @@ export class PolicyTestForm extends Form { @property({ attribute: false }) policy?: Policy; - @property({ attribute: false }) + @state() result?: PolicyTestResult; @property({ attribute: false }) diff --git a/web/src/elements/LoadingOverlay.ts b/web/src/elements/LoadingOverlay.ts index a014a6005..25ed89667 100644 --- a/web/src/elements/LoadingOverlay.ts +++ b/web/src/elements/LoadingOverlay.ts @@ -22,7 +22,7 @@ export class LoadingOverlay extends AKElement { justify-content: center; align-items: center; position: absolute; - background-color: var(--pf-global--BackgroundColor--dark-transparent-100); + background-color: var(--pf-global--BackgroundColor--dark-transparent-200); z-index: 1; } :host([topMost]) {