Merge branch 'master' into docs-flows
This commit is contained in:
commit
23193314f1
|
@ -18,10 +18,10 @@
|
||||||
"default": {
|
"default": {
|
||||||
"amqp": {
|
"amqp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
|
"sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b",
|
||||||
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
|
"sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139"
|
||||||
],
|
],
|
||||||
"version": "==2.5.2"
|
"version": "==2.6.0"
|
||||||
},
|
},
|
||||||
"asgiref": {
|
"asgiref": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -53,17 +53,18 @@
|
||||||
},
|
},
|
||||||
"boto3": {
|
"boto3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:c774003dc13d6de74b5e19d2b84d625da4456e64bd97f44baa1fcf40d808d29a"
|
"sha256:26f8564b46d009b8f4c6470a6d6cde147b282a197339c7e31cbb0fe9fd9e5f5d",
|
||||||
|
"sha256:f59d0bd230ed3a4b932c5c4e497a0e0ff3c93b46b7e8cde54efb6fe10c8266ba"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.13.19"
|
"version": "==1.13.20"
|
||||||
},
|
},
|
||||||
"botocore": {
|
"botocore": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:5cb537e7a4cf2d59a2a8dfbbc8e14ec3bc5b640eb81a1bf3bb0523c0a75e6b1b",
|
"sha256:990f3fc33dec746829740b1a9e1fe86183cdc96aedba6a632ccfcbae03e097cc",
|
||||||
"sha256:7b8b1f082665c8670b9aa70143ee527c5d04939fe027a63ac5958359be20ccb0"
|
"sha256:d4cc47ac989a7f1d2992ef7679fb423a7966f687becf623a291a555a2d7ce1c0"
|
||||||
],
|
],
|
||||||
"version": "==1.16.19"
|
"version": "==1.16.20"
|
||||||
},
|
},
|
||||||
"celery": {
|
"celery": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -363,11 +364,11 @@
|
||||||
},
|
},
|
||||||
"kombu": {
|
"kombu": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
|
"sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505",
|
||||||
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
|
"sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.6.8"
|
"version": "==4.6.9"
|
||||||
},
|
},
|
||||||
"ldap3": {
|
"ldap3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -687,10 +688,10 @@
|
||||||
},
|
},
|
||||||
"redis": {
|
"redis": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
|
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
|
||||||
"sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
|
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
|
||||||
],
|
],
|
||||||
"version": "==3.5.2"
|
"version": "==3.5.3"
|
||||||
},
|
},
|
||||||
"requests": {
|
"requests": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -857,10 +858,10 @@
|
||||||
},
|
},
|
||||||
"autopep8": {
|
"autopep8": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954"
|
"sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.5.2"
|
"version": "==1.5.3"
|
||||||
},
|
},
|
||||||
"bandit": {
|
"bandit": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -970,10 +971,10 @@
|
||||||
},
|
},
|
||||||
"gitpython": {
|
"gitpython": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94",
|
"sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a",
|
||||||
"sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09"
|
"sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"
|
||||||
],
|
],
|
||||||
"version": "==3.1.2"
|
"version": "==3.1.3"
|
||||||
},
|
},
|
||||||
"isort": {
|
"isort": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
|
@ -13,7 +13,6 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from guardian.mixins import GuardianUserMixin
|
from guardian.mixins import GuardianUserMixin
|
||||||
from jinja2 import Undefined
|
from jinja2 import Undefined
|
||||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
||||||
from jinja2.nativetypes import NativeEnvironment
|
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
|
@ -24,7 +23,6 @@ from passbook.lib.models import CreatedUpdatedModel
|
||||||
from passbook.policies.models import PolicyBindingModel
|
from passbook.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
NATIVE_ENVIRONMENT = NativeEnvironment()
|
|
||||||
|
|
||||||
|
|
||||||
def default_token_duration():
|
def default_token_duration():
|
||||||
|
@ -208,8 +206,11 @@ class PropertyMapping(models.Model):
|
||||||
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Evaluate `self.expression` using `**kwargs` as Context."""
|
"""Evaluate `self.expression` using `**kwargs` as Context."""
|
||||||
|
from passbook.policies.expression.evaluator import Evaluator
|
||||||
|
|
||||||
|
evaluator = Evaluator()
|
||||||
try:
|
try:
|
||||||
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
|
expression = evaluator.env.from_string(self.expression)
|
||||||
except TemplateSyntaxError as exc:
|
except TemplateSyntaxError as exc:
|
||||||
raise PropertyMappingExpressionException from exc
|
raise PropertyMappingExpressionException from exc
|
||||||
try:
|
try:
|
||||||
|
@ -221,8 +222,11 @@ class PropertyMapping(models.Model):
|
||||||
raise PropertyMappingExpressionException from exc
|
raise PropertyMappingExpressionException from exc
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
from passbook.policies.expression.evaluator import Evaluator
|
||||||
|
|
||||||
|
evaluator = Evaluator()
|
||||||
try:
|
try:
|
||||||
NATIVE_ENVIRONMENT.from_string(self.expression)
|
evaluator.env.from_string(self.expression)
|
||||||
except TemplateSyntaxError as exc:
|
except TemplateSyntaxError as exc:
|
||||||
raise ValidationError("Expression Syntax Error") from exc
|
raise ValidationError("Expression Syntax Error") from exc
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
@ -2,6 +2,13 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="pf-c-form__group has-error">
|
||||||
|
<p class="pf-c-form__helper-text pf-m-error">
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
|
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
|
||||||
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
"""flow planner tests"""
|
"""flow planner tests"""
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import RequestFactory, TestCase
|
from django.test import RequestFactory, TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
from passbook.flows.planner import FlowPlanner
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
|
||||||
from passbook.policies.types import PolicyResult
|
from passbook.policies.types import PolicyResult
|
||||||
from passbook.stages.dummy.models import DummyStage
|
from passbook.stages.dummy.models import DummyStage
|
||||||
|
|
||||||
|
@ -81,3 +83,24 @@ class TestFlowPlanner(TestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
TIME_NOW_MOCK.call_count, 2
|
TIME_NOW_MOCK.call_count, 2
|
||||||
) # When taking from cache, time is not measured
|
) # When taking from cache, time is not measured
|
||||||
|
|
||||||
|
def test_planner_default_context(self):
|
||||||
|
"""Test planner with default_context"""
|
||||||
|
flow = Flow.objects.create(
|
||||||
|
name="test-default-context",
|
||||||
|
slug="test-default-context",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
FlowStageBinding.objects.create(
|
||||||
|
flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User.objects.create(username="test-user")
|
||||||
|
request = self.request_factory.get(
|
||||||
|
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
|
)
|
||||||
|
request.user = user
|
||||||
|
planner = FlowPlanner(flow)
|
||||||
|
planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user})
|
||||||
|
key = cache_key(flow, user)
|
||||||
|
self.assertTrue(cache.get(key) is not None)
|
||||||
|
|
|
@ -34,7 +34,7 @@ class FlowExecutorView(View):
|
||||||
|
|
||||||
def setup(self, request: HttpRequest, flow_slug: str):
|
def setup(self, request: HttpRequest, flow_slug: str):
|
||||||
super().setup(request, flow_slug=flow_slug)
|
super().setup(request, flow_slug=flow_slug)
|
||||||
self.flow = get_object_or_404(Flow, slug=flow_slug)
|
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
|
||||||
|
|
||||||
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
||||||
"""When a flow is non-applicable check if user is on the correct domain"""
|
"""When a flow is non-applicable check if user is on the correct domain"""
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
"""passbook expression policy evaluator"""
|
"""passbook expression policy evaluator"""
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import HttpRequest
|
||||||
from jinja2 import Undefined
|
from jinja2 import Undefined
|
||||||
from jinja2.exceptions import TemplateSyntaxError
|
from jinja2.exceptions import TemplateSyntaxError
|
||||||
from jinja2.nativetypes import NativeEnvironment
|
from jinja2.nativetypes import NativeEnvironment
|
||||||
from requests import Session
|
from requests import Session
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_SSO
|
from passbook.flows.planner import PLAN_CONTEXT_SSO
|
||||||
from passbook.flows.views import SESSION_KEY_PLAN
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
from passbook.lib.utils.http import get_client_ip
|
from passbook.lib.utils.http import get_client_ip
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from passbook.core.models import User
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,12 +24,33 @@ class Evaluator:
|
||||||
|
|
||||||
_env: NativeEnvironment
|
_env: NativeEnvironment
|
||||||
|
|
||||||
|
_context: Dict[str, Any]
|
||||||
|
_messages: List[str]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._env = NativeEnvironment()
|
self._env = NativeEnvironment(
|
||||||
|
extensions=["jinja2.ext.do"],
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
line_statement_prefix=">",
|
||||||
|
)
|
||||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||||
# update docs/policies/expression/index.md
|
# update docs/policies/expression/index.md
|
||||||
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
|
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
|
||||||
self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace
|
self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace
|
||||||
|
self._env.globals["pb_message"] = self.jinja2_func_message
|
||||||
|
self._context = {
|
||||||
|
"pb_is_group_member": Evaluator.jinja2_func_is_group_member,
|
||||||
|
"pb_user_by": Evaluator.jinja2_func_user_by,
|
||||||
|
"pb_logger": get_logger(),
|
||||||
|
"requests": Session(),
|
||||||
|
}
|
||||||
|
self._messages = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def env(self) -> NativeEnvironment:
|
||||||
|
"""Access to our custom NativeEnvironment"""
|
||||||
|
return self._env
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
|
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
|
||||||
|
@ -43,56 +63,69 @@ class Evaluator:
|
||||||
return re.sub(regex, repl, value)
|
return re.sub(regex, repl, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def jinja2_func_is_group_member(user: "User", group_name: str) -> bool:
|
def jinja2_func_user_by(**filters) -> Optional[User]:
|
||||||
|
"""Get user by filters"""
|
||||||
|
users = User.objects.filter(**filters)
|
||||||
|
if users:
|
||||||
|
return users.first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def jinja2_func_is_group_member(user: User, group_name: str) -> bool:
|
||||||
"""Check if `user` is member of group with name `group_name`"""
|
"""Check if `user` is member of group with name `group_name`"""
|
||||||
return user.groups.filter(name=group_name).exists()
|
return user.groups.filter(name=group_name).exists()
|
||||||
|
|
||||||
def _get_expression_context(
|
def jinja2_func_message(self, message: str):
|
||||||
self, request: PolicyRequest, **kwargs
|
"""Wrapper to append to messages list, which is returned with PolicyResult"""
|
||||||
) -> Dict[str, Any]:
|
self._messages.append(message)
|
||||||
"""Return dictionary with additional global variables passed to expression"""
|
|
||||||
|
def set_policy_request(self, request: PolicyRequest):
|
||||||
|
"""Update context based on policy request (if http request is given, update that too)"""
|
||||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||||
# update docs/policies/expression/index.md
|
# update docs/policies/expression/index.md
|
||||||
kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member
|
self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
|
||||||
kwargs["pb_logger"] = get_logger()
|
self._context["request"] = request
|
||||||
kwargs["requests"] = Session()
|
|
||||||
kwargs["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
|
|
||||||
if request.http_request:
|
if request.http_request:
|
||||||
kwargs["pb_client_ip"] = (
|
self.set_http_request(request.http_request)
|
||||||
get_client_ip(request.http_request) or "255.255.255.255"
|
|
||||||
)
|
|
||||||
if SESSION_KEY_PLAN in request.http_request.session:
|
|
||||||
kwargs["pb_flow_plan"] = request.http_request.session[SESSION_KEY_PLAN]
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult:
|
def set_http_request(self, request: HttpRequest):
|
||||||
"""Parse and evaluate expression.
|
"""Update context based on http request"""
|
||||||
If the Expression evaluates to a list with 2 items, the first is used as passing bool and
|
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||||
the second as messages.
|
# update docs/policies/expression/index.md
|
||||||
If the Expression evaluates to a truthy-object, it is used as passing bool."""
|
self._context["pb_client_ip"] = (
|
||||||
|
get_client_ip(request.http_request) or "255.255.255.255"
|
||||||
|
)
|
||||||
|
self._context["request"] = request
|
||||||
|
if SESSION_KEY_PLAN in request.http_request.session:
|
||||||
|
self._context["pb_flow_plan"] = request.http_request.session[
|
||||||
|
SESSION_KEY_PLAN
|
||||||
|
]
|
||||||
|
|
||||||
|
def evaluate(self, expression_source: str) -> PolicyResult:
|
||||||
|
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
||||||
|
Messages can be added using 'do pb_message()'."""
|
||||||
try:
|
try:
|
||||||
expression = self._env.from_string(expression_source)
|
expression = self._env.from_string(expression_source.lstrip().rstrip())
|
||||||
except TemplateSyntaxError as exc:
|
except TemplateSyntaxError as exc:
|
||||||
return PolicyResult(False, str(exc))
|
return PolicyResult(False, str(exc))
|
||||||
try:
|
try:
|
||||||
result: Optional[Any] = expression.render(
|
result: Optional[Any] = expression.render(self._context)
|
||||||
request=request, **self._get_expression_context(request)
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
)
|
LOGGER.warning("Expression error", exc=exc)
|
||||||
|
return PolicyResult(False, str(exc))
|
||||||
|
else:
|
||||||
|
policy_result = PolicyResult(False)
|
||||||
|
policy_result.messages = tuple(self._messages)
|
||||||
if isinstance(result, Undefined):
|
if isinstance(result, Undefined):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Expression policy returned undefined",
|
"Expression policy returned undefined",
|
||||||
src=expression_source,
|
src=expression_source,
|
||||||
req=request,
|
req=self._context,
|
||||||
)
|
)
|
||||||
return PolicyResult(False)
|
policy_result.passing = False
|
||||||
if isinstance(result, (list, tuple)) and len(result) == 2:
|
|
||||||
return PolicyResult(*result)
|
|
||||||
if result:
|
if result:
|
||||||
return PolicyResult(bool(result))
|
policy_result.passing = bool(result)
|
||||||
return PolicyResult(False)
|
return policy_result
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
LOGGER.warning("Expression error", exc=exc)
|
|
||||||
return PolicyResult(False, str(exc))
|
|
||||||
|
|
||||||
def validate(self, expression: str):
|
def validate(self, expression: str):
|
||||||
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
|
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
|
||||||
|
@ -100,4 +133,4 @@ class Evaluator:
|
||||||
self._env.from_string(expression)
|
self._env.from_string(expression)
|
||||||
return True
|
return True
|
||||||
except TemplateSyntaxError as exc:
|
except TemplateSyntaxError as exc:
|
||||||
raise ValidationError("Expression Syntax Error") from exc
|
raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc
|
||||||
|
|
|
@ -16,7 +16,9 @@ class ExpressionPolicy(Policy):
|
||||||
|
|
||||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||||
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
|
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
|
||||||
return Evaluator().evaluate(self.expression, request)
|
evaluator = Evaluator()
|
||||||
|
evaluator.set_policy_request(request)
|
||||||
|
return evaluator.evaluate(self.expression)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
Evaluator().validate(self.expression)
|
Evaluator().validate(self.expression)
|
||||||
|
|
|
@ -17,13 +17,15 @@ class TestEvaluator(TestCase):
|
||||||
"""test simple value expression"""
|
"""test simple value expression"""
|
||||||
template = "True"
|
template = "True"
|
||||||
evaluator = Evaluator()
|
evaluator = Evaluator()
|
||||||
self.assertEqual(evaluator.evaluate(template, self.request).passing, True)
|
evaluator.set_policy_request(self.request)
|
||||||
|
self.assertEqual(evaluator.evaluate(template).passing, True)
|
||||||
|
|
||||||
def test_messages(self):
|
def test_messages(self):
|
||||||
"""test expression with message return"""
|
"""test expression with message return"""
|
||||||
template = "False, 'some message'"
|
template = '{% do pb_message("some message") %}False'
|
||||||
evaluator = Evaluator()
|
evaluator = Evaluator()
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("some message",))
|
self.assertEqual(result.messages, ("some message",))
|
||||||
|
|
||||||
|
@ -31,7 +33,8 @@ class TestEvaluator(TestCase):
|
||||||
"""test invalid syntax"""
|
"""test invalid syntax"""
|
||||||
template = "{%"
|
template = "{%"
|
||||||
evaluator = Evaluator()
|
evaluator = Evaluator()
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("tag name expected",))
|
self.assertEqual(result.messages, ("tag name expected",))
|
||||||
|
|
||||||
|
@ -39,7 +42,8 @@ class TestEvaluator(TestCase):
|
||||||
"""test undefined result"""
|
"""test undefined result"""
|
||||||
template = "{{ foo.bar }}"
|
template = "{{ foo.bar }}"
|
||||||
evaluator = Evaluator()
|
evaluator = Evaluator()
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("'foo' is undefined",))
|
self.assertEqual(result.messages, ("'foo' is undefined",))
|
||||||
|
|
||||||
|
|
|
@ -39,4 +39,6 @@ class PolicyResult:
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"<PolicyResult passing={self.passing}>"
|
if self.messages:
|
||||||
|
return f"PolicyResult passing={self.passing} messages={self.messages}"
|
||||||
|
return f"PolicyResult passing={self.passing}"
|
||||||
|
|
|
@ -16,6 +16,8 @@ class IdentificationStageSerializer(ModelSerializer):
|
||||||
"name",
|
"name",
|
||||||
"user_fields",
|
"user_fields",
|
||||||
"template",
|
"template",
|
||||||
|
"enrollment_flow",
|
||||||
|
"recovery_flow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-30 22:04
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_flows", "0002_default_flows"),
|
||||||
|
("passbook_stages_identification", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="identificationstage",
|
||||||
|
name="enrollment_flow",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Optional enrollment flow, which is linked at the bottom of the page.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||||
|
related_name="+",
|
||||||
|
to="passbook_flows.Flow",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="identificationstage",
|
||||||
|
name="recovery_flow",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Optional enrollment flow, which is linked at the bottom of the page.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||||
|
related_name="+",
|
||||||
|
to="passbook_flows.Flow",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from passbook.flows.models import Stage
|
from passbook.flows.models import Flow, Stage
|
||||||
|
|
||||||
|
|
||||||
class UserFields(models.TextChoices):
|
class UserFields(models.TextChoices):
|
||||||
|
@ -29,6 +29,29 @@ class IdentificationStage(Stage):
|
||||||
)
|
)
|
||||||
template = models.TextField(choices=Templates.choices)
|
template = models.TextField(choices=Templates.choices)
|
||||||
|
|
||||||
|
enrollment_flow = models.ForeignKey(
|
||||||
|
Flow,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="+",
|
||||||
|
default=None,
|
||||||
|
help_text=_(
|
||||||
|
"Optional enrollment flow, which is linked at the bottom of the page."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
recovery_flow = models.ForeignKey(
|
||||||
|
Flow,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="+",
|
||||||
|
default=None,
|
||||||
|
help_text=_(
|
||||||
|
"Optional enrollment flow, which is linked at the bottom of the page."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
type = "passbook.stages.identification.stage.IdentificationStageView"
|
type = "passbook.stages.identification.stage.IdentificationStageView"
|
||||||
form = "passbook.stages.identification.forms.IdentificationStageForm"
|
form = "passbook.stages.identification.forms.IdentificationStageForm"
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ from django.views.generic import FormView
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.models import Source, User
|
from passbook.core.models import Source, User
|
||||||
from passbook.flows.models import FlowDesignation
|
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
from passbook.flows.stage import StageView
|
from passbook.flows.stage import StageView
|
||||||
from passbook.stages.identification.forms import IdentificationForm
|
from passbook.stages.identification.forms import IdentificationForm
|
||||||
|
@ -34,18 +33,17 @@ class IdentificationStageView(FormView, StageView):
|
||||||
return [current_stage.template]
|
return [current_stage.template]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
current_stage: IdentificationStage = self.executor.current_stage
|
||||||
# Check for related enrollment and recovery flow, add URL to view
|
# Check for related enrollment and recovery flow, add URL to view
|
||||||
enrollment_flow = self.executor.flow.related_flow(FlowDesignation.ENROLLMENT)
|
if current_stage.enrollment_flow:
|
||||||
if enrollment_flow:
|
|
||||||
kwargs["enroll_url"] = reverse(
|
kwargs["enroll_url"] = reverse(
|
||||||
"passbook_flows:flow-executor-shell",
|
"passbook_flows:flow-executor-shell",
|
||||||
kwargs={"flow_slug": enrollment_flow.slug},
|
kwargs={"flow_slug": current_stage.enrollment_flow.slug},
|
||||||
)
|
)
|
||||||
recovery_flow = self.executor.flow.related_flow(FlowDesignation.RECOVERY)
|
if current_stage.recovery_flow:
|
||||||
if recovery_flow:
|
|
||||||
kwargs["recovery_url"] = reverse(
|
kwargs["recovery_url"] = reverse(
|
||||||
"passbook_flows:flow-executor-shell",
|
"passbook_flows:flow-executor-shell",
|
||||||
kwargs={"flow_slug": recovery_flow.slug},
|
kwargs={"flow_slug": current_stage.recovery_flow.slug},
|
||||||
)
|
)
|
||||||
kwargs["primary_action"] = _("Log in")
|
kwargs["primary_action"] = _("Log in")
|
||||||
|
|
||||||
|
|
|
@ -1,72 +1,29 @@
|
||||||
{% extends 'base/skeleton.html' %}
|
|
||||||
|
|
||||||
{% load static %}
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block body %}
|
<header class="pf-c-login__main-header">
|
||||||
<div class="pf-c-background-image">
|
<h1 class="pf-c-title pf-m-3xl">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
|
{% trans 'Trouble Logging In?' %}
|
||||||
<filter id="image_overlay">
|
</h1>
|
||||||
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
|
</header>
|
||||||
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
|
<div class="pf-c-login__main-body">
|
||||||
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
|
{% block card %}
|
||||||
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
|
<form method="POST" class="pf-c-form">
|
||||||
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
|
{% block above_form %}
|
||||||
<feFuncA type="table" tableValues="0 1"></feFuncA>
|
{% endblock %}
|
||||||
</feComponentTransfer>
|
|
||||||
</filter>
|
{% include 'partials/form.html' %}
|
||||||
</svg>
|
|
||||||
|
{% block beneath_form %}
|
||||||
|
{% endblock %}
|
||||||
|
<div class="pf-c-form__group pf-m-action">
|
||||||
|
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'partials/messages.html' %}
|
<footer class="pf-c-login__main-footer">
|
||||||
<div class="pf-c-login">
|
{% if config.login.subtext %}
|
||||||
<div class="pf-c-login__container">
|
<p>{{ config.login.subtext }}</p>
|
||||||
<header class="pf-c-login__header">
|
{% endif %}
|
||||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
|
</footer>
|
||||||
alt="passbook icon" />
|
|
||||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
|
|
||||||
alt="passbook branding" />
|
|
||||||
</header>
|
|
||||||
<main class="pf-c-login__main">
|
|
||||||
<header class="pf-c-login__main-header">
|
|
||||||
<h1 class="pf-c-title pf-m-3xl">
|
|
||||||
{% trans 'Trouble Logging In?' %}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<div class="pf-c-login__main-body">
|
|
||||||
{% block card %}
|
|
||||||
<form method="POST" class="pf-c-form">
|
|
||||||
{% block above_form %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% include 'partials/form.html' %}
|
|
||||||
|
|
||||||
{% block beneath_form %}
|
|
||||||
{% endblock %}
|
|
||||||
<div class="pf-c-form__group pf-m-action">
|
|
||||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
<footer class="pf-c-login__main-footer">
|
|
||||||
{% if config.login.subtext %}
|
|
||||||
<p>{{ config.login.subtext }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
<footer class="pf-c-login__footer">
|
|
||||||
<ul class="pf-c-list pf-m-inline">
|
|
||||||
<li>
|
|
||||||
<a href="https://beryju.github.io/passbook/">{% trans 'Documentation' %}</a>
|
|
||||||
</li>
|
|
||||||
{% config 'passbook.footer_links' as footer_links %}
|
|
||||||
{% for link in footer_links %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
|
@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase):
|
||||||
slug="unique-enrollment-string",
|
slug="unique-enrollment-string",
|
||||||
designation=FlowDesignation.ENROLLMENT,
|
designation=FlowDesignation.ENROLLMENT,
|
||||||
)
|
)
|
||||||
|
self.stage.enrollment_flow = flow
|
||||||
|
self.stage.save()
|
||||||
FlowStageBinding.objects.create(
|
FlowStageBinding.objects.create(
|
||||||
flow=flow, stage=self.stage, order=0,
|
flow=flow, stage=self.stage, order=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
reverse(
|
||||||
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(flow.name, response.rendered_content)
|
self.assertIn(flow.slug, response.rendered_content)
|
||||||
|
|
||||||
def test_recovery_flow(self):
|
def test_recovery_flow(self):
|
||||||
"""Test that recovery flow is linked correctly"""
|
"""Test that recovery flow is linked correctly"""
|
||||||
|
@ -102,12 +106,16 @@ class TestIdentificationStage(TestCase):
|
||||||
slug="unique-recovery-string",
|
slug="unique-recovery-string",
|
||||||
designation=FlowDesignation.RECOVERY,
|
designation=FlowDesignation.RECOVERY,
|
||||||
)
|
)
|
||||||
|
self.stage.recovery_flow = flow
|
||||||
|
self.stage.save()
|
||||||
FlowStageBinding.objects.create(
|
FlowStageBinding.objects.create(
|
||||||
flow=flow, stage=self.stage, order=0,
|
flow=flow, stage=self.stage, order=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
reverse(
|
||||||
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(flow.name, response.rendered_content)
|
self.assertIn(flow.slug, response.rendered_content)
|
||||||
|
|
|
@ -64,4 +64,4 @@ class PromptForm(forms.Form):
|
||||||
engine.build()
|
engine.build()
|
||||||
result = engine.result
|
result = engine.result
|
||||||
if not result.passing:
|
if not result.passing:
|
||||||
raise forms.ValidationError(result.messages)
|
raise forms.ValidationError(list(result.messages))
|
||||||
|
|
14
swagger.yaml
14
swagger.yaml
|
@ -5919,6 +5919,20 @@ definitions:
|
||||||
enum:
|
enum:
|
||||||
- stages/identification/login.html
|
- stages/identification/login.html
|
||||||
- stages/identification/recovery.html
|
- stages/identification/recovery.html
|
||||||
|
enrollment_flow:
|
||||||
|
title: Enrollment flow
|
||||||
|
description: Optional enrollment flow, which is linked at the bottom of the
|
||||||
|
page.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
|
recovery_flow:
|
||||||
|
title: Recovery flow
|
||||||
|
description: Optional enrollment flow, which is linked at the bottom of the
|
||||||
|
page.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
InvitationStage:
|
InvitationStage:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
|
|
Reference in New Issue