Merge branch 'master' into azure-pipelines
# Conflicts: # .github/workflows/ci.yml
This commit is contained in:
commit
9882342ed1
|
@ -18,10 +18,10 @@
|
||||||
"default": {
|
"default": {
|
||||||
"amqp": {
|
"amqp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
|
"sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b",
|
||||||
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
|
"sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139"
|
||||||
],
|
],
|
||||||
"version": "==2.5.2"
|
"version": "==2.6.0"
|
||||||
},
|
},
|
||||||
"asgiref": {
|
"asgiref": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -53,18 +53,18 @@
|
||||||
},
|
},
|
||||||
"boto3": {
|
"boto3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1bdab4f87ff39d5aab59b0aae69965bf604fa5608984c673877f4c62c1f16240",
|
"sha256:26f8564b46d009b8f4c6470a6d6cde147b282a197339c7e31cbb0fe9fd9e5f5d",
|
||||||
"sha256:2b4924ccc1603d562969b9f3c8c74ff4a1f3bdbafe857c990422c73d8e2e229e"
|
"sha256:f59d0bd230ed3a4b932c5c4e497a0e0ff3c93b46b7e8cde54efb6fe10c8266ba"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.13.18"
|
"version": "==1.13.20"
|
||||||
},
|
},
|
||||||
"botocore": {
|
"botocore": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:93574cf95a64c71d35c12c93a23f6214cf2f4b461be3bda3a436381cbe126a84",
|
"sha256:990f3fc33dec746829740b1a9e1fe86183cdc96aedba6a632ccfcbae03e097cc",
|
||||||
"sha256:e65eb27cae262a510e335bc0c0e286e9e42381b1da0aafaa79fa13c1d8d74a95"
|
"sha256:d4cc47ac989a7f1d2992ef7679fb423a7966f687becf623a291a555a2d7ce1c0"
|
||||||
],
|
],
|
||||||
"version": "==1.16.18"
|
"version": "==1.16.20"
|
||||||
},
|
},
|
||||||
"celery": {
|
"celery": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -364,11 +364,11 @@
|
||||||
},
|
},
|
||||||
"kombu": {
|
"kombu": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
|
"sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505",
|
||||||
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
|
"sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.6.8"
|
"version": "==4.6.9"
|
||||||
},
|
},
|
||||||
"ldap3": {
|
"ldap3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -688,10 +688,10 @@
|
||||||
},
|
},
|
||||||
"redis": {
|
"redis": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
|
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
|
||||||
"sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
|
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
|
||||||
],
|
],
|
||||||
"version": "==3.5.2"
|
"version": "==3.5.3"
|
||||||
},
|
},
|
||||||
"requests": {
|
"requests": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -858,10 +858,10 @@
|
||||||
},
|
},
|
||||||
"autopep8": {
|
"autopep8": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954"
|
"sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.5.2"
|
"version": "==1.5.3"
|
||||||
},
|
},
|
||||||
"bandit": {
|
"bandit": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -971,10 +971,10 @@
|
||||||
},
|
},
|
||||||
"gitpython": {
|
"gitpython": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94",
|
"sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a",
|
||||||
"sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09"
|
"sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"
|
||||||
],
|
],
|
||||||
"version": "==3.1.2"
|
"version": "==3.1.3"
|
||||||
},
|
},
|
||||||
"isort": {
|
"isort": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""passbook administration forms"""
|
"""passbook administration forms"""
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
|
from passbook.admin.fields import CodeMirrorWidget, YAMLField
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,3 +9,4 @@ class PolicyTestForm(forms.Form):
|
||||||
"""Form to test policies against user"""
|
"""Form to test policies against user"""
|
||||||
|
|
||||||
user = forms.ModelChoiceField(queryset=User.objects.all())
|
user = forms.ModelChoiceField(queryset=User.objects.all())
|
||||||
|
context = YAMLField(widget=CodeMirrorWidget(), required=False, initial=dict)
|
||||||
|
|
|
@ -55,15 +55,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-c-card__body">
|
<div class="pf-c-card__body">
|
||||||
{% if factor_count < 1 %}
|
{% if stage_count < 1 %}
|
||||||
<i class="pficon-error-circle-o"></i> {{ factor_count }}
|
<i class="pficon-error-circle-o"></i> {{ stage_count }}
|
||||||
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
|
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="pf-icon pf-icon-ok"></i> {{ factor_count }}
|
<i class="pf-icon pf-icon-ok"></i> {{ stage_count }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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">
|
<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">
|
||||||
<div class="pf-c-card__head-main">
|
<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 'Field' %}</th>
|
||||||
<th role="columnheader" scope="col">{% trans 'Label' %}</th>
|
<th role="columnheader" scope="col">{% trans 'Label' %}</th>
|
||||||
<th role="columnheader" scope="col">{% trans 'Type' %}</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="columnheader" scope="col">{% trans 'Flows' %}</th>
|
||||||
<th role="cell"></th>
|
<th role="cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -51,6 +52,11 @@
|
||||||
{{ prompt.type }}
|
{{ prompt.type }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td role="cell">
|
||||||
|
<div>
|
||||||
|
{{ prompt.order }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td role="cell">
|
<td role="cell">
|
||||||
<ul>
|
<ul>
|
||||||
{% for flow in prompt.flow_set.all %}
|
{% for flow in prompt.flow_set.all %}
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
"""passbook Policy administration"""
|
"""passbook Policy administration"""
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.auth.mixins import (
|
from django.contrib.auth.mixins import (
|
||||||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||||
)
|
)
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
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.urls import reverse_lazy
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.views.generic import DeleteView, FormView, ListView, UpdateView
|
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.admin.forms.policies import PolicyTestForm
|
||||||
from passbook.lib.utils.reflection import all_subclasses, path_to_class
|
from passbook.lib.utils.reflection import all_subclasses, path_to_class
|
||||||
from passbook.lib.views import CreateAssignPermView
|
from passbook.lib.views import CreateAssignPermView
|
||||||
from passbook.policies.engine import PolicyEngine
|
from passbook.policies.models import Policy, PolicyBinding
|
||||||
from passbook.policies.models import Policy
|
from passbook.policies.process import PolicyProcess, PolicyRequest
|
||||||
|
|
||||||
|
|
||||||
class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||||
|
@ -25,14 +29,14 @@ class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||||
model = Policy
|
model = Policy
|
||||||
permission_required = "passbook_policies.view_policy"
|
permission_required = "passbook_policies.view_policy"
|
||||||
paginate_by = 10
|
paginate_by = 10
|
||||||
ordering = "order"
|
ordering = "name"
|
||||||
template_name = "administration/policy/list.html"
|
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)}
|
kwargs["types"] = {x.__name__: x for x in all_subclasses(Policy)}
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self) -> QuerySet:
|
||||||
return super().get_queryset().select_subclasses()
|
return super().get_queryset().select_subclasses()
|
||||||
|
|
||||||
|
|
||||||
|
@ -51,14 +55,14 @@ class PolicyCreateView(
|
||||||
success_url = reverse_lazy("passbook_admin:policies")
|
success_url = reverse_lazy("passbook_admin:policies")
|
||||||
success_message = _("Successfully created Policy")
|
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)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
form_cls = self.get_form_class()
|
form_cls = self.get_form_class()
|
||||||
if hasattr(form_cls, "template_name"):
|
if hasattr(form_cls, "template_name"):
|
||||||
kwargs["base_template"] = form_cls.template_name
|
kwargs["base_template"] = form_cls.template_name
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_form_class(self):
|
def get_form_class(self) -> Form:
|
||||||
policy_type = self.request.GET.get("type")
|
policy_type = self.request.GET.get("type")
|
||||||
try:
|
try:
|
||||||
model = next(x for x in all_subclasses(Policy) if x.__name__ == policy_type)
|
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_url = reverse_lazy("passbook_admin:policies")
|
||||||
success_message = _("Successfully updated Policy")
|
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)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
form_cls = self.get_form_class()
|
form_cls = self.get_form_class()
|
||||||
if hasattr(form_cls, "template_name"):
|
if hasattr(form_cls, "template_name"):
|
||||||
kwargs["base_template"] = form_cls.template_name
|
kwargs["base_template"] = form_cls.template_name
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_form_class(self):
|
def get_form_class(self) -> Form:
|
||||||
form_class_path = self.get_object().form
|
form_class_path = self.get_object().form
|
||||||
form_class = path_to_class(form_class_path)
|
form_class = path_to_class(form_class_path)
|
||||||
return form_class
|
return form_class
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None) -> Policy:
|
||||||
return (
|
return (
|
||||||
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
|
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_url = reverse_lazy("passbook_admin:policies")
|
||||||
success_message = _("Successfully deleted Policy")
|
success_message = _("Successfully deleted Policy")
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None) -> Policy:
|
||||||
return (
|
return (
|
||||||
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
|
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)
|
messages.success(self.request, self.success_message)
|
||||||
return super().delete(request, *args, **kwargs)
|
return super().delete(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -128,27 +132,30 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo
|
||||||
template_name = "administration/policy/test.html"
|
template_name = "administration/policy/test.html"
|
||||||
object = None
|
object = None
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None) -> QuerySet:
|
||||||
return (
|
return (
|
||||||
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
|
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()
|
kwargs["policy"] = self.get_object()
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
def post(self, *args, **kwargs):
|
def post(self, *args, **kwargs) -> HttpResponse:
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
return super().post(*args, **kwargs)
|
return super().post(*args, **kwargs)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form: PolicyTestForm) -> HttpResponse:
|
||||||
policy = self.get_object()
|
policy = self.get_object()
|
||||||
user = form.cleaned_data.get("user")
|
user = form.cleaned_data.get("user")
|
||||||
policy_engine = PolicyEngine([policy], user, self.request)
|
|
||||||
policy_engine.use_cache = False
|
p_request = PolicyRequest(user)
|
||||||
policy_engine.build()
|
p_request.http_request = self.request
|
||||||
result = policy_engine.passing
|
p_request.context = form.cleaned_data
|
||||||
if result:
|
|
||||||
|
proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
|
||||||
|
result = proc.execute()
|
||||||
|
if result.passing:
|
||||||
messages.success(self.request, _("User successfully passed policy."))
|
messages.success(self.request, _("User successfully passed policy."))
|
||||||
else:
|
else:
|
||||||
messages.error(self.request, _("User didn't pass policy."))
|
messages.error(self.request, _("User didn't pass policy."))
|
||||||
|
|
|
@ -20,7 +20,7 @@ class PromptListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||||
|
|
||||||
model = Prompt
|
model = Prompt
|
||||||
permission_required = "passbook_stages_prompt.view_prompt"
|
permission_required = "passbook_stages_prompt.view_prompt"
|
||||||
ordering = "field_key"
|
ordering = "order"
|
||||||
paginate_by = 40
|
paginate_by = 40
|
||||||
template_name = "administration/stage_prompt/list.html"
|
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 guardian.mixins import GuardianUserMixin
|
||||||
from jinja2 import Undefined
|
from jinja2 import Undefined
|
||||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
||||||
from jinja2.nativetypes import NativeEnvironment
|
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
|
@ -24,7 +23,6 @@ from passbook.lib.models import CreatedUpdatedModel
|
||||||
from passbook.policies.models import PolicyBindingModel
|
from passbook.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
NATIVE_ENVIRONMENT = NativeEnvironment()
|
|
||||||
|
|
||||||
|
|
||||||
def default_token_duration():
|
def default_token_duration():
|
||||||
|
@ -208,8 +206,11 @@ class PropertyMapping(models.Model):
|
||||||
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Evaluate `self.expression` using `**kwargs` as Context."""
|
"""Evaluate `self.expression` using `**kwargs` as Context."""
|
||||||
|
from passbook.policies.expression.evaluator import Evaluator
|
||||||
|
|
||||||
|
evaluator = Evaluator()
|
||||||
try:
|
try:
|
||||||
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
|
expression = evaluator.env.from_string(self.expression)
|
||||||
except TemplateSyntaxError as exc:
|
except TemplateSyntaxError as exc:
|
||||||
raise PropertyMappingExpressionException from exc
|
raise PropertyMappingExpressionException from exc
|
||||||
try:
|
try:
|
||||||
|
@ -221,8 +222,11 @@ class PropertyMapping(models.Model):
|
||||||
raise PropertyMappingExpressionException from exc
|
raise PropertyMappingExpressionException from exc
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
from passbook.policies.expression.evaluator import Evaluator
|
||||||
|
|
||||||
|
evaluator = Evaluator()
|
||||||
try:
|
try:
|
||||||
NATIVE_ENVIRONMENT.from_string(self.expression)
|
evaluator.env.from_string(self.expression)
|
||||||
except TemplateSyntaxError as exc:
|
except TemplateSyntaxError as exc:
|
||||||
raise ValidationError("Expression Syntax Error") from exc
|
raise ValidationError("Expression Syntax Error") from exc
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
@ -1,28 +1,7 @@
|
||||||
"""passbook core signals"""
|
"""passbook core signals"""
|
||||||
from django.core.cache import cache
|
|
||||||
from django.core.signals import Signal
|
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"])
|
user_signed_up = Signal(providing_args=["request", "user"])
|
||||||
invitation_created = Signal(providing_args=["request", "invitation"])
|
invitation_created = Signal(providing_args=["request", "invitation"])
|
||||||
invitation_used = Signal(providing_args=["request", "invitation", "user"])
|
invitation_used = Signal(providing_args=["request", "invitation", "user"])
|
||||||
password_changed = Signal(providing_args=["user", "password"])
|
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 %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="pf-c-form__group has-error">
|
||||||
|
<p class="pf-c-form__helper-text pf-m-error">
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
|
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
|
||||||
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
<div class="pf-c-form__horizontal-group">
|
<div class="pf-c-form__horizontal-group">
|
||||||
<div class="pf-c-form__actions">
|
<div class="pf-c-form__actions">
|
||||||
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
"""passbook access helper classes"""
|
"""passbook access helper classes"""
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext as _
|
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.core.models import Application, Provider, User
|
||||||
from passbook.policies.engine import PolicyEngine
|
from passbook.policies.engine import PolicyEngine
|
||||||
|
from passbook.policies.types import PolicyResult
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
@ -33,9 +32,7 @@ class AccessMixin:
|
||||||
)
|
)
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
def user_has_access(
|
def user_has_access(self, application: Application, user: User) -> PolicyResult:
|
||||||
self, application: Application, user: User
|
|
||||||
) -> Tuple[bool, List[str]]:
|
|
||||||
"""Check if user has access to application."""
|
"""Check if user has access to application."""
|
||||||
LOGGER.debug("Checking permissions", user=user, application=application)
|
LOGGER.debug("Checking permissions", user=user, application=application)
|
||||||
policy_engine = PolicyEngine(application.policies.all(), user, self.request)
|
policy_engine = PolicyEngine(application.policies.all(), user, self.request)
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""passbook core user views"""
|
"""passbook core user views"""
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
@ -6,6 +8,7 @@ from django.utils.translation import gettext as _
|
||||||
from django.views.generic import UpdateView
|
from django.views.generic import UpdateView
|
||||||
|
|
||||||
from passbook.core.forms.users import UserDetailForm
|
from passbook.core.forms.users import UserDetailForm
|
||||||
|
from passbook.flows.models import Flow, FlowDesignation
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
|
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
@ -19,3 +22,11 @@ class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
return self.request.user
|
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,
|
password=None,
|
||||||
backend=default_backend(),
|
backend=default_backend(),
|
||||||
)
|
)
|
||||||
load_pem_x509_certificate(key_data.encode("utf-8"), default_backend())
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise forms.ValidationError("Unable to load private key.")
|
raise forms.ValidationError("Unable to load private key.")
|
||||||
return key_data
|
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 uuid import uuid4
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.types import UIUserSettings
|
from passbook.core.types import UIUserSettings
|
||||||
from passbook.policies.models import PolicyBindingModel
|
from passbook.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class FlowDesignation(models.TextChoices):
|
class FlowDesignation(models.TextChoices):
|
||||||
"""Designation of what a Flow should be used for. At a later point, this
|
"""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="+"
|
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
|
"""Get a related flow with `designation`. Currently this only queries
|
||||||
Flows by `designation`, but will eventually use `self` for related lookups."""
|
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:
|
def __str__(self) -> str:
|
||||||
return f"Flow {self.name} ({self.slug})"
|
return f"Flow {self.name} ({self.slug})"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""Flows Planner"""
|
"""Flows Planner"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from time import time
|
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.core.cache import cache
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
@ -51,22 +51,12 @@ class FlowPlanner:
|
||||||
self.use_cache = True
|
self.use_cache = True
|
||||||
self.flow = flow
|
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(
|
def plan(
|
||||||
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
|
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
|
||||||
) -> FlowPlan:
|
) -> FlowPlan:
|
||||||
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
|
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
|
||||||
and return ordered list"""
|
and return ordered list"""
|
||||||
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
|
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
|
# Bit of a workaround here, if there is a pending user set in the default context
|
||||||
# we use that user for our cache key
|
# we use that user for our cache key
|
||||||
# to make sure they don't get the generic response
|
# to make sure they don't get the generic response
|
||||||
|
@ -74,14 +64,24 @@ class FlowPlanner:
|
||||||
user = default_context[PLAN_CONTEXT_PENDING_USER]
|
user = default_context[PLAN_CONTEXT_PENDING_USER]
|
||||||
else:
|
else:
|
||||||
user = request.user
|
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_key = cache_key(self.flow, user)
|
||||||
cached_plan = cache.get(cached_plan_key, None)
|
cached_plan = cache.get(cached_plan_key, None)
|
||||||
if cached_plan and self.use_cache:
|
if cached_plan and self.use_cache:
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
|
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
|
||||||
)
|
)
|
||||||
LOGGER.debug(cached_plan)
|
|
||||||
return cached_plan
|
return cached_plan
|
||||||
|
LOGGER.debug("f(plan): building plan", flow=self.flow)
|
||||||
plan = self._build_plan(user, request, default_context)
|
plan = self._build_plan(user, request, default_context)
|
||||||
cache.set(cache_key(self.flow, user), plan)
|
cache.set(cache_key(self.flow, user), plan)
|
||||||
if not plan.stages:
|
if not plan.stages:
|
||||||
|
@ -106,11 +106,10 @@ class FlowPlanner:
|
||||||
.select_related()
|
.select_related()
|
||||||
):
|
):
|
||||||
binding = stage.flowstagebinding_set.get(flow__pk=self.flow.pk)
|
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.request.context = plan.context
|
||||||
engine.build()
|
engine.build()
|
||||||
passing, _ = engine.result
|
if engine.passing:
|
||||||
if passing:
|
|
||||||
LOGGER.debug("f(plan): Stage passing", stage=stage, flow=self.flow)
|
LOGGER.debug("f(plan): Stage passing", stage=stage, flow=self.flow)
|
||||||
plan.stages.append(stage)
|
plan.stages.append(stage)
|
||||||
end_time = time()
|
end_time = time()
|
||||||
|
|
|
@ -138,11 +138,10 @@ const loadFormCode = () => {
|
||||||
newScript.src = script.src;
|
newScript.src = script.src;
|
||||||
document.head.appendChild(newScript);
|
document.head.appendChild(newScript);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
const setFormSubmitHandlers = () => {
|
const setFormSubmitHandlers = () => {
|
||||||
document.querySelectorAll("#flow-body form").forEach(form => {
|
document.querySelectorAll("#flow-body form").forEach(form => {
|
||||||
console.log(`Setting action for form ${form}`);
|
console.log(`Setting action for form ${form}`);
|
||||||
// debugger;
|
|
||||||
form.action = flowBodyUrl;
|
form.action = flowBodyUrl;
|
||||||
console.log(`Adding handler for form ${form}`);
|
console.log(`Adding handler for form ${form}`);
|
||||||
form.addEventListener('submit', (e) => {
|
form.addEventListener('submit', (e) => {
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
"""flow planner tests"""
|
"""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.shortcuts import reverse
|
||||||
from django.test import RequestFactory, TestCase
|
from django.test import RequestFactory, TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
from passbook.flows.planner import FlowPlanner
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
|
||||||
|
from passbook.policies.types import PolicyResult
|
||||||
from passbook.stages.dummy.models import DummyStage
|
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)
|
TIME_NOW_MOCK = MagicMock(return_value=3)
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,8 +40,7 @@ class TestFlowPlanner(TestCase):
|
||||||
planner.plan(request)
|
planner.plan(request)
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
|
"passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
|
||||||
POLICY_RESULT_MOCK,
|
|
||||||
)
|
)
|
||||||
def test_non_applicable_plan(self):
|
def test_non_applicable_plan(self):
|
||||||
"""Test that empty plan raises exception"""
|
"""Test that empty plan raises exception"""
|
||||||
|
@ -80,3 +82,24 @@ class TestFlowPlanner(TestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
TIME_NOW_MOCK.call_count, 2
|
TIME_NOW_MOCK.call_count, 2
|
||||||
) # When taking from cache, time is not measured
|
) # When taking from cache, time is not measured
|
||||||
|
|
||||||
|
def test_planner_default_context(self):
|
||||||
|
"""Test planner with default_context"""
|
||||||
|
flow = Flow.objects.create(
|
||||||
|
name="test-default-context",
|
||||||
|
slug="test-default-context",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
FlowStageBinding.objects.create(
|
||||||
|
flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User.objects.create(username="test-user")
|
||||||
|
request = self.request_factory.get(
|
||||||
|
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
|
)
|
||||||
|
request.user = user
|
||||||
|
planner = FlowPlanner(flow)
|
||||||
|
planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user})
|
||||||
|
key = cache_key(flow, user)
|
||||||
|
self.assertTrue(cache.get(key) is not None)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
"""flow views tests"""
|
"""flow views tests"""
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, PropertyMock, patch
|
||||||
|
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
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.planner import FlowPlan
|
||||||
from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
|
from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
|
||||||
from passbook.lib.config import CONFIG
|
from passbook.lib.config import CONFIG
|
||||||
|
from passbook.policies.types import PolicyResult
|
||||||
from passbook.stages.dummy.models import DummyStage
|
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):
|
class TestFlowExecutor(TestCase):
|
||||||
|
@ -44,8 +45,7 @@ class TestFlowExecutor(TestCase):
|
||||||
self.assertEqual(cancel_mock.call_count, 1)
|
self.assertEqual(cancel_mock.call_count, 1)
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
|
"passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
|
||||||
POLICY_RESULT_MOCK,
|
|
||||||
)
|
)
|
||||||
def test_invalid_non_applicable_flow(self):
|
def test_invalid_non_applicable_flow(self):
|
||||||
"""Tests that a non-applicable flow returns the correct error message"""
|
"""Tests that a non-applicable flow returns the correct error message"""
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""passbook multi-stage authentication engine"""
|
"""passbook multi-stage authentication engine"""
|
||||||
from typing import Any, Dict, Optional
|
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.shortcuts import get_object_or_404, redirect, reverse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
|
@ -34,7 +34,7 @@ class FlowExecutorView(View):
|
||||||
|
|
||||||
def setup(self, request: HttpRequest, flow_slug: str):
|
def setup(self, request: HttpRequest, flow_slug: str):
|
||||||
super().setup(request, flow_slug=flow_slug)
|
super().setup(request, flow_slug=flow_slug)
|
||||||
self.flow = get_object_or_404(Flow, slug=flow_slug)
|
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
|
||||||
|
|
||||||
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
||||||
"""When a flow is non-applicable check if user is on the correct domain"""
|
"""When a flow is non-applicable check if user is on the correct domain"""
|
||||||
|
@ -164,7 +164,9 @@ class ToDefaultFlow(View):
|
||||||
designation: Optional[FlowDesignation] = None
|
designation: Optional[FlowDesignation] = None
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
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 user already has a pending plan, clear it so we don't have to later.
|
||||||
if SESSION_KEY_PLAN in self.request.session:
|
if SESSION_KEY_PLAN in self.request.session:
|
||||||
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""Generic models"""
|
"""Generic models"""
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from model_utils.managers import InheritanceManager
|
||||||
|
|
||||||
|
|
||||||
class CreatedUpdatedModel(models.Model):
|
class CreatedUpdatedModel(models.Model):
|
||||||
|
@ -10,3 +11,27 @@ class CreatedUpdatedModel(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
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:
|
class Meta:
|
||||||
|
|
||||||
model = PolicyBinding
|
model = PolicyBinding
|
||||||
fields = ["policy", "target", "enabled", "order"]
|
fields = ["policy", "target", "enabled", "order", "timeout"]
|
||||||
|
|
||||||
|
|
||||||
class PolicyBindingViewSet(ModelViewSet):
|
class PolicyBindingViewSet(ModelViewSet):
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""passbook policies app config"""
|
"""passbook policies app config"""
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,3 +10,7 @@ class PassbookPoliciesConfig(AppConfig):
|
||||||
name = "passbook.policies"
|
name = "passbook.policies"
|
||||||
label = "passbook_policies"
|
label = "passbook_policies"
|
||||||
verbose_name = "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"""
|
"""passbook policy engine"""
|
||||||
from multiprocessing import Pipe, set_start_method
|
from multiprocessing import Pipe, set_start_method
|
||||||
from multiprocessing.connection import Connection
|
from multiprocessing.connection import Connection
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.models import User
|
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.process import PolicyProcess, cache_key
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
|
@ -24,12 +24,14 @@ class PolicyProcessInfo:
|
||||||
process: PolicyProcess
|
process: PolicyProcess
|
||||||
connection: Connection
|
connection: Connection
|
||||||
result: Optional[PolicyResult]
|
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.process = process
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.policy = policy
|
self.binding = binding
|
||||||
self.result = None
|
self.result = None
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,68 +39,84 @@ class PolicyEngine:
|
||||||
"""Orchestrate policy checking, launch tasks and return result"""
|
"""Orchestrate policy checking, launch tasks and return result"""
|
||||||
|
|
||||||
use_cache: bool = True
|
use_cache: bool = True
|
||||||
policies: List[Policy] = []
|
|
||||||
request: PolicyRequest
|
request: PolicyRequest
|
||||||
|
|
||||||
|
__pbm: PolicyBindingModel
|
||||||
__cached_policies: List[PolicyResult]
|
__cached_policies: List[PolicyResult]
|
||||||
__processes: List[PolicyProcessInfo]
|
__processes: List[PolicyProcessInfo]
|
||||||
|
|
||||||
def __init__(self, policies, user: User, request: HttpRequest = None):
|
def __init__(
|
||||||
self.policies = policies
|
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)
|
self.request = PolicyRequest(user)
|
||||||
if request:
|
if request:
|
||||||
self.request.http_request = request
|
self.request.http_request = request
|
||||||
self.__cached_policies = []
|
self.__cached_policies = []
|
||||||
self.__processes = []
|
self.__processes = []
|
||||||
|
|
||||||
def _select_subclasses(self) -> List[Policy]:
|
def _iter_bindings(self) -> List[PolicyBinding]:
|
||||||
"""Make sure all Policies are their respective classes"""
|
"""Make sure all Policies are their respective classes"""
|
||||||
return (
|
return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by(
|
||||||
Policy.objects.filter(pk__in=[x.pk for x in self.policies])
|
"order"
|
||||||
.select_subclasses()
|
|
||||||
.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":
|
def build(self) -> "PolicyEngine":
|
||||||
"""Build task group"""
|
"""Build task group"""
|
||||||
for policy in self._select_subclasses():
|
for binding in self._iter_bindings():
|
||||||
cached_policy = cache.get(cache_key(policy, self.request.user), None)
|
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:
|
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)
|
self.__cached_policies.append(cached_policy)
|
||||||
continue
|
continue
|
||||||
LOGGER.debug("P_ENG: Evaluating policy", policy=policy)
|
LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy)
|
||||||
our_end, task_end = Pipe(False)
|
our_end, task_end = Pipe(False)
|
||||||
task = PolicyProcess(policy, self.request, task_end)
|
task = PolicyProcess(binding, self.request, task_end)
|
||||||
LOGGER.debug("P_ENG: Starting Process", policy=policy)
|
LOGGER.debug("P_ENG: Starting Process", policy=binding.policy)
|
||||||
task.start()
|
task.start()
|
||||||
self.__processes.append(
|
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.
|
# If all policies are cached, we have an empty list here.
|
||||||
for proc_info in self.__processes:
|
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
|
# Only call .recv() if no result is saved, otherwise we just deadlock here
|
||||||
if not proc_info.result:
|
if not proc_info.result:
|
||||||
proc_info.result = proc_info.connection.recv()
|
proc_info.result = proc_info.connection.recv()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def result(self) -> Tuple[bool, List[str]]:
|
def result(self) -> PolicyResult:
|
||||||
"""Get policy-checking result"""
|
"""Get policy-checking result"""
|
||||||
messages: List[str] = []
|
messages: List[str] = []
|
||||||
process_results: List[PolicyResult] = [
|
process_results: List[PolicyResult] = [
|
||||||
x.result for x in self.__processes if x.result
|
x.result for x in self.__processes if x.result
|
||||||
]
|
]
|
||||||
for result in process_results + self.__cached_policies:
|
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:
|
if result.messages:
|
||||||
messages += result.messages
|
messages += result.messages
|
||||||
if not result.passing:
|
if not result.passing:
|
||||||
return False, messages
|
return PolicyResult(False, *messages)
|
||||||
return True, messages
|
return PolicyResult(True, *messages)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def passing(self) -> bool:
|
def passing(self) -> bool:
|
||||||
"""Only get true/false if user passes"""
|
"""Only get true/false if user passes"""
|
||||||
return self.result[0]
|
return self.result.passing
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
"""passbook expression policy evaluator"""
|
"""passbook expression policy evaluator"""
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import HttpRequest
|
||||||
from jinja2 import Undefined
|
from jinja2 import Undefined
|
||||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
from jinja2.exceptions import TemplateSyntaxError
|
||||||
from jinja2.nativetypes import NativeEnvironment
|
from jinja2.nativetypes import NativeEnvironment
|
||||||
from requests import Session
|
from requests import Session
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_SSO
|
from passbook.flows.planner import PLAN_CONTEXT_SSO
|
||||||
from passbook.flows.views import SESSION_KEY_PLAN
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
from passbook.lib.utils.http import get_client_ip
|
from passbook.lib.utils.http import get_client_ip
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from passbook.core.models import User
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,12 +24,33 @@ class Evaluator:
|
||||||
|
|
||||||
_env: NativeEnvironment
|
_env: NativeEnvironment
|
||||||
|
|
||||||
|
_context: Dict[str, Any]
|
||||||
|
_messages: List[str]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._env = NativeEnvironment()
|
self._env = NativeEnvironment(
|
||||||
|
extensions=["jinja2.ext.do"],
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
line_statement_prefix=">",
|
||||||
|
)
|
||||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||||
# update docs/policies/expression/index.md
|
# update docs/policies/expression/index.md
|
||||||
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
|
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
|
||||||
self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace
|
self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace
|
||||||
|
self._env.globals["pb_message"] = self.jinja2_func_message
|
||||||
|
self._context = {
|
||||||
|
"pb_is_group_member": Evaluator.jinja2_func_is_group_member,
|
||||||
|
"pb_user_by": Evaluator.jinja2_func_user_by,
|
||||||
|
"pb_logger": get_logger(),
|
||||||
|
"requests": Session(),
|
||||||
|
}
|
||||||
|
self._messages = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def env(self) -> NativeEnvironment:
|
||||||
|
"""Access to our custom NativeEnvironment"""
|
||||||
|
return self._env
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
|
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
|
||||||
|
@ -43,55 +63,69 @@ class Evaluator:
|
||||||
return re.sub(regex, repl, value)
|
return re.sub(regex, repl, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def jinja2_func_is_group_member(user: "User", group_name: str) -> bool:
|
def jinja2_func_user_by(**filters) -> Optional[User]:
|
||||||
|
"""Get user by filters"""
|
||||||
|
users = User.objects.filter(**filters)
|
||||||
|
if users:
|
||||||
|
return users.first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def jinja2_func_is_group_member(user: User, group_name: str) -> bool:
|
||||||
"""Check if `user` is member of group with name `group_name`"""
|
"""Check if `user` is member of group with name `group_name`"""
|
||||||
return user.groups.filter(name=group_name).exists()
|
return user.groups.filter(name=group_name).exists()
|
||||||
|
|
||||||
def _get_expression_context(
|
def jinja2_func_message(self, message: str):
|
||||||
self, request: PolicyRequest, **kwargs
|
"""Wrapper to append to messages list, which is returned with PolicyResult"""
|
||||||
) -> Dict[str, Any]:
|
self._messages.append(message)
|
||||||
"""Return dictionary with additional global variables passed to expression"""
|
|
||||||
|
def set_policy_request(self, request: PolicyRequest):
|
||||||
|
"""Update context based on policy request (if http request is given, update that too)"""
|
||||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||||
# update docs/policies/expression/index.md
|
# update docs/policies/expression/index.md
|
||||||
kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member
|
self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
|
||||||
kwargs["pb_logger"] = get_logger()
|
self._context["request"] = request
|
||||||
kwargs["requests"] = Session()
|
|
||||||
kwargs["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
|
|
||||||
if request.http_request:
|
if request.http_request:
|
||||||
kwargs["pb_client_ip"] = (
|
self.set_http_request(request.http_request)
|
||||||
|
|
||||||
|
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"
|
get_client_ip(request.http_request) or "255.255.255.255"
|
||||||
)
|
)
|
||||||
|
self._context["request"] = request
|
||||||
if SESSION_KEY_PLAN in request.http_request.session:
|
if SESSION_KEY_PLAN in request.http_request.session:
|
||||||
kwargs["pb_flow_plan"] = request.http_request.session[SESSION_KEY_PLAN]
|
self._context["pb_flow_plan"] = request.http_request.session[
|
||||||
return kwargs
|
SESSION_KEY_PLAN
|
||||||
|
]
|
||||||
|
|
||||||
def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult:
|
def evaluate(self, expression_source: str) -> PolicyResult:
|
||||||
"""Parse and evaluate expression.
|
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
||||||
If the Expression evaluates to a list with 2 items, the first is used as passing bool and
|
Messages can be added using 'do pb_message()'."""
|
||||||
the second as messages.
|
|
||||||
If the Expression evaluates to a truthy-object, it is used as passing bool."""
|
|
||||||
try:
|
try:
|
||||||
expression = self._env.from_string(expression_source)
|
expression = self._env.from_string(expression_source.lstrip().rstrip())
|
||||||
except TemplateSyntaxError as exc:
|
except TemplateSyntaxError as exc:
|
||||||
return PolicyResult(False, str(exc))
|
return PolicyResult(False, str(exc))
|
||||||
try:
|
try:
|
||||||
result: Optional[Any] = expression.render(
|
result: Optional[Any] = expression.render(self._context)
|
||||||
request=request, **self._get_expression_context(request)
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
)
|
LOGGER.warning("Expression error", exc=exc)
|
||||||
|
return PolicyResult(False, str(exc))
|
||||||
|
else:
|
||||||
|
policy_result = PolicyResult(False)
|
||||||
|
policy_result.messages = tuple(self._messages)
|
||||||
if isinstance(result, Undefined):
|
if isinstance(result, Undefined):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Expression policy returned undefined",
|
"Expression policy returned undefined",
|
||||||
src=expression_source,
|
src=expression_source,
|
||||||
req=request,
|
req=self._context,
|
||||||
)
|
)
|
||||||
return PolicyResult(False)
|
policy_result.passing = False
|
||||||
if isinstance(result, (list, tuple)) and len(result) == 2:
|
|
||||||
return PolicyResult(*result)
|
|
||||||
if result:
|
if result:
|
||||||
return PolicyResult(bool(result))
|
policy_result.passing = bool(result)
|
||||||
return PolicyResult(False)
|
return policy_result
|
||||||
except UndefinedError as exc:
|
|
||||||
return PolicyResult(False, str(exc))
|
|
||||||
|
|
||||||
def validate(self, expression: str):
|
def validate(self, expression: str):
|
||||||
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
|
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
|
||||||
|
@ -99,4 +133,4 @@ class Evaluator:
|
||||||
self._env.from_string(expression)
|
self._env.from_string(expression)
|
||||||
return True
|
return True
|
||||||
except TemplateSyntaxError as exc:
|
except TemplateSyntaxError as exc:
|
||||||
raise ValidationError("Expression Syntax Error") from exc
|
raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc
|
||||||
|
|
|
@ -16,7 +16,9 @@ class ExpressionPolicy(Policy):
|
||||||
|
|
||||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||||
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
|
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
|
||||||
return Evaluator().evaluate(self.expression, request)
|
evaluator = Evaluator()
|
||||||
|
evaluator.set_policy_request(request)
|
||||||
|
return evaluator.evaluate(self.expression)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
Evaluator().validate(self.expression)
|
Evaluator().validate(self.expression)
|
||||||
|
|
|
@ -17,13 +17,15 @@ class TestEvaluator(TestCase):
|
||||||
"""test simple value expression"""
|
"""test simple value expression"""
|
||||||
template = "True"
|
template = "True"
|
||||||
evaluator = Evaluator()
|
evaluator = Evaluator()
|
||||||
self.assertEqual(evaluator.evaluate(template, self.request).passing, True)
|
evaluator.set_policy_request(self.request)
|
||||||
|
self.assertEqual(evaluator.evaluate(template).passing, True)
|
||||||
|
|
||||||
def test_messages(self):
|
def test_messages(self):
|
||||||
"""test expression with message return"""
|
"""test expression with message return"""
|
||||||
template = "False, 'some message'"
|
template = '{% do pb_message("some message") %}False'
|
||||||
evaluator = Evaluator()
|
evaluator = Evaluator()
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("some message",))
|
self.assertEqual(result.messages, ("some message",))
|
||||||
|
|
||||||
|
@ -31,7 +33,8 @@ class TestEvaluator(TestCase):
|
||||||
"""test invalid syntax"""
|
"""test invalid syntax"""
|
||||||
template = "{%"
|
template = "{%"
|
||||||
evaluator = Evaluator()
|
evaluator = Evaluator()
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("tag name expected",))
|
self.assertEqual(result.messages, ("tag name expected",))
|
||||||
|
|
||||||
|
@ -39,7 +42,8 @@ class TestEvaluator(TestCase):
|
||||||
"""test undefined result"""
|
"""test undefined result"""
|
||||||
template = "{{ foo.bar }}"
|
template = "{{ foo.bar }}"
|
||||||
evaluator = Evaluator()
|
evaluator = Evaluator()
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("'foo' is undefined",))
|
self.assertEqual(result.messages, ("'foo' is undefined",))
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,8 @@ from django import forms
|
||||||
|
|
||||||
from passbook.policies.models import PolicyBinding, PolicyBindingModel
|
from passbook.policies.models import PolicyBinding, PolicyBindingModel
|
||||||
|
|
||||||
GENERAL_FIELDS = ["name", "negate", "order", "timeout"]
|
GENERAL_FIELDS = ["name"]
|
||||||
GENERAL_SERIALIZER_FIELDS = ["pk", "name", "negate", "order", "timeout"]
|
GENERAL_SERIALIZER_FIELDS = ["pk", "name"]
|
||||||
|
|
||||||
|
|
||||||
class PolicyBindingForm(forms.ModelForm):
|
class PolicyBindingForm(forms.ModelForm):
|
||||||
|
@ -18,9 +18,4 @@ class PolicyBindingForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = PolicyBinding
|
model = PolicyBinding
|
||||||
fields = [
|
fields = ["enabled", "policy", "target", "order", "timeout"]
|
||||||
"enabled",
|
|
||||||
"policy",
|
|
||||||
"target",
|
|
||||||
"order",
|
|
||||||
]
|
|
||||||
|
|
|
@ -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 django.utils.translation import gettext_lazy as _
|
||||||
from model_utils.managers import InheritanceManager
|
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.exceptions import PolicyException
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
|
@ -22,7 +26,6 @@ class PolicyBindingModel(models.Model):
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Policy Binding Model")
|
verbose_name = _("Policy Binding Model")
|
||||||
verbose_name_plural = _("Policy Binding Models")
|
verbose_name_plural = _("Policy Binding Models")
|
||||||
|
|
||||||
|
@ -36,13 +39,19 @@ class PolicyBinding(models.Model):
|
||||||
|
|
||||||
enabled = models.BooleanField(default=True)
|
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(
|
target = models.ForeignKey(
|
||||||
PolicyBindingModel, on_delete=models.CASCADE, related_name="+"
|
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()
|
||||||
order = models.IntegerField(default=0)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"PolicyBinding policy={self.policy} target={self.target} order={self.order}"
|
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 = _("Policy Binding")
|
||||||
verbose_name_plural = _("Policy Bindings")
|
verbose_name_plural = _("Policy Bindings")
|
||||||
|
unique_together = ("policy", "target", "order")
|
||||||
|
|
||||||
|
|
||||||
class Policy(CreatedUpdatedModel):
|
class Policy(CreatedUpdatedModel):
|
||||||
|
@ -60,11 +70,8 @@ class Policy(CreatedUpdatedModel):
|
||||||
policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||||
|
|
||||||
name = models.TextField(blank=True, null=True)
|
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):
|
def __str__(self):
|
||||||
return f"Policy {self.name}"
|
return f"Policy {self.name}"
|
||||||
|
@ -72,3 +79,9 @@ class Policy(CreatedUpdatedModel):
|
||||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||||
"""Check if user instance passes this policy"""
|
"""Check if user instance passes this policy"""
|
||||||
raise PolicyException()
|
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 django.core.cache import cache
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.models import User
|
|
||||||
from passbook.policies.exceptions import PolicyException
|
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
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
LOGGER = get_logger()
|
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"""
|
"""Generate Cache key for policy"""
|
||||||
prefix = f"policy_{policy.pk}"
|
prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
|
||||||
if user:
|
if request.http_request:
|
||||||
prefix += f"#{user.pk}"
|
prefix += f"_{request.http_request.session.session_key}"
|
||||||
|
if request.user:
|
||||||
|
prefix += f"#{request.user.pk}"
|
||||||
return prefix
|
return prefix
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,40 +27,50 @@ class PolicyProcess(Process):
|
||||||
"""Evaluate a single policy within a seprate process"""
|
"""Evaluate a single policy within a seprate process"""
|
||||||
|
|
||||||
connection: Connection
|
connection: Connection
|
||||||
policy: Policy
|
binding: PolicyBinding
|
||||||
request: PolicyRequest
|
request: PolicyRequest
|
||||||
|
|
||||||
def __init__(self, policy: Policy, request: PolicyRequest, connection: Connection):
|
def __init__(
|
||||||
|
self,
|
||||||
|
binding: PolicyBinding,
|
||||||
|
request: PolicyRequest,
|
||||||
|
connection: Optional[Connection],
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.policy = policy
|
self.binding = binding
|
||||||
self.request = request
|
self.request = request
|
||||||
|
if connection:
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|
||||||
def run(self):
|
def execute(self) -> PolicyResult:
|
||||||
"""Task wrapper to run policy checking"""
|
"""Run actual policy, returns result"""
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"P_ENG(proc): Running policy",
|
"P_ENG(proc): Running policy",
|
||||||
policy=self.policy,
|
policy=self.binding.policy,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
process="PolicyProcess",
|
process="PolicyProcess",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
policy_result = self.policy.passes(self.request)
|
policy_result = self.binding.policy.passes(self.request)
|
||||||
except PolicyException as exc:
|
except PolicyException as exc:
|
||||||
LOGGER.debug("P_ENG(proc): error", exc=exc)
|
LOGGER.debug("P_ENG(proc): error", exc=exc)
|
||||||
policy_result = PolicyResult(False, str(exc))
|
policy_result = PolicyResult(False, str(exc))
|
||||||
# Invert result if policy.negate is set
|
# Invert result if policy.negate is set
|
||||||
if self.policy.negate:
|
if self.binding.negate:
|
||||||
policy_result.passing = not policy_result.passing
|
policy_result.passing = not policy_result.passing
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"P_ENG(proc): Finished",
|
"P_ENG(proc): Finished",
|
||||||
policy=self.policy,
|
policy=self.binding.policy,
|
||||||
result=policy_result,
|
result=policy_result,
|
||||||
process="PolicyProcess",
|
process="PolicyProcess",
|
||||||
passing=policy_result.passing,
|
passing=policy_result.passing,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
)
|
)
|
||||||
key = cache_key(self.policy, self.request.user)
|
key = cache_key(self.binding, self.request)
|
||||||
cache.set(key, policy_result)
|
cache.set(key, policy_result)
|
||||||
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
|
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.core.models import User
|
||||||
from passbook.policies.dummy.models import DummyPolicy
|
from passbook.policies.dummy.models import DummyPolicy
|
||||||
from passbook.policies.engine import PolicyEngine
|
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):
|
class PolicyTestEngine(TestCase):
|
||||||
|
@ -20,40 +21,64 @@ class PolicyTestEngine(TestCase):
|
||||||
self.policy_true = DummyPolicy.objects.create(
|
self.policy_true = DummyPolicy.objects.create(
|
||||||
result=True, wait_min=0, wait_max=1
|
result=True, wait_min=0, wait_max=1
|
||||||
)
|
)
|
||||||
self.policy_negate = DummyPolicy.objects.create(
|
self.policy_wrong_type = Policy.objects.create(name="wrong_type")
|
||||||
negate=True, result=True, wait_min=0, wait_max=1
|
self.policy_raises = ExpressionPolicy.objects.create(
|
||||||
|
name="raises", expression="{{ 0/0 }}"
|
||||||
)
|
)
|
||||||
self.policy_raises = Policy.objects.create(name="raises")
|
|
||||||
|
|
||||||
def test_engine_empty(self):
|
def test_engine_empty(self):
|
||||||
"""Ensure empty policy list passes"""
|
"""Ensure empty policy list passes"""
|
||||||
engine = PolicyEngine([], self.user)
|
pbm = PolicyBindingModel.objects.create()
|
||||||
self.assertEqual(engine.build().passing, True)
|
engine = PolicyEngine(pbm, self.user)
|
||||||
|
result = engine.build().result
|
||||||
|
self.assertEqual(result.passing, True)
|
||||||
|
self.assertEqual(result.messages, ())
|
||||||
|
|
||||||
def test_engine(self):
|
def test_engine(self):
|
||||||
"""Ensure all policies passes (Mix of false and true -> false)"""
|
"""Ensure all policies passes (Mix of false and true -> false)"""
|
||||||
engine = PolicyEngine(
|
pbm = PolicyBindingModel.objects.create()
|
||||||
DummyPolicy.objects.filter(negate__exact=False), self.user
|
PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
|
||||||
)
|
PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=1)
|
||||||
self.assertEqual(engine.build().passing, False)
|
engine = PolicyEngine(pbm, self.user)
|
||||||
|
result = engine.build().result
|
||||||
|
self.assertEqual(result.passing, False)
|
||||||
|
self.assertEqual(result.messages, ("dummy",))
|
||||||
|
|
||||||
def test_engine_negate(self):
|
def test_engine_negate(self):
|
||||||
"""Test negate flag"""
|
"""Test negate flag"""
|
||||||
engine = PolicyEngine(DummyPolicy.objects.filter(negate__exact=True), self.user)
|
pbm = PolicyBindingModel.objects.create()
|
||||||
self.assertEqual(engine.build().passing, False)
|
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):
|
def test_engine_policy_error(self):
|
||||||
"""Test negate flag"""
|
"""Test policy raising an error flag"""
|
||||||
engine = PolicyEngine(Policy.objects.filter(name="raises"), self.user)
|
pbm = PolicyBindingModel.objects.create()
|
||||||
self.assertEqual(engine.build().passing, False)
|
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):
|
def test_engine_cache(self):
|
||||||
"""Ensure empty policy list passes"""
|
"""Ensure empty policy list passes"""
|
||||||
engine = PolicyEngine(
|
pbm = PolicyBindingModel.objects.create()
|
||||||
DummyPolicy.objects.filter(negate__exact=False), self.user
|
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(len(cache.keys("policy_*")), 0)
|
||||||
self.assertEqual(engine.build().passing, False)
|
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(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
|
self.messages = messages
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"<PolicyResult passing={self.passing}>"
|
if self.messages:
|
||||||
|
return f"PolicyResult passing={self.passing} messages={self.messages}"
|
||||||
|
return f"PolicyResult passing={self.passing}"
|
||||||
|
|
|
@ -50,9 +50,9 @@ class PassbookAuthorizationView(AccessMixin, AuthorizationView):
|
||||||
provider.save()
|
provider.save()
|
||||||
self._application = application
|
self._application = application
|
||||||
# Check permissions
|
# Check permissions
|
||||||
passing, policy_messages = self.user_has_access(self._application, request.user)
|
result = self.user_has_access(self._application, request.user)
|
||||||
if not passing:
|
if not result.passing:
|
||||||
for policy_message in policy_messages:
|
for policy_message in result.messages:
|
||||||
messages.error(request, policy_message)
|
messages.error(request, policy_message)
|
||||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||||
# Some clients don't pass response_type, so we default to code
|
# 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]:
|
def client_related_provider(client: Client) -> Optional[Provider]:
|
||||||
"""Lookup related Application from Client"""
|
"""Lookup related Application from Client"""
|
||||||
# because oidc_provider is also used by app_gw, we can't be
|
# 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
|
# and choose the one that inherits from Provider, which is guaranteed to
|
||||||
# have the application property
|
# have the application property
|
||||||
collector = Collector(using="default")
|
collector = Collector(using="default")
|
||||||
|
@ -50,9 +50,9 @@ def check_permissions(
|
||||||
policy_engine.build()
|
policy_engine.build()
|
||||||
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
passing, policy_messages = policy_engine.result
|
result = policy_engine.result
|
||||||
if not passing:
|
if not result.passing:
|
||||||
for policy_message in policy_messages:
|
for policy_message in result.messages:
|
||||||
messages.error(request, policy_message)
|
messages.error(request, policy_message)
|
||||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
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",
|
"group_object_filter",
|
||||||
"user_group_membership_field",
|
"user_group_membership_field",
|
||||||
"object_uniqueness_field",
|
"object_uniqueness_field",
|
||||||
|
"sync_users",
|
||||||
"sync_groups",
|
"sync_groups",
|
||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
|
|
|
@ -16,26 +16,10 @@ LOGGER = get_logger()
|
||||||
class Connector:
|
class Connector:
|
||||||
"""Wrapper for ldap3 to easily manage user authentication and creation"""
|
"""Wrapper for ldap3 to easily manage user authentication and creation"""
|
||||||
|
|
||||||
_server: ldap3.Server
|
|
||||||
_connection = ldap3.Connection
|
|
||||||
_source: LDAPSource
|
_source: LDAPSource
|
||||||
|
|
||||||
def __init__(self, source: LDAPSource):
|
def __init__(self, source: LDAPSource):
|
||||||
self._source = source
|
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
|
@staticmethod
|
||||||
def encode_pass(password: str) -> bytes:
|
def encode_pass(password: str) -> bytes:
|
||||||
|
@ -45,19 +29,23 @@ class Connector:
|
||||||
@property
|
@property
|
||||||
def base_dn_users(self) -> str:
|
def base_dn_users(self) -> str:
|
||||||
"""Shortcut to get full base_dn for user lookups"""
|
"""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
|
@property
|
||||||
def base_dn_groups(self) -> str:
|
def base_dn_groups(self) -> str:
|
||||||
"""Shortcut to get full base_dn for group lookups"""
|
"""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):
|
def sync_groups(self):
|
||||||
"""Iterate over all LDAP Groups and create passbook_core.Group instances"""
|
"""Iterate over all LDAP Groups and create passbook_core.Group instances"""
|
||||||
if not self._source.sync_groups:
|
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
|
return
|
||||||
groups = self._connection.extend.standard.paged_search(
|
groups = self._source.connection.extend.standard.paged_search(
|
||||||
search_base=self.base_dn_groups,
|
search_base=self.base_dn_groups,
|
||||||
search_filter=self._source.group_object_filter,
|
search_filter=self._source.group_object_filter,
|
||||||
search_scope=ldap3.SUBTREE,
|
search_scope=ldap3.SUBTREE,
|
||||||
|
@ -87,7 +75,10 @@ class Connector:
|
||||||
|
|
||||||
def sync_users(self):
|
def sync_users(self):
|
||||||
"""Iterate over all LDAP Users and create passbook_core.User instances"""
|
"""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_base=self.base_dn_users,
|
||||||
search_filter=self._source.user_object_filter,
|
search_filter=self._source.user_object_filter,
|
||||||
search_scope=ldap3.SUBTREE,
|
search_scope=ldap3.SUBTREE,
|
||||||
|
@ -101,9 +92,9 @@ class Connector:
|
||||||
LOGGER.warning("Cannot find uniqueness Field in attributes")
|
LOGGER.warning("Cannot find uniqueness Field in attributes")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
defaults = self._build_object_properties(attributes)
|
||||||
user, created = User.objects.update_or_create(
|
user, created = User.objects.update_or_create(
|
||||||
attributes__ldap_uniq=uniq,
|
attributes__ldap_uniq=uniq, defaults=defaults,
|
||||||
defaults=self._build_object_properties(attributes),
|
|
||||||
)
|
)
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
LOGGER.warning("Failed to create user", exc=exc)
|
LOGGER.warning("Failed to create user", exc=exc)
|
||||||
|
@ -123,7 +114,7 @@ class Connector:
|
||||||
|
|
||||||
def sync_membership(self):
|
def sync_membership(self):
|
||||||
"""Iterate over all Users and assign Groups using memberOf Field"""
|
"""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_base=self.base_dn_users,
|
||||||
search_filter=self._source.user_object_filter,
|
search_filter=self._source.user_object_filter,
|
||||||
search_scope=ldap3.SUBTREE,
|
search_scope=ldap3.SUBTREE,
|
||||||
|
@ -220,7 +211,7 @@ class Connector:
|
||||||
LOGGER.debug("Attempting Binding as user", user=user)
|
LOGGER.debug("Attempting Binding as user", user=user)
|
||||||
try:
|
try:
|
||||||
temp_connection = ldap3.Connection(
|
temp_connection = ldap3.Connection(
|
||||||
self._server,
|
self._source.connection.server,
|
||||||
user=user.attributes.get("distinguishedName"),
|
user=user.attributes.get("distinguishedName"),
|
||||||
password=password,
|
password=password,
|
||||||
raise_exceptions=True,
|
raise_exceptions=True,
|
||||||
|
|
|
@ -26,6 +26,7 @@ class LDAPSourceForm(forms.ModelForm):
|
||||||
"group_object_filter",
|
"group_object_filter",
|
||||||
"user_group_membership_field",
|
"user_group_membership_field",
|
||||||
"object_uniqueness_field",
|
"object_uniqueness_field",
|
||||||
|
"sync_users",
|
||||||
"sync_groups",
|
"sync_groups",
|
||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"property_mappings",
|
"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"""
|
"""passbook LDAP Models"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from ldap3 import Connection, Server
|
||||||
|
|
||||||
from passbook.core.models import Group, PropertyMapping, Source
|
from passbook.core.models import Group, PropertyMapping, Source
|
||||||
|
|
||||||
|
@ -22,10 +24,12 @@ class LDAPSource(Source):
|
||||||
additional_user_dn = models.TextField(
|
additional_user_dn = models.TextField(
|
||||||
help_text=_("Prepended to Base DN for User-queries."),
|
help_text=_("Prepended to Base DN for User-queries."),
|
||||||
verbose_name=_("Addition User DN"),
|
verbose_name=_("Addition User DN"),
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
additional_group_dn = models.TextField(
|
additional_group_dn = models.TextField(
|
||||||
help_text=_("Prepended to Base DN for Group-queries."),
|
help_text=_("Prepended to Base DN for Group-queries."),
|
||||||
verbose_name=_("Addition Group DN"),
|
verbose_name=_("Addition Group DN"),
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_object_filter = models.TextField(
|
user_object_filter = models.TextField(
|
||||||
|
@ -43,6 +47,7 @@ class LDAPSource(Source):
|
||||||
default="objectSid", help_text=_("Field which contains a unique Identifier.")
|
default="objectSid", help_text=_("Field which contains a unique Identifier.")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sync_users = models.BooleanField(default=True)
|
||||||
sync_groups = models.BooleanField(default=True)
|
sync_groups = models.BooleanField(default=True)
|
||||||
sync_parent_group = models.ForeignKey(
|
sync_parent_group = models.ForeignKey(
|
||||||
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
|
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"
|
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:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("LDAP Source")
|
verbose_name = _("LDAP Source")
|
||||||
|
|
|
@ -9,7 +9,6 @@ def sync_groups(source_pk: int):
|
||||||
"""Sync LDAP Groups on background worker"""
|
"""Sync LDAP Groups on background worker"""
|
||||||
source = LDAPSource.objects.get(pk=source_pk)
|
source = LDAPSource.objects.get(pk=source_pk)
|
||||||
connector = Connector(source)
|
connector = Connector(source)
|
||||||
connector.bind()
|
|
||||||
connector.sync_groups()
|
connector.sync_groups()
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +17,6 @@ def sync_users(source_pk: int):
|
||||||
"""Sync LDAP Users on background worker"""
|
"""Sync LDAP Users on background worker"""
|
||||||
source = LDAPSource.objects.get(pk=source_pk)
|
source = LDAPSource.objects.get(pk=source_pk)
|
||||||
connector = Connector(source)
|
connector = Connector(source)
|
||||||
connector.bind()
|
|
||||||
connector.sync_users()
|
connector.sync_users()
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,7 +25,6 @@ def sync():
|
||||||
"""Sync all sources"""
|
"""Sync all sources"""
|
||||||
for source in LDAPSource.objects.filter(enabled=True):
|
for source in LDAPSource.objects.filter(enabled=True):
|
||||||
connector = Connector(source)
|
connector = Connector(source)
|
||||||
connector.bind()
|
|
||||||
connector.sync_users()
|
connector.sync_users()
|
||||||
connector.sync_groups()
|
connector.sync_groups()
|
||||||
connector.sync_membership()
|
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"""
|
"""OAuth Clients"""
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Optional
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||||
from urllib.parse import parse_qs, urlencode
|
from urllib.parse import parse_qs, urlencode
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
@ -14,24 +14,29 @@ from structlog import get_logger
|
||||||
from passbook import __version__
|
from passbook import __version__
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from passbook.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
|
|
||||||
class BaseOAuthClient:
|
class BaseOAuthClient:
|
||||||
"""Base OAuth Client"""
|
"""Base OAuth Client"""
|
||||||
|
|
||||||
session: Session
|
session: Session
|
||||||
|
source: "OAuthSource"
|
||||||
|
|
||||||
def __init__(self, source, token=""): # nosec
|
def __init__(self, source: "OAuthSource", token=""): # nosec
|
||||||
self.source = source
|
self.source = source
|
||||||
self.token = token
|
self.token = token
|
||||||
self.session = Session()
|
self.session = Session()
|
||||||
self.session.headers.update({"User-Agent": "passbook %s" % __version__})
|
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."
|
"Fetch access token from callback request."
|
||||||
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
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."
|
"Fetch user profile information."
|
||||||
try:
|
try:
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -45,7 +50,7 @@ class BaseOAuthClient:
|
||||||
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return response.json() or response.text
|
return response.json()
|
||||||
|
|
||||||
def get_redirect_args(self, request, callback) -> Dict[str, str]:
|
def get_redirect_args(self, request, callback) -> Dict[str, str]:
|
||||||
"Get request parameters for redirect url."
|
"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.flows.views import SESSION_KEY_PLAN
|
||||||
from passbook.lib.utils.urls import redirect_with_qs
|
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.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||||
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ class OAuthClientMixin:
|
||||||
|
|
||||||
client_class: Optional[Callable] = None
|
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."
|
"Get instance of the OAuth client for this source."
|
||||||
if self.client_class is not None:
|
if self.client_class is not None:
|
||||||
# pylint: disable=not-callable
|
# pylint: disable=not-callable
|
||||||
|
|
|
@ -16,6 +16,8 @@ class IdentificationStageSerializer(ModelSerializer):
|
||||||
"name",
|
"name",
|
||||||
"user_fields",
|
"user_fields",
|
||||||
"template",
|
"template",
|
||||||
|
"enrollment_flow",
|
||||||
|
"recovery_flow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ class IdentificationStageForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = IdentificationStage
|
model = IdentificationStage
|
||||||
fields = ["name", "user_fields", "template"]
|
fields = ["name", "user_fields", "template", "enrollment_flow", "recovery_flow"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(),
|
"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.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from passbook.flows.models import Stage
|
from passbook.flows.models import Flow, Stage
|
||||||
|
|
||||||
|
|
||||||
class UserFields(models.TextChoices):
|
class UserFields(models.TextChoices):
|
||||||
|
@ -29,6 +29,29 @@ class IdentificationStage(Stage):
|
||||||
)
|
)
|
||||||
template = models.TextField(choices=Templates.choices)
|
template = models.TextField(choices=Templates.choices)
|
||||||
|
|
||||||
|
enrollment_flow = models.ForeignKey(
|
||||||
|
Flow,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="+",
|
||||||
|
default=None,
|
||||||
|
help_text=_(
|
||||||
|
"Optional enrollment flow, which is linked at the bottom of the page."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
recovery_flow = models.ForeignKey(
|
||||||
|
Flow,
|
||||||
|
on_delete=models.SET_DEFAULT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="+",
|
||||||
|
default=None,
|
||||||
|
help_text=_(
|
||||||
|
"Optional enrollment flow, which is linked at the bottom of the page."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
type = "passbook.stages.identification.stage.IdentificationStageView"
|
type = "passbook.stages.identification.stage.IdentificationStageView"
|
||||||
form = "passbook.stages.identification.forms.IdentificationStageForm"
|
form = "passbook.stages.identification.forms.IdentificationStageForm"
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ from django.views.generic import FormView
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.models import Source, User
|
from passbook.core.models import Source, User
|
||||||
from passbook.flows.models import FlowDesignation
|
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
from passbook.flows.stage import StageView
|
from passbook.flows.stage import StageView
|
||||||
from passbook.stages.identification.forms import IdentificationForm
|
from passbook.stages.identification.forms import IdentificationForm
|
||||||
|
@ -34,18 +33,17 @@ class IdentificationStageView(FormView, StageView):
|
||||||
return [current_stage.template]
|
return [current_stage.template]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
current_stage: IdentificationStage = self.executor.current_stage
|
||||||
# Check for related enrollment and recovery flow, add URL to view
|
# Check for related enrollment and recovery flow, add URL to view
|
||||||
enrollment_flow = self.executor.flow.related_flow(FlowDesignation.ENROLLMENT)
|
if current_stage.enrollment_flow:
|
||||||
if enrollment_flow:
|
|
||||||
kwargs["enroll_url"] = reverse(
|
kwargs["enroll_url"] = reverse(
|
||||||
"passbook_flows:flow-executor",
|
"passbook_flows:flow-executor-shell",
|
||||||
kwargs={"flow_slug": enrollment_flow.slug},
|
kwargs={"flow_slug": current_stage.enrollment_flow.slug},
|
||||||
)
|
)
|
||||||
recovery_flow = self.executor.flow.related_flow(FlowDesignation.RECOVERY)
|
if current_stage.recovery_flow:
|
||||||
if recovery_flow:
|
|
||||||
kwargs["recovery_url"] = reverse(
|
kwargs["recovery_url"] = reverse(
|
||||||
"passbook_flows:flow-executor",
|
"passbook_flows:flow-executor-shell",
|
||||||
kwargs={"flow_slug": recovery_flow.slug},
|
kwargs={"flow_slug": current_stage.recovery_flow.slug},
|
||||||
)
|
)
|
||||||
kwargs["primary_action"] = _("Log in")
|
kwargs["primary_action"] = _("Log in")
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
<header class="pf-c-login__main-header">
|
<header class="pf-c-login__main-header">
|
||||||
<h1 class="pf-c-title pf-m-3xl">
|
<h1 class="pf-c-title pf-m-3xl">
|
||||||
|
@ -21,3 +22,35 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 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">
|
<header class="pf-c-login__main-header">
|
||||||
<h1 class="pf-c-title pf-m-3xl">
|
<h1 class="pf-c-title pf-m-3xl">
|
||||||
{% trans 'Trouble Logging In?' %}
|
{% trans 'Trouble Logging In?' %}
|
||||||
|
@ -53,20 +27,3 @@
|
||||||
<p>{{ config.login.subtext }}</p>
|
<p>{{ config.login.subtext }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
|
||||||
<footer class="pf-c-login__footer">
|
|
||||||
<ul class="pf-c-list pf-m-inline">
|
|
||||||
<li>
|
|
||||||
<a href="https://beryju.github.io/passbook/">{% trans 'Documentation' %}</a>
|
|
||||||
</li>
|
|
||||||
{% config 'passbook.footer_links' as footer_links %}
|
|
||||||
{% for link in footer_links %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
|
@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase):
|
||||||
slug="unique-enrollment-string",
|
slug="unique-enrollment-string",
|
||||||
designation=FlowDesignation.ENROLLMENT,
|
designation=FlowDesignation.ENROLLMENT,
|
||||||
)
|
)
|
||||||
|
self.stage.enrollment_flow = flow
|
||||||
|
self.stage.save()
|
||||||
FlowStageBinding.objects.create(
|
FlowStageBinding.objects.create(
|
||||||
flow=flow, stage=self.stage, order=0,
|
flow=flow, stage=self.stage, order=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
reverse(
|
||||||
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(flow.name, response.rendered_content)
|
self.assertIn(flow.slug, response.rendered_content)
|
||||||
|
|
||||||
def test_recovery_flow(self):
|
def test_recovery_flow(self):
|
||||||
"""Test that recovery flow is linked correctly"""
|
"""Test that recovery flow is linked correctly"""
|
||||||
|
@ -102,12 +106,16 @@ class TestIdentificationStage(TestCase):
|
||||||
slug="unique-recovery-string",
|
slug="unique-recovery-string",
|
||||||
designation=FlowDesignation.RECOVERY,
|
designation=FlowDesignation.RECOVERY,
|
||||||
)
|
)
|
||||||
|
self.stage.recovery_flow = flow
|
||||||
|
self.stage.save()
|
||||||
FlowStageBinding.objects.create(
|
FlowStageBinding.objects.create(
|
||||||
flow=flow, stage=self.stage, order=0,
|
flow=flow, stage=self.stage, order=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
reverse(
|
||||||
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(flow.name, response.rendered_content)
|
self.assertIn(flow.slug, response.rendered_content)
|
||||||
|
|
|
@ -38,6 +38,7 @@ class PromptSerializer(ModelSerializer):
|
||||||
"type",
|
"type",
|
||||||
"required",
|
"required",
|
||||||
"placeholder",
|
"placeholder",
|
||||||
|
"order",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ class PromptAdminForm(forms.ModelForm):
|
||||||
"type",
|
"type",
|
||||||
"required",
|
"required",
|
||||||
"placeholder",
|
"placeholder",
|
||||||
|
"order",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"label": forms.TextInput(),
|
"label": forms.TextInput(),
|
||||||
|
@ -48,16 +49,19 @@ class PromptForm(forms.Form):
|
||||||
self.stage = stage
|
self.stage = stage
|
||||||
self.plan = plan
|
self.plan = plan
|
||||||
super().__init__(*args, **kwargs)
|
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
|
field: Prompt
|
||||||
self.fields[field.field_key] = field.field
|
self.fields[field.field_key] = field.field
|
||||||
|
self.field_order = sorted(fields, key=lambda x: x.order)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user())
|
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.request.context = cleaned_data
|
||||||
engine.build()
|
engine.build()
|
||||||
passing, messages = engine.result
|
result = engine.result
|
||||||
if not passing:
|
if not result.passing:
|
||||||
raise forms.ValidationError(messages)
|
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"
|
EMAIL = "e-mail"
|
||||||
PASSWORD = "password" # noqa # nosec
|
PASSWORD = "password" # noqa # nosec
|
||||||
NUMBER = "number"
|
NUMBER = "number"
|
||||||
|
CHECKBOX = "checkbox"
|
||||||
|
DATE = "data"
|
||||||
|
DATE_TIME = "data-time"
|
||||||
|
|
||||||
|
SEPARATOR = "separator"
|
||||||
HIDDEN = "hidden"
|
HIDDEN = "hidden"
|
||||||
|
STATIC = "static"
|
||||||
|
|
||||||
|
|
||||||
class Prompt(models.Model):
|
class Prompt(models.Model):
|
||||||
|
@ -32,41 +38,37 @@ class Prompt(models.Model):
|
||||||
required = models.BooleanField(default=True)
|
required = models.BooleanField(default=True)
|
||||||
placeholder = models.TextField()
|
placeholder = models.TextField()
|
||||||
|
|
||||||
|
order = models.IntegerField(default=0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def field(self):
|
def field(self):
|
||||||
"""Return instantiated form input field"""
|
"""Return instantiated form input field"""
|
||||||
attrs = {"placeholder": _(self.placeholder)}
|
attrs = {"placeholder": _(self.placeholder)}
|
||||||
if self.type == FieldTypes.TEXT:
|
field_class = forms.CharField
|
||||||
return forms.CharField(
|
widget = forms.TextInput(attrs=attrs)
|
||||||
label=_(self.label),
|
kwargs = {
|
||||||
widget=forms.TextInput(attrs=attrs),
|
"label": _(self.label),
|
||||||
required=self.required,
|
"required": self.required,
|
||||||
)
|
}
|
||||||
if self.type == FieldTypes.EMAIL:
|
if self.type == FieldTypes.EMAIL:
|
||||||
return forms.EmailField(
|
field_class = forms.EmailField
|
||||||
label=_(self.label),
|
|
||||||
widget=forms.TextInput(attrs=attrs),
|
|
||||||
required=self.required,
|
|
||||||
)
|
|
||||||
if self.type == FieldTypes.PASSWORD:
|
if self.type == FieldTypes.PASSWORD:
|
||||||
return forms.CharField(
|
widget = forms.PasswordInput(attrs=attrs)
|
||||||
label=_(self.label),
|
|
||||||
widget=forms.PasswordInput(attrs=attrs),
|
|
||||||
required=self.required,
|
|
||||||
)
|
|
||||||
if self.type == FieldTypes.NUMBER:
|
if self.type == FieldTypes.NUMBER:
|
||||||
return forms.IntegerField(
|
field_class = forms.IntegerField
|
||||||
label=_(self.label),
|
widget = forms.NumberInput(attrs=attrs)
|
||||||
widget=forms.NumberInput(attrs=attrs),
|
|
||||||
required=self.required,
|
|
||||||
)
|
|
||||||
if self.type == FieldTypes.HIDDEN:
|
if self.type == FieldTypes.HIDDEN:
|
||||||
return forms.CharField(
|
widget = forms.HiddenInput(attrs=attrs)
|
||||||
widget=forms.HiddenInput(attrs=attrs),
|
kwargs["required"] = False
|
||||||
required=False,
|
kwargs["initial"] = self.placeholder
|
||||||
initial=self.placeholder,
|
if self.type == FieldTypes.CHECKBOX:
|
||||||
)
|
field_class = forms.CheckboxInput
|
||||||
raise ValueError("field_type is not valid, not one of FieldTypes.")
|
kwargs["required"] = False
|
||||||
|
|
||||||
|
# TODO: Implement static
|
||||||
|
# TODO: Implement separator
|
||||||
|
kwargs["widget"] = widget
|
||||||
|
return field_class(**kwargs)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.type not in FieldTypes:
|
if self.type not in FieldTypes:
|
||||||
|
|
|
@ -93,25 +93,6 @@ class TestPromptStage(TestCase):
|
||||||
|
|
||||||
FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
|
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):
|
def test_render(self):
|
||||||
"""Test render of form, check if all prompts are rendered correctly"""
|
"""Test render of form, check if all prompts are rendered correctly"""
|
||||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
|
||||||
|
@ -139,7 +120,7 @@ class TestPromptStage(TestCase):
|
||||||
expr_policy = ExpressionPolicy.objects.create(
|
expr_policy = ExpressionPolicy.objects.create(
|
||||||
name="validate-form", expression=expr
|
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)
|
form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
|
||||||
self.assertEqual(form.is_valid(), True)
|
self.assertEqual(form.is_valid(), True)
|
||||||
return form
|
return form
|
||||||
|
@ -151,7 +132,7 @@ class TestPromptStage(TestCase):
|
||||||
expr_policy = ExpressionPolicy.objects.create(
|
expr_policy = ExpressionPolicy.objects.create(
|
||||||
name="validate-form", expression=expr
|
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)
|
form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
|
||||||
self.assertEqual(form.is_valid(), False)
|
self.assertEqual(form.is_valid(), False)
|
||||||
return form
|
return form
|
||||||
|
|
|
@ -25,7 +25,14 @@ class UserWriteStageView(StageView):
|
||||||
LOGGER.debug(message)
|
LOGGER.debug(message)
|
||||||
return self.executor.stage_invalid()
|
return self.executor.stage_invalid()
|
||||||
data = self.executor.plan.context[PLAN_CONTEXT_PROMPT]
|
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]
|
user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
setter_name = f"set_{key}"
|
setter_name = f"set_{key}"
|
||||||
|
@ -44,14 +51,4 @@ class UserWriteStageView(StageView):
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Updated existing user", user=user, flow_slug=self.executor.flow.slug,
|
"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()
|
return self.executor.stage_ok()
|
||||||
|
|
|
@ -72,6 +72,7 @@ class TestUserWriteStage(TestCase):
|
||||||
plan.context[PLAN_CONTEXT_PROMPT] = {
|
plan.context[PLAN_CONTEXT_PROMPT] = {
|
||||||
"username": "test-user-new",
|
"username": "test-user-new",
|
||||||
"password": new_password,
|
"password": new_password,
|
||||||
|
"some-custom-attribute": "test",
|
||||||
}
|
}
|
||||||
session = self.client.session
|
session = self.client.session
|
||||||
session[SESSION_KEY_PLAN] = plan
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
@ -88,6 +89,7 @@ class TestUserWriteStage(TestCase):
|
||||||
)
|
)
|
||||||
self.assertTrue(user_qs.exists())
|
self.assertTrue(user_qs.exists())
|
||||||
self.assertTrue(user_qs.first().check_password(new_password))
|
self.assertTrue(user_qs.first().check_password(new_password))
|
||||||
|
self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test")
|
||||||
|
|
||||||
def test_without_data(self):
|
def test_without_data(self):
|
||||||
"""Test without data results in error"""
|
"""Test without data results in error"""
|
||||||
|
|
131
swagger.yaml
131
swagger.yaml
|
@ -837,7 +837,7 @@ paths:
|
||||||
parameters:
|
parameters:
|
||||||
- name: policy_uuid
|
- name: policy_uuid
|
||||||
in: path
|
in: path
|
||||||
description: A UUID string identifying this policy.
|
description: A UUID string identifying this Policy.
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
@ -5079,19 +5079,6 @@ definitions:
|
||||||
title: Name
|
title: Name
|
||||||
type: string
|
type: string
|
||||||
x-nullable: true
|
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__:
|
__type__:
|
||||||
title: 'type '
|
title: 'type '
|
||||||
type: string
|
type: string
|
||||||
|
@ -5100,6 +5087,7 @@ definitions:
|
||||||
required:
|
required:
|
||||||
- policy
|
- policy
|
||||||
- target
|
- target
|
||||||
|
- order
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
policy:
|
policy:
|
||||||
|
@ -5118,6 +5106,12 @@ definitions:
|
||||||
type: integer
|
type: integer
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
minimum: -2147483648
|
||||||
|
timeout:
|
||||||
|
title: Timeout
|
||||||
|
description: Timeout after which Policy execution is terminated.
|
||||||
|
type: integer
|
||||||
|
maximum: 2147483647
|
||||||
|
minimum: -2147483648
|
||||||
DummyPolicy:
|
DummyPolicy:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
@ -5130,19 +5124,6 @@ definitions:
|
||||||
title: Name
|
title: Name
|
||||||
type: string
|
type: string
|
||||||
x-nullable: true
|
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:
|
result:
|
||||||
title: Result
|
title: Result
|
||||||
type: boolean
|
type: boolean
|
||||||
|
@ -5170,19 +5151,6 @@ definitions:
|
||||||
title: Name
|
title: Name
|
||||||
type: string
|
type: string
|
||||||
x-nullable: true
|
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:
|
expression:
|
||||||
title: Expression
|
title: Expression
|
||||||
type: string
|
type: string
|
||||||
|
@ -5199,19 +5167,6 @@ definitions:
|
||||||
title: Name
|
title: Name
|
||||||
type: string
|
type: string
|
||||||
x-nullable: true
|
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:
|
allowed_count:
|
||||||
title: Allowed count
|
title: Allowed count
|
||||||
type: integer
|
type: integer
|
||||||
|
@ -5231,19 +5186,6 @@ definitions:
|
||||||
title: Name
|
title: Name
|
||||||
type: string
|
type: string
|
||||||
x-nullable: true
|
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:
|
amount_uppercase:
|
||||||
title: Amount uppercase
|
title: Amount uppercase
|
||||||
type: integer
|
type: integer
|
||||||
|
@ -5286,19 +5228,6 @@ definitions:
|
||||||
title: Name
|
title: Name
|
||||||
type: string
|
type: string
|
||||||
x-nullable: true
|
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:
|
days:
|
||||||
title: Days
|
title: Days
|
||||||
type: integer
|
type: integer
|
||||||
|
@ -5319,19 +5248,6 @@ definitions:
|
||||||
title: Name
|
title: Name
|
||||||
type: string
|
type: string
|
||||||
x-nullable: true
|
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:
|
check_ip:
|
||||||
title: Check ip
|
title: Check ip
|
||||||
type: boolean
|
type: boolean
|
||||||
|
@ -5690,8 +5606,6 @@ definitions:
|
||||||
- bind_cn
|
- bind_cn
|
||||||
- bind_password
|
- bind_password
|
||||||
- base_dn
|
- base_dn
|
||||||
- additional_user_dn
|
|
||||||
- additional_group_dn
|
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
pk:
|
pk:
|
||||||
|
@ -5738,12 +5652,10 @@ definitions:
|
||||||
title: Addition User DN
|
title: Addition User DN
|
||||||
description: Prepended to Base DN for User-queries.
|
description: Prepended to Base DN for User-queries.
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
|
||||||
additional_group_dn:
|
additional_group_dn:
|
||||||
title: Addition Group DN
|
title: Addition Group DN
|
||||||
description: Prepended to Base DN for Group-queries.
|
description: Prepended to Base DN for Group-queries.
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
|
||||||
user_object_filter:
|
user_object_filter:
|
||||||
title: User object filter
|
title: User object filter
|
||||||
description: Consider Objects matching this filter to be Users.
|
description: Consider Objects matching this filter to be Users.
|
||||||
|
@ -5764,6 +5676,9 @@ definitions:
|
||||||
description: Field which contains a unique Identifier.
|
description: Field which contains a unique Identifier.
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
sync_users:
|
||||||
|
title: Sync users
|
||||||
|
type: boolean
|
||||||
sync_groups:
|
sync_groups:
|
||||||
title: Sync groups
|
title: Sync groups
|
||||||
type: boolean
|
type: boolean
|
||||||
|
@ -6003,6 +5918,20 @@ definitions:
|
||||||
enum:
|
enum:
|
||||||
- stages/identification/login.html
|
- stages/identification/login.html
|
||||||
- stages/identification/recovery.html
|
- stages/identification/recovery.html
|
||||||
|
enrollment_flow:
|
||||||
|
title: Enrollment flow
|
||||||
|
description: Optional enrollment flow, which is linked at the bottom of the
|
||||||
|
page.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
|
recovery_flow:
|
||||||
|
title: Recovery flow
|
||||||
|
description: Optional enrollment flow, which is linked at the bottom of the
|
||||||
|
page.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
InvitationStage:
|
InvitationStage:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
|
@ -6112,7 +6041,12 @@ definitions:
|
||||||
- e-mail
|
- e-mail
|
||||||
- password
|
- password
|
||||||
- number
|
- number
|
||||||
|
- checkbox
|
||||||
|
- data
|
||||||
|
- data-time
|
||||||
|
- separator
|
||||||
- hidden
|
- hidden
|
||||||
|
- static
|
||||||
required:
|
required:
|
||||||
title: Required
|
title: Required
|
||||||
type: boolean
|
type: boolean
|
||||||
|
@ -6120,6 +6054,11 @@ definitions:
|
||||||
title: Placeholder
|
title: Placeholder
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
|
order:
|
||||||
|
title: Order
|
||||||
|
type: integer
|
||||||
|
maximum: 2147483647
|
||||||
|
minimum: -2147483648
|
||||||
PromptStage:
|
PromptStage:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
|
|
Reference in New Issue