Merge branch 'master' into azure-pipelines
# Conflicts: # .github/workflows/ci.yml
This commit is contained in:
commit
9882342ed1
|
@ -18,10 +18,10 @@
|
|||
"default": {
|
||||
"amqp": {
|
||||
"hashes": [
|
||||
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
|
||||
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
|
||||
"sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b",
|
||||
"sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139"
|
||||
],
|
||||
"version": "==2.5.2"
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"asgiref": {
|
||||
"hashes": [
|
||||
|
@ -53,18 +53,18 @@
|
|||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:1bdab4f87ff39d5aab59b0aae69965bf604fa5608984c673877f4c62c1f16240",
|
||||
"sha256:2b4924ccc1603d562969b9f3c8c74ff4a1f3bdbafe857c990422c73d8e2e229e"
|
||||
"sha256:26f8564b46d009b8f4c6470a6d6cde147b282a197339c7e31cbb0fe9fd9e5f5d",
|
||||
"sha256:f59d0bd230ed3a4b932c5c4e497a0e0ff3c93b46b7e8cde54efb6fe10c8266ba"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.13.18"
|
||||
"version": "==1.13.20"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:93574cf95a64c71d35c12c93a23f6214cf2f4b461be3bda3a436381cbe126a84",
|
||||
"sha256:e65eb27cae262a510e335bc0c0e286e9e42381b1da0aafaa79fa13c1d8d74a95"
|
||||
"sha256:990f3fc33dec746829740b1a9e1fe86183cdc96aedba6a632ccfcbae03e097cc",
|
||||
"sha256:d4cc47ac989a7f1d2992ef7679fb423a7966f687becf623a291a555a2d7ce1c0"
|
||||
],
|
||||
"version": "==1.16.18"
|
||||
"version": "==1.16.20"
|
||||
},
|
||||
"celery": {
|
||||
"hashes": [
|
||||
|
@ -364,11 +364,11 @@
|
|||
},
|
||||
"kombu": {
|
||||
"hashes": [
|
||||
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
|
||||
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
|
||||
"sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505",
|
||||
"sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.6.8"
|
||||
"version": "==4.6.9"
|
||||
},
|
||||
"ldap3": {
|
||||
"hashes": [
|
||||
|
@ -688,10 +688,10 @@
|
|||
},
|
||||
"redis": {
|
||||
"hashes": [
|
||||
"sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
|
||||
"sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
|
||||
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
|
||||
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
|
||||
],
|
||||
"version": "==3.5.2"
|
||||
"version": "==3.5.3"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
|
@ -858,10 +858,10 @@
|
|||
},
|
||||
"autopep8": {
|
||||
"hashes": [
|
||||
"sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954"
|
||||
"sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.5.2"
|
||||
"version": "==1.5.3"
|
||||
},
|
||||
"bandit": {
|
||||
"hashes": [
|
||||
|
@ -971,10 +971,10 @@
|
|||
},
|
||||
"gitpython": {
|
||||
"hashes": [
|
||||
"sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94",
|
||||
"sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09"
|
||||
"sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a",
|
||||
"sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"
|
||||
],
|
||||
"version": "==3.1.2"
|
||||
"version": "==3.1.3"
|
||||
},
|
||||
"isort": {
|
||||
"hashes": [
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""passbook administration forms"""
|
||||
from django import forms
|
||||
|
||||
from passbook.admin.fields import CodeMirrorWidget, YAMLField
|
||||
from passbook.core.models import User
|
||||
|
||||
|
||||
|
@ -8,3 +9,4 @@ class PolicyTestForm(forms.Form):
|
|||
"""Form to test policies against user"""
|
||||
|
||||
user = forms.ModelChoiceField(queryset=User.objects.all())
|
||||
context = YAMLField(widget=CodeMirrorWidget(), required=False, initial=dict)
|
||||
|
|
|
@ -55,15 +55,26 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
{% if factor_count < 1 %}
|
||||
<i class="pficon-error-circle-o"></i> {{ factor_count }}
|
||||
{% if stage_count < 1 %}
|
||||
<i class="pficon-error-circle-o"></i> {{ stage_count }}
|
||||
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
|
||||
{% else %}
|
||||
<i class="pf-icon pf-icon-ok"></i> {{ factor_count }}
|
||||
<i class="pf-icon pf-icon-ok"></i> {{ stage_count }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'passbook_admin:stages' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card__head">
|
||||
<div class="pf-c-card__head-main">
|
||||
<i class="pf-icon pf-icon-topology"></i> {% trans 'Flows' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<i class="pf-icon pf-icon-ok"></i> {{ flow_count }}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'passbook_admin:policies' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card__head">
|
||||
<div class="pf-c-card__head-main">
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
<th role="columnheader" scope="col">{% trans 'Field' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Label' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Type' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Order' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Flows' %}</th>
|
||||
<th role="cell"></th>
|
||||
</tr>
|
||||
|
@ -51,6 +52,11 @@
|
|||
{{ prompt.type }}
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<div>
|
||||
{{ prompt.order }}
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<ul>
|
||||
{% for flow in prompt.flow_set.all %}
|
||||
|
|
|
@ -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."))
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -2,6 +2,13 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="pf-c-form__group has-error">
|
||||
<p class="pf-c-form__helper-text pf-m-error">
|
||||
{{ form.non_field_errors }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for field in form %}
|
||||
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
|
||||
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
<div class="pf-c-form__horizontal-group">
|
||||
<div class="pf-c-form__actions">
|
||||
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
|
||||
{% if unenrollment_enabled %}
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)]
|
|
@ -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})"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"] = (
|
||||
self.set_http_request(request.http_request)
|
||||
|
||||
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:
|
||||
kwargs["pb_flow_plan"] = request.http_request.session[SESSION_KEY_PLAN]
|
||||
return kwargs
|
||||
self._context["pb_flow_plan"] = request.http_request.session[
|
||||
SESSION_KEY_PLAN
|
||||
]
|
||||
|
||||
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 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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",))
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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")},
|
||||
),
|
||||
]
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
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())
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -39,4 +39,6 @@ class PolicyResult:
|
|||
self.messages = messages
|
||||
|
||||
def __str__(self):
|
||||
return f"<PolicyResult passing={self.passing}>"
|
||||
if self.messages:
|
||||
return f"PolicyResult passing={self.passing} messages={self.messages}"
|
||||
return f"PolicyResult passing={self.passing}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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),
|
||||
]
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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())
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -16,6 +16,8 @@ class IdentificationStageSerializer(ModelSerializer):
|
|||
"name",
|
||||
"user_fields",
|
||||
"template",
|
||||
"enrollment_flow",
|
||||
"recovery_flow",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 3.0.6 on 2020-05-30 22:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_flows", "0002_default_flows"),
|
||||
("passbook_stages_identification", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="identificationstage",
|
||||
name="enrollment_flow",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Optional enrollment flow, which is linked at the bottom of the page.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
related_name="+",
|
||||
to="passbook_flows.Flow",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="identificationstage",
|
||||
name="recovery_flow",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Optional enrollment flow, which is linked at the bottom of the page.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
related_name="+",
|
||||
to="passbook_flows.Flow",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField
|
|||
from django.db import models
|
||||
from django.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"
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
|
@ -21,3 +22,35 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
<ul class="pf-c-login__main-footer-links">
|
||||
{% for source in sources %}
|
||||
<li class="pf-c-login__main-footer-links-item">
|
||||
<a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
|
||||
{% if source.icon_path %}
|
||||
<img src="{% static source.icon_path %}" alt="{{ source.name }}">
|
||||
{% elif source.icon_url %}
|
||||
<img src="icon_url" alt="{{ source.name }}">
|
||||
{% else %}
|
||||
<i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if enroll_url or recovery_url %}
|
||||
<div class="pf-c-login__main-footer-band">
|
||||
{% if enroll_url %}
|
||||
<p class="pf-c-login__main-footer-band-item">
|
||||
{% trans 'Need an account?' %}
|
||||
<a href="{{ enroll_url }}">{% trans 'Sign up.' %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if recovery_url %}
|
||||
<p class="pf-c-login__main-footer-band-item">
|
||||
<a href="{{ recovery_url }}">{% trans 'Forgot username or password?' %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</footer>
|
||||
|
|
|
@ -1,32 +1,6 @@
|
|||
{% extends 'base/skeleton.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block body %}
|
||||
<div class="pf-c-background-image">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
|
||||
<filter id="image_overlay">
|
||||
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
|
||||
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
|
||||
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
|
||||
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
|
||||
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
|
||||
<feFuncA type="table" tableValues="0 1"></feFuncA>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
</svg>
|
||||
</div>
|
||||
{% include 'partials/messages.html' %}
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<header class="pf-c-login__header">
|
||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
|
||||
alt="passbook icon" />
|
||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
|
||||
alt="passbook branding" />
|
||||
</header>
|
||||
<main class="pf-c-login__main">
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% trans 'Trouble Logging In?' %}
|
||||
|
@ -53,20 +27,3 @@
|
|||
<p>{{ config.login.subtext }}</p>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</main>
|
||||
<footer class="pf-c-login__footer">
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
<li>
|
||||
<a href="https://beryju.github.io/passbook/">{% trans 'Documentation' %}</a>
|
||||
</li>
|
||||
{% config 'passbook.footer_links' as footer_links %}
|
||||
{% for link in footer_links %}
|
||||
<li>
|
||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase):
|
|||
slug="unique-enrollment-string",
|
||||
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)
|
||||
|
|
|
@ -38,6 +38,7 @@ class PromptSerializer(ModelSerializer):
|
|||
"type",
|
||||
"required",
|
||||
"placeholder",
|
||||
"order",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -25,7 +25,14 @@ 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:
|
||||
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", 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}"
|
||||
|
@ -44,14 +51,4 @@ class UserWriteStageView(StageView):
|
|||
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
|
||||
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,
|
||||
)
|
||||
return self.executor.stage_ok()
|
||||
|
|
|
@ -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"""
|
||||
|
|
131
swagger.yaml
131
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
|
||||
|
|
Reference in New Issue