diff --git a/authentik/core/expression/evaluator.py b/authentik/core/expression/evaluator.py
index 03bdf158c..85e6ccbc4 100644
--- a/authentik/core/expression/evaluator.py
+++ b/authentik/core/expression/evaluator.py
@@ -21,11 +21,14 @@ PROPERTY_MAPPING_TIME = Histogram(
class PropertyMappingEvaluator(BaseEvaluator):
"""Custom Evaluator that adds some different context variables."""
+ dry_run: bool
+
def __init__(
self,
model: Model,
user: Optional[User] = None,
request: Optional[HttpRequest] = None,
+ dry_run: Optional[bool] = False,
**kwargs,
):
if hasattr(model, "name"):
@@ -42,9 +45,13 @@ class PropertyMappingEvaluator(BaseEvaluator):
req.http_request = request
self._context["request"] = req
self._context.update(**kwargs)
+ self.dry_run = dry_run
def handle_error(self, exc: Exception, expression_source: str):
"""Exception Handler"""
+ # For dry-run requests we don't save exceptions
+ if self.dry_run:
+ return
error_string = exception_to_string(exc)
event = Event.new(
EventAction.PROPERTY_MAPPING_EXCEPTION,
diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py
index 0f333e32c..907754496 100644
--- a/authentik/stages/prompt/api.py
+++ b/authentik/stages/prompt/api.py
@@ -1,11 +1,22 @@
"""Prompt Stage API Views"""
+from drf_spectacular.utils import extend_schema
+from rest_framework.decorators import action
+from rest_framework.request import Request
+from rest_framework.response import Response
from rest_framework.serializers import CharField, ModelSerializer
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
+from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.flows.api.stages import StageSerializer
+from authentik.flows.challenge import ChallengeTypes, HttpChallengeResponse
+from authentik.flows.planner import FlowPlan
+from authentik.flows.views.executor import FlowExecutorView
+from authentik.lib.generators import generate_id
+from authentik.lib.utils.errors import exception_to_string
from authentik.stages.prompt.models import Prompt, PromptStage
+from authentik.stages.prompt.stage import PromptChallenge, PromptStageView
class PromptStageSerializer(StageSerializer):
@@ -60,3 +71,49 @@ class PromptViewSet(UsedByMixin, ModelViewSet):
serializer_class = PromptSerializer
filterset_fields = ["field_key", "name", "label", "type", "placeholder"]
search_fields = ["field_key", "name", "label", "type", "placeholder"]
+
+ @extend_schema(
+ request=PromptSerializer,
+ responses={
+ 200: PromptChallenge,
+ },
+ )
+ @action(detail=False, methods=["POST"])
+ def preview(self, request: Request) -> Response:
+ """Preview a prompt as a challenge, just like a flow would receive"""
+ # Remove a couple things from the request, the serializer will fail on these
+ # when previewing an existing prompt
+ # and since we don't plan to save from this, set a random name and remove the stage
+ request.data["name"] = generate_id()
+ request.data.pop("promptstage_set", None)
+ # Validate data, same as a normal edit/create request
+ prompt = PromptSerializer(data=request.data)
+ prompt.is_valid(raise_exception=True)
+ # Convert serializer to prompt instance
+ prompt_model = Prompt(**prompt.validated_data)
+ # Convert to field challenge
+ try:
+ fields = PromptStageView(
+ FlowExecutorView(
+ plan=FlowPlan(""),
+ request=request._request,
+ ),
+ request=request._request,
+ ).get_prompt_challenge_fields([prompt_model], {}, dry_run=True)
+ except PropertyMappingExpressionException as exc:
+ return Response(
+ {
+ "non_field_errors": [
+ exception_to_string(exc),
+ ]
+ },
+ status=400,
+ )
+ challenge = PromptChallenge(
+ data={
+ "type": ChallengeTypes.NATIVE.value,
+ "fields": fields,
+ },
+ )
+ challenge.is_valid()
+ return HttpChallengeResponse(challenge)
diff --git a/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py b/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py
index 906369440..062f117f0 100644
--- a/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py
+++ b/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py
@@ -39,7 +39,7 @@ class Migration(migrations.Migration):
("email", "Email: Text field with Email type."),
(
"password",
- "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.",
+ "Password: Masked input, multiple inputs of this type on the same prompt need to be identical.",
),
("number", "Number"),
("checkbox", "Checkbox"),
diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py
index 425c4d5fc..3487f3285 100644
--- a/authentik/stages/prompt/models.py
+++ b/authentik/stages/prompt/models.py
@@ -59,9 +59,8 @@ class FieldTypes(models.TextChoices):
PASSWORD = (
"password", # noqa # nosec
_(
- "Password: Masked input, password is validated against sources. Policies still "
- "have to be applied to this Stage. If two of these are used in the same stage, "
- "they are ensured to be identical."
+ "Password: Masked input, multiple inputs of this type on the same prompt "
+ "need to be identical."
),
)
NUMBER = "number"
@@ -137,7 +136,11 @@ class Prompt(SerializerModel):
return PromptSerializer
def get_choices(
- self, prompt_context: dict, user: User, request: HttpRequest
+ self,
+ prompt_context: dict,
+ user: User,
+ request: HttpRequest,
+ dry_run: Optional[bool] = False,
) -> Optional[tuple[dict[str, Any]]]:
"""Get fully interpolated list of choices"""
if self.type not in CHOICE_FIELDS:
@@ -148,14 +151,19 @@ class Prompt(SerializerModel):
if self.field_key in prompt_context:
raw_choices = prompt_context[self.field_key]
elif self.placeholder_expression:
- evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context)
+ evaluator = PropertyMappingEvaluator(
+ self, user, request, prompt_context=prompt_context, dry_run=dry_run
+ )
try:
raw_choices = evaluator.evaluate(self.placeholder)
except Exception as exc: # pylint:disable=broad-except
+ wrapped = PropertyMappingExpressionException(str(exc))
LOGGER.warning(
"failed to evaluate prompt choices",
- exc=PropertyMappingExpressionException(str(exc)),
+ exc=wrapped,
)
+ if dry_run:
+ raise wrapped from exc
if isinstance(raw_choices, (list, tuple, set)):
choices = raw_choices
@@ -167,11 +175,17 @@ class Prompt(SerializerModel):
return tuple(choices)
- def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest) -> str:
+ def get_placeholder(
+ self,
+ prompt_context: dict,
+ user: User,
+ request: HttpRequest,
+ dry_run: Optional[bool] = False,
+ ) -> str:
"""Get fully interpolated placeholder"""
if self.type in CHOICE_FIELDS:
# Make sure to return a valid choice as placeholder
- choices = self.get_choices(prompt_context, user, request)
+ choices = self.get_choices(prompt_context, user, request, dry_run=dry_run)
if not choices:
return ""
return choices[0]
@@ -182,14 +196,19 @@ class Prompt(SerializerModel):
return prompt_context[self.field_key]
if self.placeholder_expression:
- evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context)
+ evaluator = PropertyMappingEvaluator(
+ self, user, request, prompt_context=prompt_context, dry_run=dry_run
+ )
try:
return evaluator.evaluate(self.placeholder)
except Exception as exc: # pylint:disable=broad-except
+ wrapped = PropertyMappingExpressionException(str(exc))
LOGGER.warning(
"failed to evaluate prompt placeholder",
- exc=PropertyMappingExpressionException(str(exc)),
+ exc=wrapped,
)
+ if dry_run:
+ raise wrapped from exc
return self.placeholder
def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField:
diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py
index d0d723342..343fb7612 100644
--- a/authentik/stages/prompt/stage.py
+++ b/authentik/stages/prompt/stage.py
@@ -190,23 +190,30 @@ class PromptStageView(ChallengeStageView):
response_class = PromptChallengeResponse
- def get_challenge(self, *args, **kwargs) -> Challenge:
- fields: list[Prompt] = list(self.executor.current_stage.fields.all().order_by("order"))
+ def get_prompt_challenge_fields(self, fields: list[Prompt], context: dict, dry_run=False):
+ """Get serializers for all fields in `fields`, using the context `context`.
+ If `dry_run` is set, property mapping expression errors are raised, otherwise they
+ are logged and events are created"""
serializers = []
- context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
for field in fields:
data = StagePromptSerializer(field).data
# Ensure all choices and placeholders are str, as otherwise further in
# we can fail serializer validation if we return some types such as bool
- choices = field.get_choices(context_prompt, self.get_pending_user(), self.request)
+ choices = field.get_choices(context, self.get_pending_user(), self.request, dry_run)
if choices:
data["choices"] = [str(choice) for choice in choices]
else:
data["choices"] = None
data["placeholder"] = str(
- field.get_placeholder(context_prompt, self.get_pending_user(), self.request)
+ field.get_placeholder(context, self.get_pending_user(), self.request, dry_run)
)
serializers.append(data)
+ return serializers
+
+ def get_challenge(self, *args, **kwargs) -> Challenge:
+ fields: list[Prompt] = list(self.executor.current_stage.fields.all().order_by("order"))
+ context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
+ serializers = self.get_prompt_challenge_fields(fields, context_prompt)
challenge = PromptChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py
index 4a7453ea0..8e30f826f 100644
--- a/authentik/stages/prompt/tests.py
+++ b/authentik/stages/prompt/tests.py
@@ -6,6 +6,7 @@ from django.urls import reverse
from rest_framework.exceptions import ErrorDetail, ValidationError
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
+from authentik.flows.challenge import ChallengeTypes
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowStageBinding
from authentik.flows.planner import FlowPlan
@@ -493,6 +494,60 @@ class TestPromptStage(FlowTestCase):
with self.assertRaises(ValueError):
prompt.save()
+ def test_api_preview(self):
+ """Test API preview"""
+ self.client.force_login(self.user)
+ response = self.client.post(
+ reverse("authentik_api:prompt-preview"),
+ data={
+ "field_key": "text_prompt_expression",
+ "label": "TEXT_LABEL",
+ "type": FieldTypes.TEXT,
+ "placeholder": 'return "Hello world"',
+ "placeholder_expression": True,
+ "sub_text": "test",
+ "order": 123,
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(
+ response.content.decode(),
+ {
+ "type": ChallengeTypes.NATIVE.value,
+ "component": "ak-stage-prompt",
+ "fields": [
+ {
+ "field_key": "text_prompt_expression",
+ "label": "TEXT_LABEL",
+ "type": "text",
+ "required": True,
+ "placeholder": "Hello world",
+ "order": 123,
+ "sub_text": "test",
+ "choices": None,
+ }
+ ],
+ },
+ )
+
+ def test_api_preview_invalid_expression(self):
+ """Test API preview"""
+ self.client.force_login(self.user)
+ response = self.client.post(
+ reverse("authentik_api:prompt-preview"),
+ data={
+ "field_key": "text_prompt_expression",
+ "label": "TEXT_LABEL",
+ "type": FieldTypes.TEXT,
+ "placeholder": "return [",
+ "placeholder_expression": True,
+ "sub_text": "test",
+ "order": 123,
+ },
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("non_field_errors", response.content.decode())
+
def field_type_tester_factory(field_type: FieldTypes, required: bool):
"""Test field for field_type"""
diff --git a/schema.yml b/schema.yml
index 40571e624..0fa1a0d0e 100644
--- a/schema.yml
+++ b/schema.yml
@@ -24517,7 +24517,7 @@ paths:
* `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited.
* `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type.
- * `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.
+ * `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
* `number` - Number
* `checkbox` - Checkbox
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
@@ -24536,7 +24536,7 @@ paths:
* `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited.
* `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type.
- * `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.
+ * `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
* `number` - Number
* `checkbox` - Checkbox
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
@@ -24784,6 +24784,39 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
+ /stages/prompt/prompts/preview/:
+ post:
+ operationId: stages_prompt_prompts_preview_create
+ description: Preview a prompt as a challenge, just like a flow would receive
+ tags:
+ - stages
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PromptRequest'
+ required: true
+ security:
+ - authentik: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PromptChallenge'
+ description: ''
+ '400':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ValidationError'
+ description: ''
+ '403':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenericError'
+ description: ''
/stages/prompt/stages/:
get:
operationId: stages_prompt_stages_list
@@ -38125,7 +38158,7 @@ components:
* `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited.
* `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type.
- * `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.
+ * `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
* `number` - Number
* `checkbox` - Checkbox
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts
index db05badb5..41b19a4e1 100644
--- a/web/src/admin/admin-overview/AdminOverviewPage.ts
+++ b/web/src/admin/admin-overview/AdminOverviewPage.ts
@@ -1,4 +1,3 @@
-import { AdminInterface } from "@goauthentik/admin/AdminInterface";
import "@goauthentik/admin/admin-overview/TopApplicationsTable";
import "@goauthentik/admin/admin-overview/cards/AdminStatusCard";
import "@goauthentik/admin/admin-overview/cards/RecentEventsCard";
@@ -9,7 +8,8 @@ import "@goauthentik/admin/admin-overview/charts/AdminLoginAuthorizeChart";
import "@goauthentik/admin/admin-overview/charts/OutpostStatusChart";
import "@goauthentik/admin/admin-overview/charts/SyncStatusChart";
import { VERSION } from "@goauthentik/common/constants";
-import { AKElement, rootInterface } from "@goauthentik/elements/Base";
+import { me } from "@goauthentik/common/users";
+import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/cards/AggregatePromiseCard";
import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
@@ -17,13 +17,15 @@ import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
import { t } from "@lingui/macro";
import { CSSResult, TemplateResult, css, html } from "lit";
-import { customElement } from "lit/decorators.js";
+import { customElement, state } from "lit/decorators.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
+import { SessionUser } from "@goauthentik/api";
+
export function versionFamily(): string {
const parts = VERSION.split(".");
parts.pop();
@@ -56,11 +58,17 @@ export class AdminOverviewPage extends AKElement {
];
}
+ @state()
+ user?: SessionUser;
+
+ async firstUpdated(): Promise
${t`Optionally pre-fill the input value.
@@ -241,7 +392,13 @@ export class PromptForm extends ModelForm
${t`Any HTML can be used.`}