diff --git a/passbook/admin/templates/administration/stage_prompt/list.html b/passbook/admin/templates/administration/stage_prompt/list.html
index 5c13689b4..5aff63c69 100644
--- a/passbook/admin/templates/administration/stage_prompt/list.html
+++ b/passbook/admin/templates/administration/stage_prompt/list.html
@@ -29,6 +29,7 @@
{% trans 'Field' %} |
{% trans 'Label' %} |
{% trans 'Type' %} |
+ {% trans 'Order' %} |
{% trans 'Flows' %} |
|
@@ -51,6 +52,11 @@
{{ prompt.type }}
+
+
+ {{ prompt.order }}
+
+ |
{% for flow in prompt.flow_set.all %}
diff --git a/passbook/admin/views/policies.py b/passbook/admin/views/policies.py
index e8a009bd2..76196e70e 100644
--- a/passbook/admin/views/policies.py
+++ b/passbook/admin/views/policies.py
@@ -1,11 +1,15 @@
"""passbook Policy administration"""
+from typing import Any, Dict
+
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 Http404
+from django.db.models import QuerySet
+from django.forms import Form
+from django.http import Http404, HttpRequest, HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.views.generic import DeleteView, FormView, ListView, UpdateView
@@ -15,8 +19,8 @@ from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.forms.policies import PolicyTestForm
from passbook.lib.utils.reflection import all_subclasses, path_to_class
from passbook.lib.views import CreateAssignPermView
-from passbook.policies.engine import PolicyEngine
-from passbook.policies.models import Policy
+from passbook.policies.models import Policy, PolicyBinding
+from passbook.policies.process import PolicyProcess, PolicyRequest
class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
@@ -25,14 +29,14 @@ class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
model = Policy
permission_required = "passbook_policies.view_policy"
paginate_by = 10
- ordering = "order"
+ ordering = "name"
template_name = "administration/policy/list.html"
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs["types"] = {x.__name__: x for x in all_subclasses(Policy)}
return super().get_context_data(**kwargs)
- def get_queryset(self):
+ def get_queryset(self) -> QuerySet:
return super().get_queryset().select_subclasses()
@@ -51,14 +55,14 @@ class PolicyCreateView(
success_url = reverse_lazy("passbook_admin:policies")
success_message = _("Successfully created Policy")
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
form_cls = self.get_form_class()
if hasattr(form_cls, "template_name"):
kwargs["base_template"] = form_cls.template_name
return kwargs
- def get_form_class(self):
+ def get_form_class(self) -> Form:
policy_type = self.request.GET.get("type")
try:
model = next(x for x in all_subclasses(Policy) if x.__name__ == policy_type)
@@ -79,19 +83,19 @@ class PolicyUpdateView(
success_url = reverse_lazy("passbook_admin:policies")
success_message = _("Successfully updated Policy")
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
form_cls = self.get_form_class()
if hasattr(form_cls, "template_name"):
kwargs["base_template"] = form_cls.template_name
return kwargs
- def get_form_class(self):
+ def get_form_class(self) -> Form:
form_class_path = self.get_object().form
form_class = path_to_class(form_class_path)
return form_class
- def get_object(self, queryset=None):
+ def get_object(self, queryset=None) -> Policy:
return (
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
@@ -109,12 +113,12 @@ class PolicyDeleteView(
success_url = reverse_lazy("passbook_admin:policies")
success_message = _("Successfully deleted Policy")
- def get_object(self, queryset=None):
+ def get_object(self, queryset=None) -> Policy:
return (
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
- def delete(self, request, *args, **kwargs):
+ def delete(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)
@@ -128,27 +132,30 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo
template_name = "administration/policy/test.html"
object = None
- def get_object(self, queryset=None):
+ def get_object(self, queryset=None) -> QuerySet:
return (
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs["policy"] = self.get_object()
return super().get_context_data(**kwargs)
- def post(self, *args, **kwargs):
+ def post(self, *args, **kwargs) -> HttpResponse:
self.object = self.get_object()
return super().post(*args, **kwargs)
- def form_valid(self, form):
+ def form_valid(self, form: PolicyTestForm) -> HttpResponse:
policy = self.get_object()
user = form.cleaned_data.get("user")
- policy_engine = PolicyEngine([policy], user, self.request)
- policy_engine.use_cache = False
- policy_engine.build()
- result = policy_engine.passing
- if result:
+
+ p_request = PolicyRequest(user)
+ p_request.http_request = self.request
+ p_request.context = form.cleaned_data
+
+ proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
+ result = proc.execute()
+ if result.passing:
messages.success(self.request, _("User successfully passed policy."))
else:
messages.error(self.request, _("User didn't pass policy."))
diff --git a/passbook/admin/views/stages_prompts.py b/passbook/admin/views/stages_prompts.py
index bb833d4a1..5614f206b 100644
--- a/passbook/admin/views/stages_prompts.py
+++ b/passbook/admin/views/stages_prompts.py
@@ -20,7 +20,7 @@ class PromptListView(LoginRequiredMixin, PermissionListMixin, ListView):
model = Prompt
permission_required = "passbook_stages_prompt.view_prompt"
- ordering = "field_key"
+ ordering = "order"
paginate_by = 40
template_name = "administration/stage_prompt/list.html"
diff --git a/passbook/core/migrations/0002_default_user.py b/passbook/core/migrations/0002_default_user.py
new file mode 100644
index 000000000..66e6a2d3e
--- /dev/null
+++ b/passbook/core/migrations/0002_default_user.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.0.6 on 2020-05-23 16:40
+
+from django.apps.registry import Apps
+from django.db import migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ # User = apps.get_model("passbook_core", "User")
+ from passbook.core.models import User
+
+ pbadmin = User.objects.create(
+ username="pbadmin", email="root@localhost", # password="pbadmin"
+ )
+ pbadmin.set_password("pbadmin") # nosec
+ pbadmin.is_superuser = True
+ pbadmin.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_core", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_user),
+ ]
diff --git a/passbook/core/models.py b/passbook/core/models.py
index 08ad322dc..13517ea0e 100644
--- a/passbook/core/models.py
+++ b/passbook/core/models.py
@@ -13,7 +13,6 @@ from django.utils.translation import gettext_lazy as _
from guardian.mixins import GuardianUserMixin
from jinja2 import Undefined
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
-from jinja2.nativetypes import NativeEnvironment
from model_utils.managers import InheritanceManager
from structlog import get_logger
@@ -24,7 +23,6 @@ from passbook.lib.models import CreatedUpdatedModel
from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger()
-NATIVE_ENVIRONMENT = NativeEnvironment()
def default_token_duration():
@@ -208,8 +206,11 @@ class PropertyMapping(models.Model):
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context."""
+ from passbook.policies.expression.evaluator import Evaluator
+
+ evaluator = Evaluator()
try:
- expression = NATIVE_ENVIRONMENT.from_string(self.expression)
+ expression = evaluator.env.from_string(self.expression)
except TemplateSyntaxError as exc:
raise PropertyMappingExpressionException from exc
try:
@@ -221,8 +222,11 @@ class PropertyMapping(models.Model):
raise PropertyMappingExpressionException from exc
def save(self, *args, **kwargs):
+ from passbook.policies.expression.evaluator import Evaluator
+
+ evaluator = Evaluator()
try:
- NATIVE_ENVIRONMENT.from_string(self.expression)
+ evaluator.env.from_string(self.expression)
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
return super().save(*args, **kwargs)
diff --git a/passbook/core/signals.py b/passbook/core/signals.py
index c3a50b4cc..74b6b49f1 100644
--- a/passbook/core/signals.py
+++ b/passbook/core/signals.py
@@ -1,28 +1,7 @@
"""passbook core signals"""
-from django.core.cache import cache
from django.core.signals import Signal
-from django.db.models.signals import post_save
-from django.dispatch import receiver
-from structlog import get_logger
-
-LOGGER = get_logger()
user_signed_up = Signal(providing_args=["request", "user"])
invitation_created = Signal(providing_args=["request", "invitation"])
invitation_used = Signal(providing_args=["request", "invitation", "user"])
password_changed = Signal(providing_args=["user", "password"])
-
-
-@receiver(post_save)
-# pylint: disable=unused-argument
-def invalidate_policy_cache(sender, instance, **_):
- """Invalidate Policy cache when policy is updated"""
- from passbook.policies.models import Policy
- from passbook.policies.process import cache_key
-
- if isinstance(instance, Policy):
- LOGGER.debug("Invalidating policy cache", policy=instance)
- prefix = cache_key(instance) + "*"
- keys = cache.keys(prefix)
- cache.delete_many(keys)
- LOGGER.debug("Deleted %d keys", len(keys))
diff --git a/passbook/core/templates/partials/form.html b/passbook/core/templates/partials/form.html
index 83a6e8ae3..efd249d08 100644
--- a/passbook/core/templates/partials/form.html
+++ b/passbook/core/templates/partials/form.html
@@ -2,6 +2,13 @@
{% load i18n %}
{% csrf_token %}
+{% if form.non_field_errors %}
+
+{% endif %}
{% for field in form %}
diff --git a/passbook/core/views/access.py b/passbook/core/views/access.py
index ff3888645..c2e07dd19 100644
--- a/passbook/core/views/access.py
+++ b/passbook/core/views/access.py
@@ -1,6 +1,4 @@
"""passbook access helper classes"""
-from typing import List, Tuple
-
from django.contrib import messages
from django.http import HttpRequest
from django.utils.translation import gettext as _
@@ -8,6 +6,7 @@ from structlog import get_logger
from passbook.core.models import Application, Provider, User
from passbook.policies.engine import PolicyEngine
+from passbook.policies.types import PolicyResult
LOGGER = get_logger()
@@ -33,9 +32,7 @@ class AccessMixin:
)
raise exc
- def user_has_access(
- self, application: Application, user: User
- ) -> Tuple[bool, List[str]]:
+ def user_has_access(self, application: Application, user: User) -> PolicyResult:
"""Check if user has access to application."""
LOGGER.debug("Checking permissions", user=user, application=application)
policy_engine = PolicyEngine(application.policies.all(), user, self.request)
diff --git a/passbook/core/views/user.py b/passbook/core/views/user.py
index bb575ee8e..4bc02b27f 100644
--- a/passbook/core/views/user.py
+++ b/passbook/core/views/user.py
@@ -1,4 +1,6 @@
"""passbook core user views"""
+from typing import Any, Dict
+
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
@@ -6,6 +8,7 @@ from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from passbook.core.forms.users import UserDetailForm
+from passbook.flows.models import Flow, FlowDesignation
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
@@ -19,3 +22,11 @@ class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
def get_object(self):
return self.request.user
+
+ def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ unenrollment_flow = Flow.with_policy(
+ self.request, designation=FlowDesignation.UNRENOLLMENT
+ )
+ kwargs["unenrollment_enabled"] = bool(unenrollment_flow)
+ return kwargs
diff --git a/passbook/crypto/forms.py b/passbook/crypto/forms.py
index babf25919..79d5f7100 100644
--- a/passbook/crypto/forms.py
+++ b/passbook/crypto/forms.py
@@ -34,7 +34,6 @@ class CertificateKeyPairForm(forms.ModelForm):
password=None,
backend=default_backend(),
)
- load_pem_x509_certificate(key_data.encode("utf-8"), default_backend())
except ValueError:
raise forms.ValidationError("Unable to load private key.")
return key_data
diff --git a/passbook/crypto/migrations/0002_create_self_signed_kp.py b/passbook/crypto/migrations/0002_create_self_signed_kp.py
new file mode 100644
index 000000000..66239b816
--- /dev/null
+++ b/passbook/crypto/migrations/0002_create_self_signed_kp.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.0.6 on 2020-05-23 23:07
+
+from django.db import migrations
+
+
+def create_self_signed(apps, schema_editor):
+ CertificateKeyPair = apps.get_model("passbook_crypto", "CertificateKeyPair")
+ db_alias = schema_editor.connection.alias
+ from passbook.crypto.builder import CertificateBuilder
+
+ builder = CertificateBuilder()
+ builder.build()
+ CertificateKeyPair.objects.using(db_alias).create(
+ name="passbook Self-signed Certificate",
+ certificate_data=builder.certificate,
+ key_data=builder.private_key,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_crypto", "0001_initial"),
+ ]
+
+ operations = [migrations.RunPython(create_self_signed)]
diff --git a/passbook/flows/models.py b/passbook/flows/models.py
index de0147c99..ffb194386 100644
--- a/passbook/flows/models.py
+++ b/passbook/flows/models.py
@@ -3,12 +3,16 @@ from typing import Optional
from uuid import uuid4
from django.db import models
+from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager
+from structlog import get_logger
from passbook.core.types import UIUserSettings
from passbook.policies.models import PolicyBindingModel
+LOGGER = get_logger()
+
class FlowDesignation(models.TextChoices):
"""Designation of what a Flow should be used for. At a later point, this
@@ -62,10 +66,29 @@ class Flow(PolicyBindingModel):
PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+"
)
- def related_flow(self, designation: str) -> Optional["Flow"]:
+ @staticmethod
+ def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]:
+ """Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
+ from passbook.policies.engine import PolicyEngine
+
+ flows = Flow.objects.filter(**flow_filter)
+ for flow in flows:
+ engine = PolicyEngine(flow, request.user, request)
+ engine.build()
+ result = engine.result
+ if result.passing:
+ LOGGER.debug("with_policy: flow passing", flow=flow)
+ return flow
+ LOGGER.warning(
+ "with_policy: flow not passing", flow=flow, messages=result.messages
+ )
+ LOGGER.debug("with_policy: no flow found", filters=flow_filter)
+ return None
+
+ def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]:
"""Get a related flow with `designation`. Currently this only queries
Flows by `designation`, but will eventually use `self` for related lookups."""
- return Flow.objects.filter(designation=designation).first()
+ return Flow.with_policy(request, designation=designation)
def __str__(self) -> str:
return f"Flow {self.name} ({self.slug})"
diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py
index a2d0dc05f..262279434 100644
--- a/passbook/flows/planner.py
+++ b/passbook/flows/planner.py
@@ -1,7 +1,7 @@
"""Flows Planner"""
from dataclasses import dataclass, field
from time import time
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional
from django.core.cache import cache
from django.http import HttpRequest
@@ -51,22 +51,12 @@ class FlowPlanner:
self.use_cache = True
self.flow = flow
- def _check_flow_root_policies(self, request: HttpRequest) -> Tuple[bool, List[str]]:
- engine = PolicyEngine(self.flow.policies.all(), request.user, request)
- engine.build()
- return engine.result
-
def plan(
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
) -> FlowPlan:
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
and return ordered list"""
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
- # First off, check the flow's direct policy bindings
- # to make sure the user even has access to the flow
- root_passing, root_passing_messages = self._check_flow_root_policies(request)
- if not root_passing:
- raise FlowNonApplicableException(root_passing_messages)
# Bit of a workaround here, if there is a pending user set in the default context
# we use that user for our cache key
# to make sure they don't get the generic response
@@ -74,14 +64,24 @@ class FlowPlanner:
user = default_context[PLAN_CONTEXT_PENDING_USER]
else:
user = request.user
+ # First off, check the flow's direct policy bindings
+ # to make sure the user even has access to the flow
+ engine = PolicyEngine(self.flow, user, request)
+ if default_context:
+ engine.request.context = default_context
+ engine.build()
+ result = engine.result
+ if not result.passing:
+ raise FlowNonApplicableException(result.messages)
+ # User is passing so far, check if we have a cached plan
cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None)
if cached_plan and self.use_cache:
LOGGER.debug(
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
)
- LOGGER.debug(cached_plan)
return cached_plan
+ LOGGER.debug("f(plan): building plan", flow=self.flow)
plan = self._build_plan(user, request, default_context)
cache.set(cache_key(self.flow, user), plan)
if not plan.stages:
@@ -106,11 +106,10 @@ class FlowPlanner:
.select_related()
):
binding = stage.flowstagebinding_set.get(flow__pk=self.flow.pk)
- engine = PolicyEngine(binding.policies.all(), user, request)
+ engine = PolicyEngine(binding, user, request)
engine.request.context = plan.context
engine.build()
- passing, _ = engine.result
- if passing:
+ if engine.passing:
LOGGER.debug("f(plan): Stage passing", stage=stage, flow=self.flow)
plan.stages.append(stage)
end_time = time()
diff --git a/passbook/flows/templates/flows/shell.html b/passbook/flows/templates/flows/shell.html
index 046f3c685..be6b22724 100644
--- a/passbook/flows/templates/flows/shell.html
+++ b/passbook/flows/templates/flows/shell.html
@@ -138,11 +138,10 @@ const loadFormCode = () => {
newScript.src = script.src;
document.head.appendChild(newScript);
});
-}
+};
const setFormSubmitHandlers = () => {
document.querySelectorAll("#flow-body form").forEach(form => {
console.log(`Setting action for form ${form}`);
- // debugger;
form.action = flowBodyUrl;
console.log(`Adding handler for form ${form}`);
form.addEventListener('submit', (e) => {
diff --git a/passbook/flows/tests/test_planner.py b/passbook/flows/tests/test_planner.py
index 79c0b6bdb..af6ae98fa 100644
--- a/passbook/flows/tests/test_planner.py
+++ b/passbook/flows/tests/test_planner.py
@@ -1,16 +1,19 @@
"""flow planner tests"""
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, PropertyMock, patch
+from django.core.cache import cache
from django.shortcuts import reverse
from django.test import RequestFactory, TestCase
from guardian.shortcuts import get_anonymous_user
+from passbook.core.models import User
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
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.stages.dummy.models import DummyStage
-POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],))
+POLICY_RESULT_MOCK = PropertyMock(return_value=PolicyResult(False))
TIME_NOW_MOCK = MagicMock(return_value=3)
@@ -37,8 +40,7 @@ class TestFlowPlanner(TestCase):
planner.plan(request)
@patch(
- "passbook.flows.planner.FlowPlanner._check_flow_root_policies",
- POLICY_RESULT_MOCK,
+ "passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
)
def test_non_applicable_plan(self):
"""Test that empty plan raises exception"""
@@ -80,3 +82,24 @@ class TestFlowPlanner(TestCase):
self.assertEqual(
TIME_NOW_MOCK.call_count, 2
) # 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)
diff --git a/passbook/flows/tests/test_views.py b/passbook/flows/tests/test_views.py
index f9979e4d9..cacbe2004 100644
--- a/passbook/flows/tests/test_views.py
+++ b/passbook/flows/tests/test_views.py
@@ -1,5 +1,5 @@
"""flow views tests"""
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, PropertyMock, patch
from django.shortcuts import reverse
from django.test import Client, TestCase
@@ -9,9 +9,10 @@ from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import FlowPlan
from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
from passbook.lib.config import CONFIG
+from passbook.policies.types import PolicyResult
from passbook.stages.dummy.models import DummyStage
-POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],))
+POLICY_RESULT_MOCK = PropertyMock(return_value=PolicyResult(False))
class TestFlowExecutor(TestCase):
@@ -44,8 +45,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(cancel_mock.call_count, 1)
@patch(
- "passbook.flows.planner.FlowPlanner._check_flow_root_policies",
- POLICY_RESULT_MOCK,
+ "passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
)
def test_invalid_non_applicable_flow(self):
"""Tests that a non-applicable flow returns the correct error message"""
diff --git a/passbook/flows/views.py b/passbook/flows/views.py
index 37380a90c..5c8c71f11 100644
--- a/passbook/flows/views.py
+++ b/passbook/flows/views.py
@@ -1,7 +1,7 @@
"""passbook multi-stage authentication engine"""
from typing import Any, Dict, Optional
-from django.http import HttpRequest, HttpResponse
+from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
@@ -34,7 +34,7 @@ class FlowExecutorView(View):
def setup(self, request: HttpRequest, flow_slug: str):
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:
"""When a flow is non-applicable check if user is on the correct domain"""
@@ -164,7 +164,9 @@ class ToDefaultFlow(View):
designation: Optional[FlowDesignation] = None
def dispatch(self, request: HttpRequest) -> HttpResponse:
- flow = get_object_or_404(Flow, designation=self.designation)
+ flow = Flow.with_policy(request, designation=self.designation)
+ if not flow:
+ raise Http404
# If user already has a pending plan, clear it so we don't have to later.
if SESSION_KEY_PLAN in self.request.session:
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
diff --git a/passbook/lib/models.py b/passbook/lib/models.py
index d6036dd0e..0966dd8ac 100644
--- a/passbook/lib/models.py
+++ b/passbook/lib/models.py
@@ -1,5 +1,6 @@
"""Generic models"""
from django.db import models
+from model_utils.managers import InheritanceManager
class CreatedUpdatedModel(models.Model):
@@ -10,3 +11,27 @@ class CreatedUpdatedModel(models.Model):
class Meta:
abstract = True
+
+
+class InheritanceAutoManager(InheritanceManager):
+ """Object manager which automatically selects the subclass"""
+
+ def get_queryset(self):
+ return super().get_queryset().select_subclasses()
+
+
+class InheritanceForwardManyToOneDescriptor(
+ models.fields.related.ForwardManyToOneDescriptor
+):
+ """Forward ManyToOne Descriptor that selects subclass. Requires InheritanceAutoManager."""
+
+ def get_queryset(self, **hints):
+ return self.field.remote_field.model.objects.db_manager(
+ hints=hints
+ ).select_subclasses()
+
+
+class InheritanceForeignKey(models.ForeignKey):
+ """Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor"""
+
+ forward_related_accessor_class = InheritanceForwardManyToOneDescriptor
diff --git a/passbook/policies/api.py b/passbook/policies/api.py
index ebae9a3a0..27fd129ac 100644
--- a/passbook/policies/api.py
+++ b/passbook/policies/api.py
@@ -12,7 +12,7 @@ class PolicyBindingSerializer(ModelSerializer):
class Meta:
model = PolicyBinding
- fields = ["policy", "target", "enabled", "order"]
+ fields = ["policy", "target", "enabled", "order", "timeout"]
class PolicyBindingViewSet(ModelViewSet):
diff --git a/passbook/policies/apps.py b/passbook/policies/apps.py
index 5795355b6..946f84609 100644
--- a/passbook/policies/apps.py
+++ b/passbook/policies/apps.py
@@ -1,4 +1,6 @@
"""passbook policies app config"""
+from importlib import import_module
+
from django.apps import AppConfig
@@ -8,3 +10,7 @@ class PassbookPoliciesConfig(AppConfig):
name = "passbook.policies"
label = "passbook_policies"
verbose_name = "passbook Policies"
+
+ def ready(self):
+ """Load source_types from config file"""
+ import_module("passbook.policies.signals")
diff --git a/passbook/policies/engine.py b/passbook/policies/engine.py
index 24aaae59f..5db4f8cfc 100644
--- a/passbook/policies/engine.py
+++ b/passbook/policies/engine.py
@@ -1,14 +1,14 @@
"""passbook policy engine"""
from multiprocessing import Pipe, set_start_method
from multiprocessing.connection import Connection
-from typing import List, Optional, Tuple
+from typing import List, Optional
from django.core.cache import cache
from django.http import HttpRequest
from structlog import get_logger
from passbook.core.models import User
-from passbook.policies.models import Policy
+from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel
from passbook.policies.process import PolicyProcess, cache_key
from passbook.policies.types import PolicyRequest, PolicyResult
@@ -24,12 +24,14 @@ class PolicyProcessInfo:
process: PolicyProcess
connection: Connection
result: Optional[PolicyResult]
- policy: Policy
+ binding: PolicyBinding
- def __init__(self, process: PolicyProcess, connection: Connection, policy: Policy):
+ def __init__(
+ self, process: PolicyProcess, connection: Connection, binding: PolicyBinding
+ ):
self.process = process
self.connection = connection
- self.policy = policy
+ self.binding = binding
self.result = None
@@ -37,68 +39,84 @@ class PolicyEngine:
"""Orchestrate policy checking, launch tasks and return result"""
use_cache: bool = True
- policies: List[Policy] = []
request: PolicyRequest
+ __pbm: PolicyBindingModel
__cached_policies: List[PolicyResult]
__processes: List[PolicyProcessInfo]
- def __init__(self, policies, user: User, request: HttpRequest = None):
- self.policies = policies
+ def __init__(
+ self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None
+ ):
+ if not isinstance(pbm, PolicyBindingModel):
+ raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
+ self.__pbm = pbm
self.request = PolicyRequest(user)
if request:
self.request.http_request = request
self.__cached_policies = []
self.__processes = []
- def _select_subclasses(self) -> List[Policy]:
+ def _iter_bindings(self) -> List[PolicyBinding]:
"""Make sure all Policies are their respective classes"""
- return (
- Policy.objects.filter(pk__in=[x.pk for x in self.policies])
- .select_subclasses()
- .order_by("order")
+ return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by(
+ "order"
)
+ def _check_policy_type(self, policy: Policy):
+ """Check policy type, make sure it's not the root class as that has no logic implemented"""
+ # policy_type = type(policy)
+ if policy.__class__ == Policy:
+ raise TypeError(f"Policy '{policy}' is root type")
+
def build(self) -> "PolicyEngine":
"""Build task group"""
- for policy in self._select_subclasses():
- cached_policy = cache.get(cache_key(policy, self.request.user), None)
+ for binding in self._iter_bindings():
+ self._check_policy_type(binding.policy)
+ key = cache_key(binding, self.request)
+ cached_policy = cache.get(key, None)
if cached_policy and self.use_cache:
- LOGGER.debug("P_ENG: Taking result from cache", policy=policy)
+ LOGGER.debug(
+ "P_ENG: Taking result from cache",
+ policy=binding.policy,
+ cache_key=key,
+ )
self.__cached_policies.append(cached_policy)
continue
- LOGGER.debug("P_ENG: Evaluating policy", policy=policy)
+ LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy)
our_end, task_end = Pipe(False)
- task = PolicyProcess(policy, self.request, task_end)
- LOGGER.debug("P_ENG: Starting Process", policy=policy)
+ task = PolicyProcess(binding, self.request, task_end)
+ LOGGER.debug("P_ENG: Starting Process", policy=binding.policy)
task.start()
self.__processes.append(
- PolicyProcessInfo(process=task, connection=our_end, policy=policy)
+ PolicyProcessInfo(process=task, connection=our_end, binding=binding)
)
# If all policies are cached, we have an empty list here.
for proc_info in self.__processes:
- proc_info.process.join(proc_info.policy.timeout)
+ proc_info.process.join(proc_info.binding.timeout)
# Only call .recv() if no result is saved, otherwise we just deadlock here
if not proc_info.result:
proc_info.result = proc_info.connection.recv()
return self
@property
- def result(self) -> Tuple[bool, List[str]]:
+ def result(self) -> PolicyResult:
"""Get policy-checking result"""
messages: List[str] = []
process_results: List[PolicyResult] = [
x.result for x in self.__processes if x.result
]
for result in process_results + self.__cached_policies:
- LOGGER.debug("P_ENG: result", passing=result.passing)
+ LOGGER.debug(
+ "P_ENG: result", passing=result.passing, messages=result.messages
+ )
if result.messages:
messages += result.messages
if not result.passing:
- return False, messages
- return True, messages
+ return PolicyResult(False, *messages)
+ return PolicyResult(True, *messages)
@property
def passing(self) -> bool:
"""Only get true/false if user passes"""
- return self.result[0]
+ return self.result.passing
diff --git a/passbook/policies/expression/evaluator.py b/passbook/policies/expression/evaluator.py
index 2b31f4671..01ed3b7ae 100644
--- a/passbook/policies/expression/evaluator.py
+++ b/passbook/policies/expression/evaluator.py
@@ -1,22 +1,21 @@
"""passbook expression policy evaluator"""
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.http import HttpRequest
from jinja2 import Undefined
-from jinja2.exceptions import TemplateSyntaxError, UndefinedError
+from jinja2.exceptions import TemplateSyntaxError
from jinja2.nativetypes import NativeEnvironment
from requests import Session
from structlog import get_logger
+from passbook.core.models import User
from passbook.flows.planner import PLAN_CONTEXT_SSO
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.http import get_client_ip
from passbook.policies.types import PolicyRequest, PolicyResult
-if TYPE_CHECKING:
- from passbook.core.models import User
-
LOGGER = get_logger()
@@ -25,12 +24,33 @@ class Evaluator:
_env: NativeEnvironment
+ _context: Dict[str, Any]
+ _messages: List[str]
+
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 docs/policies/expression/index.md
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
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
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
@@ -43,55 +63,69 @@ class Evaluator:
return re.sub(regex, repl, value)
@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`"""
return user.groups.filter(name=group_name).exists()
- def _get_expression_context(
- self, request: PolicyRequest, **kwargs
- ) -> Dict[str, Any]:
- """Return dictionary with additional global variables passed to expression"""
+ def jinja2_func_message(self, message: str):
+ """Wrapper to append to messages list, which is returned with PolicyResult"""
+ self._messages.append(message)
+
+ 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 docs/policies/expression/index.md
- kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member
- kwargs["pb_logger"] = get_logger()
- kwargs["requests"] = Session()
- kwargs["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
+ self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
+ self._context["request"] = request
if request.http_request:
- kwargs["pb_client_ip"] = (
- 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
+ self.set_http_request(request.http_request)
- def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult:
- """Parse and evaluate expression.
- If the Expression evaluates to a list with 2 items, the first is used as passing bool and
- the second as messages.
- If the Expression evaluates to a truthy-object, it is used as passing bool."""
+ def set_http_request(self, request: HttpRequest):
+ """Update context based on http request"""
+ # update passbook/policies/expression/templates/policy/expression/form.html
+ # update docs/policies/expression/index.md
+ 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:
- expression = self._env.from_string(expression_source)
+ expression = self._env.from_string(expression_source.lstrip().rstrip())
except TemplateSyntaxError as exc:
return PolicyResult(False, str(exc))
try:
- result: Optional[Any] = expression.render(
- request=request, **self._get_expression_context(request)
- )
+ result: Optional[Any] = expression.render(self._context)
+ 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):
LOGGER.warning(
"Expression policy returned undefined",
src=expression_source,
- req=request,
+ req=self._context,
)
- return PolicyResult(False)
- if isinstance(result, (list, tuple)) and len(result) == 2:
- return PolicyResult(*result)
+ policy_result.passing = False
if result:
- return PolicyResult(bool(result))
- return PolicyResult(False)
- except UndefinedError as exc:
- return PolicyResult(False, str(exc))
+ policy_result.passing = bool(result)
+ return policy_result
def validate(self, expression: str):
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
@@ -99,4 +133,4 @@ class Evaluator:
self._env.from_string(expression)
return True
except TemplateSyntaxError as exc:
- raise ValidationError("Expression Syntax Error") from exc
+ raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc
diff --git a/passbook/policies/expression/models.py b/passbook/policies/expression/models.py
index edf9b629b..e43f17560 100644
--- a/passbook/policies/expression/models.py
+++ b/passbook/policies/expression/models.py
@@ -16,7 +16,9 @@ class ExpressionPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult:
"""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):
Evaluator().validate(self.expression)
diff --git a/passbook/policies/expression/tests/test_evaluator.py b/passbook/policies/expression/tests/test_evaluator.py
index ca22e86ec..e39cdfec9 100644
--- a/passbook/policies/expression/tests/test_evaluator.py
+++ b/passbook/policies/expression/tests/test_evaluator.py
@@ -17,13 +17,15 @@ class TestEvaluator(TestCase):
"""test simple value expression"""
template = "True"
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):
"""test expression with message return"""
- template = "False, 'some message'"
+ template = '{% do pb_message("some message") %}False'
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.messages, ("some message",))
@@ -31,7 +33,8 @@ class TestEvaluator(TestCase):
"""test invalid syntax"""
template = "{%"
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.messages, ("tag name expected",))
@@ -39,7 +42,8 @@ class TestEvaluator(TestCase):
"""test undefined result"""
template = "{{ foo.bar }}"
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.messages, ("'foo' is undefined",))
diff --git a/passbook/policies/forms.py b/passbook/policies/forms.py
index 8bcdbb9e0..0378e2c58 100644
--- a/passbook/policies/forms.py
+++ b/passbook/policies/forms.py
@@ -3,8 +3,8 @@ from django import forms
from passbook.policies.models import PolicyBinding, PolicyBindingModel
-GENERAL_FIELDS = ["name", "negate", "order", "timeout"]
-GENERAL_SERIALIZER_FIELDS = ["pk", "name", "negate", "order", "timeout"]
+GENERAL_FIELDS = ["name"]
+GENERAL_SERIALIZER_FIELDS = ["pk", "name"]
class PolicyBindingForm(forms.ModelForm):
@@ -18,9 +18,4 @@ class PolicyBindingForm(forms.ModelForm):
class Meta:
model = PolicyBinding
- fields = [
- "enabled",
- "policy",
- "target",
- "order",
- ]
+ fields = ["enabled", "policy", "target", "order", "timeout"]
diff --git a/passbook/policies/migrations/0002_auto_20200528_1647.py b/passbook/policies/migrations/0002_auto_20200528_1647.py
new file mode 100644
index 000000000..b43a2f732
--- /dev/null
+++ b/passbook/policies/migrations/0002_auto_20200528_1647.py
@@ -0,0 +1,58 @@
+# Generated by Django 3.0.6 on 2020-05-28 16:47
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import passbook.lib.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_policies", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="policy",
+ options={
+ "base_manager_name": "objects",
+ "verbose_name": "Policy",
+ "verbose_name_plural": "Policies",
+ },
+ ),
+ migrations.RemoveField(model_name="policy", name="negate",),
+ migrations.RemoveField(model_name="policy", name="order",),
+ migrations.RemoveField(model_name="policy", name="timeout",),
+ migrations.AddField(
+ model_name="policybinding",
+ name="negate",
+ field=models.BooleanField(
+ default=False,
+ help_text="Negates the outcome of the policy. Messages are unaffected.",
+ ),
+ ),
+ migrations.AddField(
+ model_name="policybinding",
+ name="timeout",
+ field=models.IntegerField(
+ default=30,
+ help_text="Timeout after which Policy execution is terminated.",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="policybinding", name="order", field=models.IntegerField(),
+ ),
+ migrations.AlterField(
+ model_name="policybinding",
+ name="policy",
+ field=passbook.lib.models.InheritanceForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="+",
+ to="passbook_policies.Policy",
+ ),
+ ),
+ migrations.AlterUniqueTogether(
+ name="policybinding", unique_together={("policy", "target", "order")},
+ ),
+ ]
diff --git a/passbook/policies/models.py b/passbook/policies/models.py
index b47dc9ecd..826938f51 100644
--- a/passbook/policies/models.py
+++ b/passbook/policies/models.py
@@ -5,7 +5,11 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager
-from passbook.lib.models import CreatedUpdatedModel
+from passbook.lib.models import (
+ CreatedUpdatedModel,
+ InheritanceAutoManager,
+ InheritanceForeignKey,
+)
from passbook.policies.exceptions import PolicyException
from passbook.policies.types import PolicyRequest, PolicyResult
@@ -22,7 +26,6 @@ class PolicyBindingModel(models.Model):
objects = InheritanceManager()
class Meta:
-
verbose_name = _("Policy Binding Model")
verbose_name_plural = _("Policy Binding Models")
@@ -36,13 +39,19 @@ class PolicyBinding(models.Model):
enabled = models.BooleanField(default=True)
- policy = models.ForeignKey("Policy", on_delete=models.CASCADE, related_name="+")
+ policy = InheritanceForeignKey("Policy", on_delete=models.CASCADE, related_name="+")
target = models.ForeignKey(
PolicyBindingModel, on_delete=models.CASCADE, related_name="+"
)
+ negate = models.BooleanField(
+ default=False,
+ help_text=_("Negates the outcome of the policy. Messages are unaffected."),
+ )
+ timeout = models.IntegerField(
+ default=30, help_text=_("Timeout after which Policy execution is terminated.")
+ )
- # default value and non-unique for compatibility
- order = models.IntegerField(default=0)
+ order = models.IntegerField()
def __str__(self) -> str:
return f"PolicyBinding policy={self.policy} target={self.target} order={self.order}"
@@ -51,6 +60,7 @@ class PolicyBinding(models.Model):
verbose_name = _("Policy Binding")
verbose_name_plural = _("Policy Bindings")
+ unique_together = ("policy", "target", "order")
class Policy(CreatedUpdatedModel):
@@ -60,11 +70,8 @@ class Policy(CreatedUpdatedModel):
policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField(blank=True, null=True)
- negate = models.BooleanField(default=False)
- order = models.IntegerField(default=0)
- timeout = models.IntegerField(default=30)
- objects = InheritanceManager()
+ objects = InheritanceAutoManager()
def __str__(self):
return f"Policy {self.name}"
@@ -72,3 +79,9 @@ class Policy(CreatedUpdatedModel):
def passes(self, request: PolicyRequest) -> PolicyResult:
"""Check if user instance passes this policy"""
raise PolicyException()
+
+ class Meta:
+ base_manager_name = "objects"
+
+ verbose_name = _("Policy")
+ verbose_name_plural = _("Policies")
diff --git a/passbook/policies/process.py b/passbook/policies/process.py
index f6c2a1265..a187627a6 100644
--- a/passbook/policies/process.py
+++ b/passbook/policies/process.py
@@ -6,19 +6,20 @@ from typing import Optional
from django.core.cache import cache
from structlog import get_logger
-from passbook.core.models import User
from passbook.policies.exceptions import PolicyException
-from passbook.policies.models import Policy
+from passbook.policies.models import PolicyBinding
from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
-def cache_key(policy: Policy, user: Optional[User] = None) -> str:
+def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
"""Generate Cache key for policy"""
- prefix = f"policy_{policy.pk}"
- if user:
- prefix += f"#{user.pk}"
+ prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
+ if request.http_request:
+ prefix += f"_{request.http_request.session.session_key}"
+ if request.user:
+ prefix += f"#{request.user.pk}"
return prefix
@@ -26,40 +27,50 @@ class PolicyProcess(Process):
"""Evaluate a single policy within a seprate process"""
connection: Connection
- policy: Policy
+ binding: PolicyBinding
request: PolicyRequest
- def __init__(self, policy: Policy, request: PolicyRequest, connection: Connection):
+ def __init__(
+ self,
+ binding: PolicyBinding,
+ request: PolicyRequest,
+ connection: Optional[Connection],
+ ):
super().__init__()
- self.policy = policy
+ self.binding = binding
self.request = request
- self.connection = connection
+ if connection:
+ self.connection = connection
- def run(self):
- """Task wrapper to run policy checking"""
+ def execute(self) -> PolicyResult:
+ """Run actual policy, returns result"""
LOGGER.debug(
"P_ENG(proc): Running policy",
- policy=self.policy,
+ policy=self.binding.policy,
user=self.request.user,
process="PolicyProcess",
)
try:
- policy_result = self.policy.passes(self.request)
+ policy_result = self.binding.policy.passes(self.request)
except PolicyException as exc:
LOGGER.debug("P_ENG(proc): error", exc=exc)
policy_result = PolicyResult(False, str(exc))
# Invert result if policy.negate is set
- if self.policy.negate:
+ if self.binding.negate:
policy_result.passing = not policy_result.passing
LOGGER.debug(
"P_ENG(proc): Finished",
- policy=self.policy,
+ policy=self.binding.policy,
result=policy_result,
process="PolicyProcess",
passing=policy_result.passing,
user=self.request.user,
)
- key = cache_key(self.policy, self.request.user)
+ key = cache_key(self.binding, self.request)
cache.set(key, policy_result)
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
- self.connection.send(policy_result)
+ return policy_result
+
+ def run(self):
+ """Task wrapper to run policy checking"""
+ self.connection.send(self.execute())
diff --git a/passbook/policies/signals.py b/passbook/policies/signals.py
new file mode 100644
index 000000000..82e0b3d94
--- /dev/null
+++ b/passbook/policies/signals.py
@@ -0,0 +1,26 @@
+"""passbook policy signals"""
+from django.core.cache import cache
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from structlog import get_logger
+
+LOGGER = get_logger()
+
+
+@receiver(post_save)
+# pylint: disable=unused-argument
+def invalidate_policy_cache(sender, instance, **_):
+ """Invalidate Policy cache when policy is updated"""
+ from passbook.policies.models import Policy, PolicyBinding
+
+ if isinstance(instance, Policy):
+ LOGGER.debug("Invalidating policy cache", policy=instance)
+ total = 0
+ for binding in PolicyBinding.objects.filter(policy=instance):
+ prefix = (
+ f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}*"
+ )
+ keys = cache.keys(prefix)
+ total += len(keys)
+ cache.delete_many(keys)
+ LOGGER.debug("Deleted keys", len=total)
diff --git a/passbook/policies/tests/test_engine.py b/passbook/policies/tests/test_engine.py
index 0f4c63a83..05537e432 100644
--- a/passbook/policies/tests/test_engine.py
+++ b/passbook/policies/tests/test_engine.py
@@ -5,7 +5,8 @@ from django.test import TestCase
from passbook.core.models import User
from passbook.policies.dummy.models import DummyPolicy
from passbook.policies.engine import PolicyEngine
-from passbook.policies.models import Policy
+from passbook.policies.expression.models import ExpressionPolicy
+from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel
class PolicyTestEngine(TestCase):
@@ -20,40 +21,64 @@ class PolicyTestEngine(TestCase):
self.policy_true = DummyPolicy.objects.create(
result=True, wait_min=0, wait_max=1
)
- self.policy_negate = DummyPolicy.objects.create(
- negate=True, result=True, wait_min=0, wait_max=1
+ self.policy_wrong_type = Policy.objects.create(name="wrong_type")
+ self.policy_raises = ExpressionPolicy.objects.create(
+ name="raises", expression="{{ 0/0 }}"
)
- self.policy_raises = Policy.objects.create(name="raises")
def test_engine_empty(self):
"""Ensure empty policy list passes"""
- engine = PolicyEngine([], self.user)
- self.assertEqual(engine.build().passing, True)
+ pbm = PolicyBindingModel.objects.create()
+ engine = PolicyEngine(pbm, self.user)
+ result = engine.build().result
+ self.assertEqual(result.passing, True)
+ self.assertEqual(result.messages, ())
def test_engine(self):
"""Ensure all policies passes (Mix of false and true -> false)"""
- engine = PolicyEngine(
- DummyPolicy.objects.filter(negate__exact=False), self.user
- )
- self.assertEqual(engine.build().passing, False)
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=1)
+ engine = PolicyEngine(pbm, self.user)
+ result = engine.build().result
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("dummy",))
def test_engine_negate(self):
"""Test negate flag"""
- engine = PolicyEngine(DummyPolicy.objects.filter(negate__exact=True), self.user)
- self.assertEqual(engine.build().passing, False)
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(
+ target=pbm, policy=self.policy_true, negate=True, order=0
+ )
+ engine = PolicyEngine(pbm, self.user)
+ result = engine.build().result
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("dummy",))
def test_engine_policy_error(self):
- """Test negate flag"""
- engine = PolicyEngine(Policy.objects.filter(name="raises"), self.user)
- self.assertEqual(engine.build().passing, False)
+ """Test policy raising an error flag"""
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_raises, order=0)
+ engine = PolicyEngine(pbm, self.user)
+ result = engine.build().result
+ self.assertEqual(result.passing, False)
+ self.assertEqual(result.messages, ("division by zero",))
+
+ def test_engine_policy_type(self):
+ """Test invalid policy type"""
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_wrong_type, order=0)
+ with self.assertRaises(TypeError):
+ engine = PolicyEngine(pbm, self.user)
+ engine.build()
def test_engine_cache(self):
"""Ensure empty policy list passes"""
- engine = PolicyEngine(
- DummyPolicy.objects.filter(negate__exact=False), self.user
- )
+ pbm = PolicyBindingModel.objects.create()
+ PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
+ engine = PolicyEngine(pbm, self.user)
self.assertEqual(len(cache.keys("policy_*")), 0)
self.assertEqual(engine.build().passing, False)
- self.assertEqual(len(cache.keys("policy_*")), 2)
+ self.assertEqual(len(cache.keys("policy_*")), 1)
self.assertEqual(engine.build().passing, False)
- self.assertEqual(len(cache.keys("policy_*")), 2)
+ self.assertEqual(len(cache.keys("policy_*")), 1)
diff --git a/passbook/policies/types.py b/passbook/policies/types.py
index 1fd65e63b..29bf02932 100644
--- a/passbook/policies/types.py
+++ b/passbook/policies/types.py
@@ -39,4 +39,6 @@ class PolicyResult:
self.messages = messages
def __str__(self):
- return f""
+ if self.messages:
+ return f"PolicyResult passing={self.passing} messages={self.messages}"
+ return f"PolicyResult passing={self.passing}"
diff --git a/passbook/providers/oauth/views/oauth2.py b/passbook/providers/oauth/views/oauth2.py
index 96a0eaaa2..208dd99ac 100644
--- a/passbook/providers/oauth/views/oauth2.py
+++ b/passbook/providers/oauth/views/oauth2.py
@@ -50,9 +50,9 @@ class PassbookAuthorizationView(AccessMixin, AuthorizationView):
provider.save()
self._application = application
# Check permissions
- passing, policy_messages = self.user_has_access(self._application, request.user)
- if not passing:
- for policy_message in policy_messages:
+ result = self.user_has_access(self._application, request.user)
+ if not result.passing:
+ for policy_message in result.messages:
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")
# Some clients don't pass response_type, so we default to code
diff --git a/passbook/providers/oidc/auth.py b/passbook/providers/oidc/auth.py
index 334607afa..91a0b9dcf 100644
--- a/passbook/providers/oidc/auth.py
+++ b/passbook/providers/oidc/auth.py
@@ -18,7 +18,7 @@ LOGGER = get_logger()
def client_related_provider(client: Client) -> Optional[Provider]:
"""Lookup related Application from Client"""
# because oidc_provider is also used by app_gw, we can't be
- # sure an OpenIDPRovider instance exists. hence we look through all related models
+ # sure an OpenIDProvider instance exists. hence we look through all related models
# and choose the one that inherits from Provider, which is guaranteed to
# have the application property
collector = Collector(using="default")
@@ -50,9 +50,9 @@ def check_permissions(
policy_engine.build()
# Check permissions
- passing, policy_messages = policy_engine.result
- if not passing:
- for policy_message in policy_messages:
+ result = policy_engine.result
+ if not result.passing:
+ for policy_message in result.messages:
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")
diff --git a/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py b/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py
new file mode 100644
index 000000000..72575b6d6
--- /dev/null
+++ b/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py
@@ -0,0 +1,63 @@
+# Generated by Django 3.0.6 on 2020-05-23 19:32
+
+from django.db import migrations
+
+
+def create_default_property_mappings(apps, schema_editor):
+ """Create default SAML Property Mappings"""
+ SAMLPropertyMapping = apps.get_model(
+ "passbook_providers_saml", "SAMLPropertyMapping"
+ )
+ db_alias = schema_editor.connection.alias
+ defaults = [
+ {
+ "FriendlyName": "eduPersonPrincipalName",
+ "Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
+ "Expression": "{{ user.email }}",
+ },
+ {
+ "FriendlyName": "cn",
+ "Name": "urn:oid:2.5.4.3",
+ "Expression": "{{ user.name }}",
+ },
+ {
+ "FriendlyName": "mail",
+ "Name": "urn:oid:0.9.2342.19200300.100.1.3",
+ "Expression": "{{ user.email }}",
+ },
+ {
+ "FriendlyName": "displayName",
+ "Name": "urn:oid:2.16.840.1.113730.3.1.241",
+ "Expression": "{{ user.username }}",
+ },
+ {
+ "FriendlyName": "uid",
+ "Name": "urn:oid:0.9.2342.19200300.100.1.1",
+ "Expression": "{{ user.pk }}",
+ },
+ {
+ "FriendlyName": "member-of",
+ "Name": "member-of",
+ "Expression": "[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]",
+ },
+ ]
+ for default in defaults:
+ SAMLPropertyMapping.objects.using(db_alias).get_or_create(
+ saml_name=default["Name"],
+ friendly_name=default["FriendlyName"],
+ expression=default["Expression"],
+ defaults={
+ "name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
+ },
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_providers_saml", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_property_mappings),
+ ]
diff --git a/passbook/sources/ldap/api.py b/passbook/sources/ldap/api.py
index a51a5ce12..e5ad2677c 100644
--- a/passbook/sources/ldap/api.py
+++ b/passbook/sources/ldap/api.py
@@ -23,6 +23,7 @@ class LDAPSourceSerializer(ModelSerializer):
"group_object_filter",
"user_group_membership_field",
"object_uniqueness_field",
+ "sync_users",
"sync_groups",
"sync_parent_group",
"property_mappings",
diff --git a/passbook/sources/ldap/connector.py b/passbook/sources/ldap/connector.py
index 064a6a628..748c25e9e 100644
--- a/passbook/sources/ldap/connector.py
+++ b/passbook/sources/ldap/connector.py
@@ -16,26 +16,10 @@ LOGGER = get_logger()
class Connector:
"""Wrapper for ldap3 to easily manage user authentication and creation"""
- _server: ldap3.Server
- _connection = ldap3.Connection
_source: LDAPSource
def __init__(self, source: LDAPSource):
self._source = source
- self._server = ldap3.Server(source.server_uri) # Implement URI parsing
-
- def bind(self):
- """Bind using Source's Credentials"""
- self._connection = ldap3.Connection(
- self._server,
- raise_exceptions=True,
- user=self._source.bind_cn,
- password=self._source.bind_password,
- )
-
- self._connection.bind()
- if self._source.start_tls:
- self._connection.start_tls()
@staticmethod
def encode_pass(password: str) -> bytes:
@@ -45,19 +29,23 @@ class Connector:
@property
def base_dn_users(self) -> str:
"""Shortcut to get full base_dn for user lookups"""
- return ",".join([self._source.additional_user_dn, self._source.base_dn])
+ if self._source.additional_user_dn:
+ return f"{self._source.additional_user_dn},{self._source.base_dn}"
+ return self._source.base_dn
@property
def base_dn_groups(self) -> str:
"""Shortcut to get full base_dn for group lookups"""
- return ",".join([self._source.additional_group_dn, self._source.base_dn])
+ if self._source.additional_group_dn:
+ return f"{self._source.additional_group_dn},{self._source.base_dn}"
+ return self._source.base_dn
def sync_groups(self):
"""Iterate over all LDAP Groups and create passbook_core.Group instances"""
if not self._source.sync_groups:
- LOGGER.debug("Group syncing is disabled for this Source")
+ LOGGER.warning("Group syncing is disabled for this Source")
return
- groups = self._connection.extend.standard.paged_search(
+ groups = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=ldap3.SUBTREE,
@@ -87,7 +75,10 @@ class Connector:
def sync_users(self):
"""Iterate over all LDAP Users and create passbook_core.User instances"""
- users = self._connection.extend.standard.paged_search(
+ if not self._source.sync_users:
+ LOGGER.warning("User syncing is disabled for this Source")
+ return
+ users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
@@ -101,9 +92,9 @@ class Connector:
LOGGER.warning("Cannot find uniqueness Field in attributes")
continue
try:
+ defaults = self._build_object_properties(attributes)
user, created = User.objects.update_or_create(
- attributes__ldap_uniq=uniq,
- defaults=self._build_object_properties(attributes),
+ attributes__ldap_uniq=uniq, defaults=defaults,
)
except IntegrityError as exc:
LOGGER.warning("Failed to create user", exc=exc)
@@ -123,7 +114,7 @@ class Connector:
def sync_membership(self):
"""Iterate over all Users and assign Groups using memberOf Field"""
- users = self._connection.extend.standard.paged_search(
+ users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
@@ -220,7 +211,7 @@ class Connector:
LOGGER.debug("Attempting Binding as user", user=user)
try:
temp_connection = ldap3.Connection(
- self._server,
+ self._source.connection.server,
user=user.attributes.get("distinguishedName"),
password=password,
raise_exceptions=True,
diff --git a/passbook/sources/ldap/forms.py b/passbook/sources/ldap/forms.py
index 249ebd5af..48d71d48a 100644
--- a/passbook/sources/ldap/forms.py
+++ b/passbook/sources/ldap/forms.py
@@ -26,6 +26,7 @@ class LDAPSourceForm(forms.ModelForm):
"group_object_filter",
"user_group_membership_field",
"object_uniqueness_field",
+ "sync_users",
"sync_groups",
"sync_parent_group",
"property_mappings",
diff --git a/passbook/sources/ldap/migrations/0002_ldapsource_sync_users.py b/passbook/sources/ldap/migrations/0002_ldapsource_sync_users.py
new file mode 100644
index 000000000..27a0da2b3
--- /dev/null
+++ b/passbook/sources/ldap/migrations/0002_ldapsource_sync_users.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.6 on 2020-05-23 19:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_sources_ldap", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ldapsource",
+ name="sync_users",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/passbook/sources/ldap/migrations/0003_default_ldap_property_mappings.py b/passbook/sources/ldap/migrations/0003_default_ldap_property_mappings.py
new file mode 100644
index 000000000..318952211
--- /dev/null
+++ b/passbook/sources/ldap/migrations/0003_default_ldap_property_mappings.py
@@ -0,0 +1,35 @@
+# Generated by Django 3.0.6 on 2020-05-23 19:30
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+
+def create_default_ad_property_mappings(apps: Apps, schema_editor):
+ LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
+ mapping = {
+ "name": "{{ ldap.name }}",
+ "first_name": "{{ ldap.givenName }}",
+ "last_name": "{{ ldap.sn }}",
+ "username": "{{ ldap.sAMAccountName }}",
+ "email": "{{ ldap.mail }}",
+ }
+ db_alias = schema_editor.connection.alias
+ for object_field, expression in mapping.items():
+ LDAPPropertyMapping.objects.using(db_alias).get_or_create(
+ expression=expression,
+ object_field=object_field,
+ defaults={
+ "name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
+ },
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_sources_ldap", "0002_ldapsource_sync_users"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_default_ad_property_mappings),
+ ]
diff --git a/passbook/sources/ldap/models.py b/passbook/sources/ldap/models.py
index 393cccfa2..34fa96e56 100644
--- a/passbook/sources/ldap/models.py
+++ b/passbook/sources/ldap/models.py
@@ -1,8 +1,10 @@
"""passbook LDAP Models"""
+from typing import Optional
from django.core.validators import URLValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
+from ldap3 import Connection, Server
from passbook.core.models import Group, PropertyMapping, Source
@@ -22,10 +24,12 @@ class LDAPSource(Source):
additional_user_dn = models.TextField(
help_text=_("Prepended to Base DN for User-queries."),
verbose_name=_("Addition User DN"),
+ blank=True,
)
additional_group_dn = models.TextField(
help_text=_("Prepended to Base DN for Group-queries."),
verbose_name=_("Addition Group DN"),
+ blank=True,
)
user_object_filter = models.TextField(
@@ -43,6 +47,7 @@ class LDAPSource(Source):
default="objectSid", help_text=_("Field which contains a unique Identifier.")
)
+ sync_users = models.BooleanField(default=True)
sync_groups = models.BooleanField(default=True)
sync_parent_group = models.ForeignKey(
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
@@ -50,6 +55,25 @@ class LDAPSource(Source):
form = "passbook.sources.ldap.forms.LDAPSourceForm"
+ _connection: Optional[Connection]
+
+ @property
+ def connection(self) -> Connection:
+ """Get a fully connected and bound LDAP Connection"""
+ if not self._connection:
+ server = Server(self.server_uri)
+ self._connection = Connection(
+ server,
+ raise_exceptions=True,
+ user=self.bind_cn,
+ password=self.bind_password,
+ )
+
+ self._connection.bind()
+ if self.start_tls:
+ self._connection.start_tls()
+ return self._connection
+
class Meta:
verbose_name = _("LDAP Source")
diff --git a/passbook/sources/ldap/tasks.py b/passbook/sources/ldap/tasks.py
index 581d27c7a..eeb1cb282 100644
--- a/passbook/sources/ldap/tasks.py
+++ b/passbook/sources/ldap/tasks.py
@@ -9,7 +9,6 @@ def sync_groups(source_pk: int):
"""Sync LDAP Groups on background worker"""
source = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source)
- connector.bind()
connector.sync_groups()
@@ -18,7 +17,6 @@ def sync_users(source_pk: int):
"""Sync LDAP Users on background worker"""
source = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source)
- connector.bind()
connector.sync_users()
@@ -27,7 +25,6 @@ def sync():
"""Sync all sources"""
for source in LDAPSource.objects.filter(enabled=True):
connector = Connector(source)
- connector.bind()
connector.sync_users()
connector.sync_groups()
connector.sync_membership()
diff --git a/passbook/sources/ldap/tests.py b/passbook/sources/ldap/tests.py
new file mode 100644
index 000000000..faa3f4177
--- /dev/null
+++ b/passbook/sources/ldap/tests.py
@@ -0,0 +1,75 @@
+"""LDAP Source tests"""
+from unittest.mock import PropertyMock, patch
+
+from django.test import TestCase
+from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
+
+from passbook.core.models import User
+from passbook.sources.ldap.connector import Connector
+from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
+
+
+def _build_mock_connection() -> Connection:
+ """Create mock connection"""
+ server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
+ _pass = "foo" # noqa # nosec
+ connection = Connection(
+ server,
+ user="cn=my_user,ou=test,o=lab",
+ password=_pass,
+ client_strategy=MOCK_SYNC,
+ )
+ connection.strategy.add_entry(
+ "cn=user0,ou=test,o=lab",
+ {
+ "userPassword": "test0000",
+ "sAMAccountName": "user0_sn",
+ "revision": 0,
+ "objectSid": "unique-test0000",
+ "objectCategory": "Person",
+ },
+ )
+ connection.strategy.add_entry(
+ "cn=user1,ou=test,o=lab",
+ {
+ "userPassword": "test1111",
+ "sAMAccountName": "user1_sn",
+ "revision": 0,
+ "objectSid": "unique-test1111",
+ "objectCategory": "Person",
+ },
+ )
+ connection.strategy.add_entry(
+ "cn=user2,ou=test,o=lab",
+ {
+ "userPassword": "test2222",
+ "sAMAccountName": "user2_sn",
+ "revision": 0,
+ "objectSid": "unique-test2222",
+ "objectCategory": "Person",
+ },
+ )
+ connection.bind()
+ return connection
+
+
+LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection())
+
+
+class LDAPSourceTests(TestCase):
+ """LDAP Source tests"""
+
+ def setUp(self):
+ self.source = LDAPSource.objects.create(
+ name="ldap", slug="ldap", base_dn="o=lab"
+ )
+ self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
+ self.source.save()
+
+ @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
+ def test_sync_users(self):
+ """Test user sync"""
+ connector = Connector(self.source)
+ connector.sync_users()
+ user = User.objects.filter(username="user2_sn")
+ self.assertTrue(user.exists())
diff --git a/passbook/sources/oauth/clients.py b/passbook/sources/oauth/clients.py
index 06a9310fc..35c58d7ba 100644
--- a/passbook/sources/oauth/clients.py
+++ b/passbook/sources/oauth/clients.py
@@ -1,6 +1,6 @@
"""OAuth Clients"""
import json
-from typing import Dict, Optional
+from typing import TYPE_CHECKING, Any, Dict, Optional
from urllib.parse import parse_qs, urlencode
from django.http import HttpRequest
@@ -14,24 +14,29 @@ from structlog import get_logger
from passbook import __version__
LOGGER = get_logger()
+if TYPE_CHECKING:
+ from passbook.sources.oauth.models import OAuthSource
class BaseOAuthClient:
"""Base OAuth Client"""
session: Session
+ source: "OAuthSource"
- def __init__(self, source, token=""): # nosec
+ def __init__(self, source: "OAuthSource", token=""): # nosec
self.source = source
self.token = token
self.session = Session()
self.session.headers.update({"User-Agent": "passbook %s" % __version__})
- def get_access_token(self, request, callback=None):
+ def get_access_token(
+ self, request: HttpRequest, callback=None
+ ) -> Optional[Dict[str, Any]]:
"Fetch access token from callback request."
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
- def get_profile_info(self, token: Dict[str, str]):
+ def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]:
"Fetch user profile information."
try:
headers = {
@@ -45,7 +50,7 @@ class BaseOAuthClient:
LOGGER.warning("Unable to fetch user profile", exc=exc)
return None
else:
- return response.json() or response.text
+ return response.json()
def get_redirect_args(self, request, callback) -> Dict[str, str]:
"Get request parameters for redirect url."
diff --git a/passbook/sources/oauth/views/core.py b/passbook/sources/oauth/views/core.py
index 7e3249bc7..9166ad3b6 100644
--- a/passbook/sources/oauth/views/core.py
+++ b/passbook/sources/oauth/views/core.py
@@ -21,7 +21,7 @@ from passbook.flows.planner import (
)
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs
-from passbook.sources.oauth.clients import get_client
+from passbook.sources.oauth.clients import BaseOAuthClient, get_client
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
@@ -34,7 +34,7 @@ class OAuthClientMixin:
client_class: Optional[Callable] = None
- def get_client(self, source):
+ def get_client(self, source: OAuthSource) -> BaseOAuthClient:
"Get instance of the OAuth client for this source."
if self.client_class is not None:
# pylint: disable=not-callable
diff --git a/passbook/stages/identification/api.py b/passbook/stages/identification/api.py
index bd56a0a61..f40c329e3 100644
--- a/passbook/stages/identification/api.py
+++ b/passbook/stages/identification/api.py
@@ -16,6 +16,8 @@ class IdentificationStageSerializer(ModelSerializer):
"name",
"user_fields",
"template",
+ "enrollment_flow",
+ "recovery_flow",
]
diff --git a/passbook/stages/identification/forms.py b/passbook/stages/identification/forms.py
index 04217f7f8..882ce0f03 100644
--- a/passbook/stages/identification/forms.py
+++ b/passbook/stages/identification/forms.py
@@ -16,7 +16,7 @@ class IdentificationStageForm(forms.ModelForm):
class Meta:
model = IdentificationStage
- fields = ["name", "user_fields", "template"]
+ fields = ["name", "user_fields", "template", "enrollment_flow", "recovery_flow"]
widgets = {
"name": forms.TextInput(),
}
diff --git a/passbook/stages/identification/migrations/0002_auto_20200530_2204.py b/passbook/stages/identification/migrations/0002_auto_20200530_2204.py
new file mode 100644
index 000000000..8a554f1ee
--- /dev/null
+++ b/passbook/stages/identification/migrations/0002_auto_20200530_2204.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/passbook/stages/identification/models.py b/passbook/stages/identification/models.py
index d43b6b15c..94bf0ec48 100644
--- a/passbook/stages/identification/models.py
+++ b/passbook/stages/identification/models.py
@@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField
from django.db import models
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):
@@ -29,6 +29,29 @@ class IdentificationStage(Stage):
)
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"
form = "passbook.stages.identification.forms.IdentificationStageForm"
diff --git a/passbook/stages/identification/stage.py b/passbook/stages/identification/stage.py
index d06474aaf..38189bf1b 100644
--- a/passbook/stages/identification/stage.py
+++ b/passbook/stages/identification/stage.py
@@ -10,7 +10,6 @@ from django.views.generic import FormView
from structlog import get_logger
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.stage import StageView
from passbook.stages.identification.forms import IdentificationForm
@@ -34,18 +33,17 @@ class IdentificationStageView(FormView, StageView):
return [current_stage.template]
def get_context_data(self, **kwargs):
+ current_stage: IdentificationStage = self.executor.current_stage
# Check for related enrollment and recovery flow, add URL to view
- enrollment_flow = self.executor.flow.related_flow(FlowDesignation.ENROLLMENT)
- if enrollment_flow:
+ if current_stage.enrollment_flow:
kwargs["enroll_url"] = reverse(
- "passbook_flows:flow-executor",
- kwargs={"flow_slug": enrollment_flow.slug},
+ "passbook_flows:flow-executor-shell",
+ kwargs={"flow_slug": current_stage.enrollment_flow.slug},
)
- recovery_flow = self.executor.flow.related_flow(FlowDesignation.RECOVERY)
- if recovery_flow:
+ if current_stage.recovery_flow:
kwargs["recovery_url"] = reverse(
- "passbook_flows:flow-executor",
- kwargs={"flow_slug": recovery_flow.slug},
+ "passbook_flows:flow-executor-shell",
+ kwargs={"flow_slug": current_stage.recovery_flow.slug},
)
kwargs["primary_action"] = _("Log in")
diff --git a/passbook/stages/identification/templates/stages/identification/login.html b/passbook/stages/identification/templates/stages/identification/login.html
index 02052f36f..d95ebd675 100644
--- a/passbook/stages/identification/templates/stages/identification/login.html
+++ b/passbook/stages/identification/templates/stages/identification/login.html
@@ -1,4 +1,5 @@
{% load i18n %}
+{% load static %}
@@ -21,3 +22,35 @@
+
diff --git a/passbook/stages/identification/templates/stages/identification/recovery.html b/passbook/stages/identification/templates/stages/identification/recovery.html
index 1dab0ae77..4c9d07c18 100644
--- a/passbook/stages/identification/templates/stages/identification/recovery.html
+++ b/passbook/stages/identification/templates/stages/identification/recovery.html
@@ -1,72 +1,29 @@
-{% extends 'base/skeleton.html' %}
-
-{% load static %}
{% load i18n %}
+{% load static %}
-{% block body %}
-
-
+
+
+ {% trans 'Trouble Logging In?' %}
+
+
+
+ {% block card %}
+
+ {% endblock %}
-{% include 'partials/messages.html' %}
-
-
-
-
-
-
- {% trans 'Trouble Logging In?' %}
-
-
-
- {% block card %}
-
- {% endblock %}
-
-
-
-
-
-
-{% endblock %}
+
diff --git a/passbook/stages/identification/tests.py b/passbook/stages/identification/tests.py
index 4ecf255e9..fa8c3919d 100644
--- a/passbook/stages/identification/tests.py
+++ b/passbook/stages/identification/tests.py
@@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase):
slug="unique-enrollment-string",
designation=FlowDesignation.ENROLLMENT,
)
+ self.stage.enrollment_flow = flow
+ self.stage.save()
FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0,
)
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.assertIn(flow.name, response.rendered_content)
+ self.assertIn(flow.slug, response.rendered_content)
def test_recovery_flow(self):
"""Test that recovery flow is linked correctly"""
@@ -102,12 +106,16 @@ class TestIdentificationStage(TestCase):
slug="unique-recovery-string",
designation=FlowDesignation.RECOVERY,
)
+ self.stage.recovery_flow = flow
+ self.stage.save()
FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0,
)
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.assertIn(flow.name, response.rendered_content)
+ self.assertIn(flow.slug, response.rendered_content)
diff --git a/passbook/stages/prompt/api.py b/passbook/stages/prompt/api.py
index 381e9427c..1c4d4a01b 100644
--- a/passbook/stages/prompt/api.py
+++ b/passbook/stages/prompt/api.py
@@ -38,6 +38,7 @@ class PromptSerializer(ModelSerializer):
"type",
"required",
"placeholder",
+ "order",
]
diff --git a/passbook/stages/prompt/forms.py b/passbook/stages/prompt/forms.py
index 14830f36d..e9157d198 100644
--- a/passbook/stages/prompt/forms.py
+++ b/passbook/stages/prompt/forms.py
@@ -31,6 +31,7 @@ class PromptAdminForm(forms.ModelForm):
"type",
"required",
"placeholder",
+ "order",
]
widgets = {
"label": forms.TextInput(),
@@ -48,16 +49,19 @@ class PromptForm(forms.Form):
self.stage = stage
self.plan = plan
super().__init__(*args, **kwargs)
- for field in self.stage.fields.all():
+ # list() is called so we only load the fields once
+ fields = list(self.stage.fields.all())
+ for field in fields:
field: Prompt
self.fields[field.field_key] = field.field
+ self.field_order = sorted(fields, key=lambda x: x.order)
def clean(self):
cleaned_data = super().clean()
user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user())
- engine = PolicyEngine(self.stage.policies.all(), user)
+ engine = PolicyEngine(self.stage, user)
engine.request.context = cleaned_data
engine.build()
- passing, messages = engine.result
- if not passing:
- raise forms.ValidationError(messages)
+ result = engine.result
+ if not result.passing:
+ raise forms.ValidationError(list(result.messages))
diff --git a/passbook/stages/prompt/migrations/0002_auto_20200528_2059.py b/passbook/stages/prompt/migrations/0002_auto_20200528_2059.py
new file mode 100644
index 000000000..5ecb06b4b
--- /dev/null
+++ b/passbook/stages/prompt/migrations/0002_auto_20200528_2059.py
@@ -0,0 +1,35 @@
+# Generated by Django 3.0.6 on 2020-05-28 20:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_prompt", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="prompt", name="order", field=models.IntegerField(default=0),
+ ),
+ migrations.AlterField(
+ model_name="prompt",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("text", "Text"),
+ ("e-mail", "Email"),
+ ("password", "Password"),
+ ("number", "Number"),
+ ("checkbox", "Checkbox"),
+ ("data", "Date"),
+ ("data-time", "Date Time"),
+ ("separator", "Separator"),
+ ("hidden", "Hidden"),
+ ("static", "Static"),
+ ],
+ max_length=100,
+ ),
+ ),
+ ]
diff --git a/passbook/stages/prompt/models.py b/passbook/stages/prompt/models.py
index 908d121b7..fc12c7b2e 100644
--- a/passbook/stages/prompt/models.py
+++ b/passbook/stages/prompt/models.py
@@ -16,7 +16,13 @@ class FieldTypes(models.TextChoices):
EMAIL = "e-mail"
PASSWORD = "password" # noqa # nosec
NUMBER = "number"
+ CHECKBOX = "checkbox"
+ DATE = "data"
+ DATE_TIME = "data-time"
+
+ SEPARATOR = "separator"
HIDDEN = "hidden"
+ STATIC = "static"
class Prompt(models.Model):
@@ -32,41 +38,37 @@ class Prompt(models.Model):
required = models.BooleanField(default=True)
placeholder = models.TextField()
+ order = models.IntegerField(default=0)
+
@property
def field(self):
"""Return instantiated form input field"""
attrs = {"placeholder": _(self.placeholder)}
- if self.type == FieldTypes.TEXT:
- return forms.CharField(
- label=_(self.label),
- widget=forms.TextInput(attrs=attrs),
- required=self.required,
- )
+ field_class = forms.CharField
+ widget = forms.TextInput(attrs=attrs)
+ kwargs = {
+ "label": _(self.label),
+ "required": self.required,
+ }
if self.type == FieldTypes.EMAIL:
- return forms.EmailField(
- label=_(self.label),
- widget=forms.TextInput(attrs=attrs),
- required=self.required,
- )
+ field_class = forms.EmailField
if self.type == FieldTypes.PASSWORD:
- return forms.CharField(
- label=_(self.label),
- widget=forms.PasswordInput(attrs=attrs),
- required=self.required,
- )
+ widget = forms.PasswordInput(attrs=attrs)
if self.type == FieldTypes.NUMBER:
- return forms.IntegerField(
- label=_(self.label),
- widget=forms.NumberInput(attrs=attrs),
- required=self.required,
- )
+ field_class = forms.IntegerField
+ widget = forms.NumberInput(attrs=attrs)
if self.type == FieldTypes.HIDDEN:
- return forms.CharField(
- widget=forms.HiddenInput(attrs=attrs),
- required=False,
- initial=self.placeholder,
- )
- raise ValueError("field_type is not valid, not one of FieldTypes.")
+ widget = forms.HiddenInput(attrs=attrs)
+ kwargs["required"] = False
+ kwargs["initial"] = self.placeholder
+ if self.type == FieldTypes.CHECKBOX:
+ field_class = forms.CheckboxInput
+ kwargs["required"] = False
+
+ # TODO: Implement static
+ # TODO: Implement separator
+ kwargs["widget"] = widget
+ return field_class(**kwargs)
def save(self, *args, **kwargs):
if self.type not in FieldTypes:
diff --git a/passbook/stages/prompt/tests.py b/passbook/stages/prompt/tests.py
index d4d2e108d..85b66c1f8 100644
--- a/passbook/stages/prompt/tests.py
+++ b/passbook/stages/prompt/tests.py
@@ -93,25 +93,6 @@ class TestPromptStage(TestCase):
FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
- def test_invalid_type(self):
- """Test that invalid form type raises an error"""
- with self.assertRaises(ValueError):
- _ = Prompt.objects.create(
- field_key="hidden_prompt",
- type="invalid",
- required=True,
- placeholder="HIDDEN_PLACEHOLDER",
- )
- with self.assertRaises(ValueError):
- prompt = Prompt.objects.create(
- field_key="hidden_prompt",
- type=FieldTypes.HIDDEN,
- required=True,
- placeholder="HIDDEN_PLACEHOLDER",
- )
- with patch.object(prompt, "type", MagicMock(return_value="invalid")):
- _ = prompt.field
-
def test_render(self):
"""Test render of form, check if all prompts are rendered correctly"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
@@ -139,7 +120,7 @@ class TestPromptStage(TestCase):
expr_policy = ExpressionPolicy.objects.create(
name="validate-form", expression=expr
)
- PolicyBinding.objects.create(policy=expr_policy, target=self.stage)
+ PolicyBinding.objects.create(policy=expr_policy, target=self.stage, order=0)
form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
self.assertEqual(form.is_valid(), True)
return form
@@ -151,7 +132,7 @@ class TestPromptStage(TestCase):
expr_policy = ExpressionPolicy.objects.create(
name="validate-form", expression=expr
)
- PolicyBinding.objects.create(policy=expr_policy, target=self.stage)
+ PolicyBinding.objects.create(policy=expr_policy, target=self.stage, order=0)
form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
self.assertEqual(form.is_valid(), False)
return form
diff --git a/passbook/stages/user_write/stage.py b/passbook/stages/user_write/stage.py
index 4910e0426..28eab9853 100644
--- a/passbook/stages/user_write/stage.py
+++ b/passbook/stages/user_write/stage.py
@@ -25,33 +25,30 @@ class UserWriteStageView(StageView):
LOGGER.debug(message)
return self.executor.stage_invalid()
data = self.executor.plan.context[PLAN_CONTEXT_PROMPT]
- if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
- user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
- for key, value in data.items():
- setter_name = f"set_{key}"
- # Check if user has a setter for this key, like set_password
- if hasattr(user, setter_name):
- setter = getattr(user, setter_name)
- if callable(setter):
- setter(value)
- # User has this key already
- elif hasattr(user, key):
- setattr(user, key, value)
- # Otherwise we just save it as custom attribute
- else:
- user.attributes[key] = value
- user.save()
- LOGGER.debug(
- "Updated existing user", user=user, flow_slug=self.executor.flow.slug,
- )
- else:
- user = User.objects.create_user(**data)
- # Set created user as pending_user, so this can be chained with user_login
- self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
+ if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User()
self.executor.plan.context[
PLAN_CONTEXT_AUTHENTICATION_BACKEND
] = class_to_path(ModelBackend)
LOGGER.debug(
- "Created new user", user=user, flow_slug=self.executor.flow.slug,
+ "Created new user", flow_slug=self.executor.flow.slug,
)
+ user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
+ for key, value in data.items():
+ setter_name = f"set_{key}"
+ # Check if user has a setter for this key, like set_password
+ if hasattr(user, setter_name):
+ setter = getattr(user, setter_name)
+ if callable(setter):
+ setter(value)
+ # User has this key already
+ elif hasattr(user, key):
+ setattr(user, key, value)
+ # Otherwise we just save it as custom attribute
+ else:
+ user.attributes[key] = value
+ user.save()
+ LOGGER.debug(
+ "Updated existing user", user=user, flow_slug=self.executor.flow.slug,
+ )
return self.executor.stage_ok()
diff --git a/passbook/stages/user_write/tests.py b/passbook/stages/user_write/tests.py
index 5bad06809..d37012207 100644
--- a/passbook/stages/user_write/tests.py
+++ b/passbook/stages/user_write/tests.py
@@ -72,6 +72,7 @@ class TestUserWriteStage(TestCase):
plan.context[PLAN_CONTEXT_PROMPT] = {
"username": "test-user-new",
"password": new_password,
+ "some-custom-attribute": "test",
}
session = self.client.session
session[SESSION_KEY_PLAN] = plan
@@ -88,6 +89,7 @@ class TestUserWriteStage(TestCase):
)
self.assertTrue(user_qs.exists())
self.assertTrue(user_qs.first().check_password(new_password))
+ self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test")
def test_without_data(self):
"""Test without data results in error"""
diff --git a/swagger.yaml b/swagger.yaml
index ce498dd6f..31c87f5be 100755
--- a/swagger.yaml
+++ b/swagger.yaml
@@ -837,7 +837,7 @@ paths:
parameters:
- name: policy_uuid
in: path
- description: A UUID string identifying this policy.
+ description: A UUID string identifying this Policy.
required: true
type: string
format: uuid
@@ -5079,19 +5079,6 @@ definitions:
title: Name
type: string
x-nullable: true
- negate:
- title: Negate
- type: boolean
- order:
- title: Order
- type: integer
- maximum: 2147483647
- minimum: -2147483648
- timeout:
- title: Timeout
- type: integer
- maximum: 2147483647
- minimum: -2147483648
__type__:
title: 'type '
type: string
@@ -5100,6 +5087,7 @@ definitions:
required:
- policy
- target
+ - order
type: object
properties:
policy:
@@ -5118,6 +5106,12 @@ definitions:
type: integer
maximum: 2147483647
minimum: -2147483648
+ timeout:
+ title: Timeout
+ description: Timeout after which Policy execution is terminated.
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
DummyPolicy:
type: object
properties:
@@ -5130,19 +5124,6 @@ definitions:
title: Name
type: string
x-nullable: true
- negate:
- title: Negate
- type: boolean
- order:
- title: Order
- type: integer
- maximum: 2147483647
- minimum: -2147483648
- timeout:
- title: Timeout
- type: integer
- maximum: 2147483647
- minimum: -2147483648
result:
title: Result
type: boolean
@@ -5170,19 +5151,6 @@ definitions:
title: Name
type: string
x-nullable: true
- negate:
- title: Negate
- type: boolean
- order:
- title: Order
- type: integer
- maximum: 2147483647
- minimum: -2147483648
- timeout:
- title: Timeout
- type: integer
- maximum: 2147483647
- minimum: -2147483648
expression:
title: Expression
type: string
@@ -5199,19 +5167,6 @@ definitions:
title: Name
type: string
x-nullable: true
- negate:
- title: Negate
- type: boolean
- order:
- title: Order
- type: integer
- maximum: 2147483647
- minimum: -2147483648
- timeout:
- title: Timeout
- type: integer
- maximum: 2147483647
- minimum: -2147483648
allowed_count:
title: Allowed count
type: integer
@@ -5231,19 +5186,6 @@ definitions:
title: Name
type: string
x-nullable: true
- negate:
- title: Negate
- type: boolean
- order:
- title: Order
- type: integer
- maximum: 2147483647
- minimum: -2147483648
- timeout:
- title: Timeout
- type: integer
- maximum: 2147483647
- minimum: -2147483648
amount_uppercase:
title: Amount uppercase
type: integer
@@ -5286,19 +5228,6 @@ definitions:
title: Name
type: string
x-nullable: true
- negate:
- title: Negate
- type: boolean
- order:
- title: Order
- type: integer
- maximum: 2147483647
- minimum: -2147483648
- timeout:
- title: Timeout
- type: integer
- maximum: 2147483647
- minimum: -2147483648
days:
title: Days
type: integer
@@ -5319,19 +5248,6 @@ definitions:
title: Name
type: string
x-nullable: true
- negate:
- title: Negate
- type: boolean
- order:
- title: Order
- type: integer
- maximum: 2147483647
- minimum: -2147483648
- timeout:
- title: Timeout
- type: integer
- maximum: 2147483647
- minimum: -2147483648
check_ip:
title: Check ip
type: boolean
@@ -5690,8 +5606,6 @@ definitions:
- bind_cn
- bind_password
- base_dn
- - additional_user_dn
- - additional_group_dn
type: object
properties:
pk:
@@ -5738,12 +5652,10 @@ definitions:
title: Addition User DN
description: Prepended to Base DN for User-queries.
type: string
- minLength: 1
additional_group_dn:
title: Addition Group DN
description: Prepended to Base DN for Group-queries.
type: string
- minLength: 1
user_object_filter:
title: User object filter
description: Consider Objects matching this filter to be Users.
@@ -5764,6 +5676,9 @@ definitions:
description: Field which contains a unique Identifier.
type: string
minLength: 1
+ sync_users:
+ title: Sync users
+ type: boolean
sync_groups:
title: Sync groups
type: boolean
@@ -6003,6 +5918,20 @@ definitions:
enum:
- stages/identification/login.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:
required:
- name
@@ -6112,7 +6041,12 @@ definitions:
- e-mail
- password
- number
+ - checkbox
+ - data
+ - data-time
+ - separator
- hidden
+ - static
required:
title: Required
type: boolean
@@ -6120,6 +6054,11 @@ definitions:
title: Placeholder
type: string
minLength: 1
+ order:
+ title: Order
+ type: integer
+ maximum: 2147483647
+ minimum: -2147483648
PromptStage:
required:
- name
|