Merge branch 'master' into azure-pipelines

# Conflicts:
#	.github/workflows/ci.yml
This commit is contained in:
Jens Langhammer 2020-06-02 20:40:04 +02:00
commit 9882342ed1
64 changed files with 1112 additions and 535 deletions

40
Pipfile.lock generated
View File

@ -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": [

View File

@ -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)

View File

@ -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">

View File

@ -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 %}

View File

@ -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."))

View File

@ -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"

View File

@ -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),
]

View File

@ -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)

View File

@ -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))

View File

@ -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' %}

View File

@ -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>

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)]

View File

@ -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})"

View File

@ -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()

View File

@ -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) => {

View File

@ -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)

View File

@ -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"""

View File

@ -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]

View File

@ -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

View File

@ -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):

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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",))

View File

@ -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",
]

View File

@ -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")},
),
]

View File

@ -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")

View File

@ -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())

View File

@ -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)

View File

@ -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)

View File

@ -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}"

View File

@ -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

View File

@ -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")

View File

@ -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),
]

View File

@ -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",

View File

@ -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,

View File

@ -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",

View File

@ -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),
),
]

View File

@ -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),
]

View File

@ -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")

View File

@ -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()

View File

@ -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())

View File

@ -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."

View File

@ -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

View File

@ -16,6 +16,8 @@ class IdentificationStageSerializer(ModelSerializer):
"name", "name",
"user_fields", "user_fields",
"template", "template",
"enrollment_flow",
"recovery_flow",
] ]

View File

@ -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(),
} }

View File

@ -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",
),
),
]

View File

@ -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"

View File

@ -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")

View File

@ -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>

View File

@ -1,38 +1,12 @@
{% extends 'base/skeleton.html' %}
{% load static %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block body %} <header class="pf-c-login__main-header">
<div class="pf-c-background-image">
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
<filter id="image_overlay">
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
<feFuncA type="table" tableValues="0 1"></feFuncA>
</feComponentTransfer>
</filter>
</svg>
</div>
{% include 'partials/messages.html' %}
<div class="pf-c-login">
<div class="pf-c-login__container">
<header class="pf-c-login__header">
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
alt="passbook icon" />
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
alt="passbook branding" />
</header>
<main class="pf-c-login__main">
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl"> <h1 class="pf-c-title pf-m-3xl">
{% trans 'Trouble Logging In?' %} {% trans 'Trouble Logging In?' %}
</h1> </h1>
</header> </header>
<div class="pf-c-login__main-body"> <div class="pf-c-login__main-body">
{% block card %} {% block card %}
<form method="POST" class="pf-c-form"> <form method="POST" class="pf-c-form">
{% block above_form %} {% block above_form %}
@ -47,26 +21,9 @@
</div> </div>
</form> </form>
{% endblock %} {% endblock %}
</div> </div>
<footer class="pf-c-login__main-footer"> <footer class="pf-c-login__main-footer">
{% if config.login.subtext %} {% if config.login.subtext %}
<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 %}

View File

@ -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)

View File

@ -38,6 +38,7 @@ class PromptSerializer(ModelSerializer):
"type", "type",
"required", "required",
"placeholder", "placeholder",
"order",
] ]

View File

@ -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))

View File

@ -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,
),
),
]

View File

@ -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:

View File

@ -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

View File

@ -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()

View File

@ -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"""

View File

@ -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