Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

web/admin: prompt preview #5078

Merged
merged 4 commits into from Mar 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions authentik/core/expression/evaluator.py
Expand Up @@ -21,11 +21,14 @@
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"):
Expand All @@ -42,9 +45,13 @@ def __init__(
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,
Expand Down
57 changes: 57 additions & 0 deletions 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):
Expand Down Expand Up @@ -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),
]
},
Dismissed Show dismissed Hide dismissed
status=400,
)
challenge = PromptChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
"fields": fields,
},
)
challenge.is_valid()
return HttpChallengeResponse(challenge)
Expand Up @@ -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"),
Expand Down
39 changes: 29 additions & 10 deletions authentik/stages/prompt/models.py
Expand Up @@ -59,9 +59,8 @@
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"
Expand Down Expand Up @@ -137,7 +136,11 @@
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:
Expand All @@ -148,14 +151,19 @@
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))

Check warning on line 160 in authentik/stages/prompt/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/prompt/models.py#L160

Added line #L160 was not covered by tests
LOGGER.warning(
"failed to evaluate prompt choices",
exc=PropertyMappingExpressionException(str(exc)),
exc=wrapped,
)
if dry_run:
raise wrapped from exc

Check warning on line 166 in authentik/stages/prompt/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/prompt/models.py#L165-L166

Added lines #L165 - L166 were not covered by tests

if isinstance(raw_choices, (list, tuple, set)):
choices = raw_choices
Expand All @@ -167,11 +175,17 @@

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]
Expand All @@ -182,14 +196,19 @@
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:
Expand Down
17 changes: 12 additions & 5 deletions authentik/stages/prompt/stage.py
Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions authentik/stages/prompt/tests.py
Expand Up @@ -6,6 +6,7 @@
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
Expand Down Expand Up @@ -493,6 +494,60 @@ def test_invalid_save(self):
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"""
Expand Down
39 changes: 36 additions & 3 deletions schema.yml
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down