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": {
"amqp": {
"hashes": [
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
"sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b",
"sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139"
],
"version": "==2.5.2"
"version": "==2.6.0"
},
"asgiref": {
"hashes": [
@ -53,18 +53,18 @@
},
"boto3": {
"hashes": [
"sha256:1bdab4f87ff39d5aab59b0aae69965bf604fa5608984c673877f4c62c1f16240",
"sha256:2b4924ccc1603d562969b9f3c8c74ff4a1f3bdbafe857c990422c73d8e2e229e"
"sha256:26f8564b46d009b8f4c6470a6d6cde147b282a197339c7e31cbb0fe9fd9e5f5d",
"sha256:f59d0bd230ed3a4b932c5c4e497a0e0ff3c93b46b7e8cde54efb6fe10c8266ba"
],
"index": "pypi",
"version": "==1.13.18"
"version": "==1.13.20"
},
"botocore": {
"hashes": [
"sha256:93574cf95a64c71d35c12c93a23f6214cf2f4b461be3bda3a436381cbe126a84",
"sha256:e65eb27cae262a510e335bc0c0e286e9e42381b1da0aafaa79fa13c1d8d74a95"
"sha256:990f3fc33dec746829740b1a9e1fe86183cdc96aedba6a632ccfcbae03e097cc",
"sha256:d4cc47ac989a7f1d2992ef7679fb423a7966f687becf623a291a555a2d7ce1c0"
],
"version": "==1.16.18"
"version": "==1.16.20"
},
"celery": {
"hashes": [
@ -364,11 +364,11 @@
},
"kombu": {
"hashes": [
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
"sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505",
"sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07"
],
"index": "pypi",
"version": "==4.6.8"
"version": "==4.6.9"
},
"ldap3": {
"hashes": [
@ -688,10 +688,10 @@
},
"redis": {
"hashes": [
"sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
"sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
],
"version": "==3.5.2"
"version": "==3.5.3"
},
"requests": {
"hashes": [
@ -858,10 +858,10 @@
},
"autopep8": {
"hashes": [
"sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954"
"sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0"
],
"index": "pypi",
"version": "==1.5.2"
"version": "==1.5.3"
},
"bandit": {
"hashes": [
@ -971,10 +971,10 @@
},
"gitpython": {
"hashes": [
"sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94",
"sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09"
"sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a",
"sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"
],
"version": "==3.1.2"
"version": "==3.1.3"
},
"isort": {
"hashes": [

View file

@ -1,6 +1,7 @@
"""passbook administration forms"""
from django import forms
from passbook.admin.fields import CodeMirrorWidget, YAMLField
from passbook.core.models import User
@ -8,3 +9,4 @@ class PolicyTestForm(forms.Form):
"""Form to test policies against user"""
user = forms.ModelChoiceField(queryset=User.objects.all())
context = YAMLField(widget=CodeMirrorWidget(), required=False, initial=dict)

View file

@ -55,15 +55,26 @@
</div>
</div>
<div class="pf-c-card__body">
{% if factor_count < 1 %}
<i class="pficon-error-circle-o"></i> {{ factor_count }}
{% if stage_count < 1 %}
<i class="pficon-error-circle-o"></i> {{ stage_count }}
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
{% else %}
<i class="pf-icon pf-icon-ok"></i> {{ factor_count }}
<i class="pf-icon pf-icon-ok"></i> {{ stage_count }}
{% endif %}
</div>
</a>
<a href="{% url 'passbook_admin:stages' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
<div class="pf-c-card__head">
<div class="pf-c-card__head-main">
<i class="pf-icon pf-icon-topology"></i> {% trans 'Flows' %}
</div>
</div>
<div class="pf-c-card__body">
<i class="pf-icon pf-icon-ok"></i> {{ flow_count }}
</div>
</a>
<a href="{% url 'passbook_admin:policies' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
<div class="pf-c-card__head">
<div class="pf-c-card__head-main">

View file

@ -29,6 +29,7 @@
<th role="columnheader" scope="col">{% trans 'Field' %}</th>
<th role="columnheader" scope="col">{% trans 'Label' %}</th>
<th role="columnheader" scope="col">{% trans 'Type' %}</th>
<th role="columnheader" scope="col">{% trans 'Order' %}</th>
<th role="columnheader" scope="col">{% trans 'Flows' %}</th>
<th role="cell"></th>
</tr>
@ -51,6 +52,11 @@
{{ prompt.type }}
</div>
</td>
<td role="cell">
<div>
{{ prompt.order }}
</div>
</td>
<td role="cell">
<ul>
{% for flow in prompt.flow_set.all %}

View file

@ -1,11 +1,15 @@
"""passbook Policy administration"""
from typing import Any, Dict
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404
from django.db.models import QuerySet
from django.forms import Form
from django.http import Http404, HttpRequest, HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.views.generic import DeleteView, FormView, ListView, UpdateView
@ -15,8 +19,8 @@ from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.forms.policies import PolicyTestForm
from passbook.lib.utils.reflection import all_subclasses, path_to_class
from passbook.lib.views import CreateAssignPermView
from passbook.policies.engine import PolicyEngine
from passbook.policies.models import Policy
from passbook.policies.models import Policy, PolicyBinding
from passbook.policies.process import PolicyProcess, PolicyRequest
class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
@ -25,14 +29,14 @@ class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
model = Policy
permission_required = "passbook_policies.view_policy"
paginate_by = 10
ordering = "order"
ordering = "name"
template_name = "administration/policy/list.html"
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs["types"] = {x.__name__: x for x in all_subclasses(Policy)}
return super().get_context_data(**kwargs)
def get_queryset(self):
def get_queryset(self) -> QuerySet:
return super().get_queryset().select_subclasses()
@ -51,14 +55,14 @@ class PolicyCreateView(
success_url = reverse_lazy("passbook_admin:policies")
success_message = _("Successfully created Policy")
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
form_cls = self.get_form_class()
if hasattr(form_cls, "template_name"):
kwargs["base_template"] = form_cls.template_name
return kwargs
def get_form_class(self):
def get_form_class(self) -> Form:
policy_type = self.request.GET.get("type")
try:
model = next(x for x in all_subclasses(Policy) if x.__name__ == policy_type)
@ -79,19 +83,19 @@ class PolicyUpdateView(
success_url = reverse_lazy("passbook_admin:policies")
success_message = _("Successfully updated Policy")
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
form_cls = self.get_form_class()
if hasattr(form_cls, "template_name"):
kwargs["base_template"] = form_cls.template_name
return kwargs
def get_form_class(self):
def get_form_class(self) -> Form:
form_class_path = self.get_object().form
form_class = path_to_class(form_class_path)
return form_class
def get_object(self, queryset=None):
def get_object(self, queryset=None) -> Policy:
return (
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
@ -109,12 +113,12 @@ class PolicyDeleteView(
success_url = reverse_lazy("passbook_admin:policies")
success_message = _("Successfully deleted Policy")
def get_object(self, queryset=None):
def get_object(self, queryset=None) -> Policy:
return (
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
def delete(self, request, *args, **kwargs):
def delete(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)
@ -128,27 +132,30 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo
template_name = "administration/policy/test.html"
object = None
def get_object(self, queryset=None):
def get_object(self, queryset=None) -> QuerySet:
return (
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
)
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs["policy"] = self.get_object()
return super().get_context_data(**kwargs)
def post(self, *args, **kwargs):
def post(self, *args, **kwargs) -> HttpResponse:
self.object = self.get_object()
return super().post(*args, **kwargs)
def form_valid(self, form):
def form_valid(self, form: PolicyTestForm) -> HttpResponse:
policy = self.get_object()
user = form.cleaned_data.get("user")
policy_engine = PolicyEngine([policy], user, self.request)
policy_engine.use_cache = False
policy_engine.build()
result = policy_engine.passing
if result:
p_request = PolicyRequest(user)
p_request.http_request = self.request
p_request.context = form.cleaned_data
proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
result = proc.execute()
if result.passing:
messages.success(self.request, _("User successfully passed policy."))
else:
messages.error(self.request, _("User didn't pass policy."))

View file

@ -20,7 +20,7 @@ class PromptListView(LoginRequiredMixin, PermissionListMixin, ListView):
model = Prompt
permission_required = "passbook_stages_prompt.view_prompt"
ordering = "field_key"
ordering = "order"
paginate_by = 40
template_name = "administration/stage_prompt/list.html"

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 jinja2 import Undefined
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.nativetypes import NativeEnvironment
from model_utils.managers import InheritanceManager
from structlog import get_logger
@ -24,7 +23,6 @@ from passbook.lib.models import CreatedUpdatedModel
from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment()
def default_token_duration():
@ -208,8 +206,11 @@ class PropertyMapping(models.Model):
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context."""
from passbook.policies.expression.evaluator import Evaluator
evaluator = Evaluator()
try:
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
expression = evaluator.env.from_string(self.expression)
except TemplateSyntaxError as exc:
raise PropertyMappingExpressionException from exc
try:
@ -221,8 +222,11 @@ class PropertyMapping(models.Model):
raise PropertyMappingExpressionException from exc
def save(self, *args, **kwargs):
from passbook.policies.expression.evaluator import Evaluator
evaluator = Evaluator()
try:
NATIVE_ENVIRONMENT.from_string(self.expression)
evaluator.env.from_string(self.expression)
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
return super().save(*args, **kwargs)

View file

@ -1,28 +1,7 @@
"""passbook core signals"""
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models.signals import post_save
from django.dispatch import receiver
from structlog import get_logger
LOGGER = get_logger()
user_signed_up = Signal(providing_args=["request", "user"])
invitation_created = Signal(providing_args=["request", "invitation"])
invitation_used = Signal(providing_args=["request", "invitation", "user"])
password_changed = Signal(providing_args=["user", "password"])
@receiver(post_save)
# pylint: disable=unused-argument
def invalidate_policy_cache(sender, instance, **_):
"""Invalidate Policy cache when policy is updated"""
from passbook.policies.models import Policy
from passbook.policies.process import cache_key
if isinstance(instance, Policy):
LOGGER.debug("Invalidating policy cache", policy=instance)
prefix = cache_key(instance) + "*"
keys = cache.keys(prefix)
cache.delete_many(keys)
LOGGER.debug("Deleted %d keys", len(keys))

View file

@ -2,6 +2,13 @@
{% load i18n %}
{% csrf_token %}
{% if form.non_field_errors %}
<div class="pf-c-form__group has-error">
<p class="pf-c-form__helper-text pf-m-error">
{{ form.non_field_errors }}
</p>
</div>
{% endif %}
{% for field in form %}
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
{% if field.field.widget|fieldtype == 'RadioSelect' %}

View file

@ -17,7 +17,9 @@
<div class="pf-c-form__horizontal-group">
<div class="pf-c-form__actions">
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
{% if unenrollment_enabled %}
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
{% endif %}
</div>
</div>
</div>

View file

@ -1,6 +1,4 @@
"""passbook access helper classes"""
from typing import List, Tuple
from django.contrib import messages
from django.http import HttpRequest
from django.utils.translation import gettext as _
@ -8,6 +6,7 @@ from structlog import get_logger
from passbook.core.models import Application, Provider, User
from passbook.policies.engine import PolicyEngine
from passbook.policies.types import PolicyResult
LOGGER = get_logger()
@ -33,9 +32,7 @@ class AccessMixin:
)
raise exc
def user_has_access(
self, application: Application, user: User
) -> Tuple[bool, List[str]]:
def user_has_access(self, application: Application, user: User) -> PolicyResult:
"""Check if user has access to application."""
LOGGER.debug("Checking permissions", user=user, application=application)
policy_engine = PolicyEngine(application.policies.all(), user, self.request)

View file

@ -1,4 +1,6 @@
"""passbook core user views"""
from typing import Any, Dict
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
@ -6,6 +8,7 @@ from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from passbook.core.forms.users import UserDetailForm
from passbook.flows.models import Flow, FlowDesignation
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
@ -19,3 +22,11 @@ class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
def get_object(self):
return self.request.user
def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
unenrollment_flow = Flow.with_policy(
self.request, designation=FlowDesignation.UNRENOLLMENT
)
kwargs["unenrollment_enabled"] = bool(unenrollment_flow)
return kwargs

View file

@ -34,7 +34,6 @@ class CertificateKeyPairForm(forms.ModelForm):
password=None,
backend=default_backend(),
)
load_pem_x509_certificate(key_data.encode("utf-8"), default_backend())
except ValueError:
raise forms.ValidationError("Unable to load private key.")
return key_data

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 django.db import models
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager
from structlog import get_logger
from passbook.core.types import UIUserSettings
from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger()
class FlowDesignation(models.TextChoices):
"""Designation of what a Flow should be used for. At a later point, this
@ -62,10 +66,29 @@ class Flow(PolicyBindingModel):
PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+"
)
def related_flow(self, designation: str) -> Optional["Flow"]:
@staticmethod
def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]:
"""Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
from passbook.policies.engine import PolicyEngine
flows = Flow.objects.filter(**flow_filter)
for flow in flows:
engine = PolicyEngine(flow, request.user, request)
engine.build()
result = engine.result
if result.passing:
LOGGER.debug("with_policy: flow passing", flow=flow)
return flow
LOGGER.warning(
"with_policy: flow not passing", flow=flow, messages=result.messages
)
LOGGER.debug("with_policy: no flow found", filters=flow_filter)
return None
def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]:
"""Get a related flow with `designation`. Currently this only queries
Flows by `designation`, but will eventually use `self` for related lookups."""
return Flow.objects.filter(designation=designation).first()
return Flow.with_policy(request, designation=designation)
def __str__(self) -> str:
return f"Flow {self.name} ({self.slug})"

View file

@ -1,7 +1,7 @@
"""Flows Planner"""
from dataclasses import dataclass, field
from time import time
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
from django.core.cache import cache
from django.http import HttpRequest
@ -51,22 +51,12 @@ class FlowPlanner:
self.use_cache = True
self.flow = flow
def _check_flow_root_policies(self, request: HttpRequest) -> Tuple[bool, List[str]]:
engine = PolicyEngine(self.flow.policies.all(), request.user, request)
engine.build()
return engine.result
def plan(
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
) -> FlowPlan:
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
and return ordered list"""
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
# First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow
root_passing, root_passing_messages = self._check_flow_root_policies(request)
if not root_passing:
raise FlowNonApplicableException(root_passing_messages)
# Bit of a workaround here, if there is a pending user set in the default context
# we use that user for our cache key
# to make sure they don't get the generic response
@ -74,14 +64,24 @@ class FlowPlanner:
user = default_context[PLAN_CONTEXT_PENDING_USER]
else:
user = request.user
# First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow
engine = PolicyEngine(self.flow, user, request)
if default_context:
engine.request.context = default_context
engine.build()
result = engine.result
if not result.passing:
raise FlowNonApplicableException(result.messages)
# User is passing so far, check if we have a cached plan
cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None)
if cached_plan and self.use_cache:
LOGGER.debug(
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
)
LOGGER.debug(cached_plan)
return cached_plan
LOGGER.debug("f(plan): building plan", flow=self.flow)
plan = self._build_plan(user, request, default_context)
cache.set(cache_key(self.flow, user), plan)
if not plan.stages:
@ -106,11 +106,10 @@ class FlowPlanner:
.select_related()
):
binding = stage.flowstagebinding_set.get(flow__pk=self.flow.pk)
engine = PolicyEngine(binding.policies.all(), user, request)
engine = PolicyEngine(binding, user, request)
engine.request.context = plan.context
engine.build()
passing, _ = engine.result
if passing:
if engine.passing:
LOGGER.debug("f(plan): Stage passing", stage=stage, flow=self.flow)
plan.stages.append(stage)
end_time = time()

View file

@ -138,11 +138,10 @@ const loadFormCode = () => {
newScript.src = script.src;
document.head.appendChild(newScript);
});
}
};
const setFormSubmitHandlers = () => {
document.querySelectorAll("#flow-body form").forEach(form => {
console.log(`Setting action for form ${form}`);
// debugger;
form.action = flowBodyUrl;
console.log(`Adding handler for form ${form}`);
form.addEventListener('submit', (e) => {

View file

@ -1,16 +1,19 @@
"""flow planner tests"""
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
from django.core.cache import cache
from django.shortcuts import reverse
from django.test import RequestFactory, TestCase
from guardian.shortcuts import get_anonymous_user
from passbook.core.models import User
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import FlowPlanner
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
from passbook.policies.types import PolicyResult
from passbook.stages.dummy.models import DummyStage
POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],))
POLICY_RESULT_MOCK = PropertyMock(return_value=PolicyResult(False))
TIME_NOW_MOCK = MagicMock(return_value=3)
@ -37,8 +40,7 @@ class TestFlowPlanner(TestCase):
planner.plan(request)
@patch(
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
POLICY_RESULT_MOCK,
"passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
)
def test_non_applicable_plan(self):
"""Test that empty plan raises exception"""
@ -80,3 +82,24 @@ class TestFlowPlanner(TestCase):
self.assertEqual(
TIME_NOW_MOCK.call_count, 2
) # When taking from cache, time is not measured
def test_planner_default_context(self):
"""Test planner with default_context"""
flow = Flow.objects.create(
name="test-default-context",
slug="test-default-context",
designation=FlowDesignation.AUTHENTICATION,
)
FlowStageBinding.objects.create(
flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0
)
user = User.objects.create(username="test-user")
request = self.request_factory.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = user
planner = FlowPlanner(flow)
planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user})
key = cache_key(flow, user)
self.assertTrue(cache.get(key) is not None)

View file

@ -1,5 +1,5 @@
"""flow views tests"""
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
from django.shortcuts import reverse
from django.test import Client, TestCase
@ -9,9 +9,10 @@ from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import FlowPlan
from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
from passbook.lib.config import CONFIG
from passbook.policies.types import PolicyResult
from passbook.stages.dummy.models import DummyStage
POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],))
POLICY_RESULT_MOCK = PropertyMock(return_value=PolicyResult(False))
class TestFlowExecutor(TestCase):
@ -44,8 +45,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(cancel_mock.call_count, 1)
@patch(
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
POLICY_RESULT_MOCK,
"passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
)
def test_invalid_non_applicable_flow(self):
"""Tests that a non-applicable flow returns the correct error message"""

View file

@ -1,7 +1,7 @@
"""passbook multi-stage authentication engine"""
from typing import Any, Dict, Optional
from django.http import HttpRequest, HttpResponse
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
@ -34,7 +34,7 @@ class FlowExecutorView(View):
def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug)
self.flow = get_object_or_404(Flow, slug=flow_slug)
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
"""When a flow is non-applicable check if user is on the correct domain"""
@ -164,7 +164,9 @@ class ToDefaultFlow(View):
designation: Optional[FlowDesignation] = None
def dispatch(self, request: HttpRequest) -> HttpResponse:
flow = get_object_or_404(Flow, designation=self.designation)
flow = Flow.with_policy(request, designation=self.designation)
if not flow:
raise Http404
# If user already has a pending plan, clear it so we don't have to later.
if SESSION_KEY_PLAN in self.request.session:
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]

View file

@ -1,5 +1,6 @@
"""Generic models"""
from django.db import models
from model_utils.managers import InheritanceManager
class CreatedUpdatedModel(models.Model):
@ -10,3 +11,27 @@ class CreatedUpdatedModel(models.Model):
class Meta:
abstract = True
class InheritanceAutoManager(InheritanceManager):
"""Object manager which automatically selects the subclass"""
def get_queryset(self):
return super().get_queryset().select_subclasses()
class InheritanceForwardManyToOneDescriptor(
models.fields.related.ForwardManyToOneDescriptor
):
"""Forward ManyToOne Descriptor that selects subclass. Requires InheritanceAutoManager."""
def get_queryset(self, **hints):
return self.field.remote_field.model.objects.db_manager(
hints=hints
).select_subclasses()
class InheritanceForeignKey(models.ForeignKey):
"""Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor"""
forward_related_accessor_class = InheritanceForwardManyToOneDescriptor

View file

@ -12,7 +12,7 @@ class PolicyBindingSerializer(ModelSerializer):
class Meta:
model = PolicyBinding
fields = ["policy", "target", "enabled", "order"]
fields = ["policy", "target", "enabled", "order", "timeout"]
class PolicyBindingViewSet(ModelViewSet):

View file

@ -1,4 +1,6 @@
"""passbook policies app config"""
from importlib import import_module
from django.apps import AppConfig
@ -8,3 +10,7 @@ class PassbookPoliciesConfig(AppConfig):
name = "passbook.policies"
label = "passbook_policies"
verbose_name = "passbook Policies"
def ready(self):
"""Load source_types from config file"""
import_module("passbook.policies.signals")

View file

@ -1,14 +1,14 @@
"""passbook policy engine"""
from multiprocessing import Pipe, set_start_method
from multiprocessing.connection import Connection
from typing import List, Optional, Tuple
from typing import List, Optional
from django.core.cache import cache
from django.http import HttpRequest
from structlog import get_logger
from passbook.core.models import User
from passbook.policies.models import Policy
from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel
from passbook.policies.process import PolicyProcess, cache_key
from passbook.policies.types import PolicyRequest, PolicyResult
@ -24,12 +24,14 @@ class PolicyProcessInfo:
process: PolicyProcess
connection: Connection
result: Optional[PolicyResult]
policy: Policy
binding: PolicyBinding
def __init__(self, process: PolicyProcess, connection: Connection, policy: Policy):
def __init__(
self, process: PolicyProcess, connection: Connection, binding: PolicyBinding
):
self.process = process
self.connection = connection
self.policy = policy
self.binding = binding
self.result = None
@ -37,68 +39,84 @@ class PolicyEngine:
"""Orchestrate policy checking, launch tasks and return result"""
use_cache: bool = True
policies: List[Policy] = []
request: PolicyRequest
__pbm: PolicyBindingModel
__cached_policies: List[PolicyResult]
__processes: List[PolicyProcessInfo]
def __init__(self, policies, user: User, request: HttpRequest = None):
self.policies = policies
def __init__(
self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None
):
if not isinstance(pbm, PolicyBindingModel):
raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
self.__pbm = pbm
self.request = PolicyRequest(user)
if request:
self.request.http_request = request
self.__cached_policies = []
self.__processes = []
def _select_subclasses(self) -> List[Policy]:
def _iter_bindings(self) -> List[PolicyBinding]:
"""Make sure all Policies are their respective classes"""
return (
Policy.objects.filter(pk__in=[x.pk for x in self.policies])
.select_subclasses()
.order_by("order")
return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by(
"order"
)
def _check_policy_type(self, policy: Policy):
"""Check policy type, make sure it's not the root class as that has no logic implemented"""
# policy_type = type(policy)
if policy.__class__ == Policy:
raise TypeError(f"Policy '{policy}' is root type")
def build(self) -> "PolicyEngine":
"""Build task group"""
for policy in self._select_subclasses():
cached_policy = cache.get(cache_key(policy, self.request.user), None)
for binding in self._iter_bindings():
self._check_policy_type(binding.policy)
key = cache_key(binding, self.request)
cached_policy = cache.get(key, None)
if cached_policy and self.use_cache:
LOGGER.debug("P_ENG: Taking result from cache", policy=policy)
LOGGER.debug(
"P_ENG: Taking result from cache",
policy=binding.policy,
cache_key=key,
)
self.__cached_policies.append(cached_policy)
continue
LOGGER.debug("P_ENG: Evaluating policy", policy=policy)
LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy)
our_end, task_end = Pipe(False)
task = PolicyProcess(policy, self.request, task_end)
LOGGER.debug("P_ENG: Starting Process", policy=policy)
task = PolicyProcess(binding, self.request, task_end)
LOGGER.debug("P_ENG: Starting Process", policy=binding.policy)
task.start()
self.__processes.append(
PolicyProcessInfo(process=task, connection=our_end, policy=policy)
PolicyProcessInfo(process=task, connection=our_end, binding=binding)
)
# If all policies are cached, we have an empty list here.
for proc_info in self.__processes:
proc_info.process.join(proc_info.policy.timeout)
proc_info.process.join(proc_info.binding.timeout)
# Only call .recv() if no result is saved, otherwise we just deadlock here
if not proc_info.result:
proc_info.result = proc_info.connection.recv()
return self
@property
def result(self) -> Tuple[bool, List[str]]:
def result(self) -> PolicyResult:
"""Get policy-checking result"""
messages: List[str] = []
process_results: List[PolicyResult] = [
x.result for x in self.__processes if x.result
]
for result in process_results + self.__cached_policies:
LOGGER.debug("P_ENG: result", passing=result.passing)
LOGGER.debug(
"P_ENG: result", passing=result.passing, messages=result.messages
)
if result.messages:
messages += result.messages
if not result.passing:
return False, messages
return True, messages
return PolicyResult(False, *messages)
return PolicyResult(True, *messages)
@property
def passing(self) -> bool:
"""Only get true/false if user passes"""
return self.result[0]
return self.result.passing

View file

@ -1,22 +1,21 @@
"""passbook expression policy evaluator"""
import re
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import Any, Dict, List, Optional
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from jinja2 import Undefined
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.exceptions import TemplateSyntaxError
from jinja2.nativetypes import NativeEnvironment
from requests import Session
from structlog import get_logger
from passbook.core.models import User
from passbook.flows.planner import PLAN_CONTEXT_SSO
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.http import get_client_ip
from passbook.policies.types import PolicyRequest, PolicyResult
if TYPE_CHECKING:
from passbook.core.models import User
LOGGER = get_logger()
@ -25,12 +24,33 @@ class Evaluator:
_env: NativeEnvironment
_context: Dict[str, Any]
_messages: List[str]
def __init__(self):
self._env = NativeEnvironment()
self._env = NativeEnvironment(
extensions=["jinja2.ext.do"],
trim_blocks=True,
lstrip_blocks=True,
line_statement_prefix=">",
)
# update passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace
self._env.globals["pb_message"] = self.jinja2_func_message
self._context = {
"pb_is_group_member": Evaluator.jinja2_func_is_group_member,
"pb_user_by": Evaluator.jinja2_func_user_by,
"pb_logger": get_logger(),
"requests": Session(),
}
self._messages = []
@property
def env(self) -> NativeEnvironment:
"""Access to our custom NativeEnvironment"""
return self._env
@staticmethod
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
@ -43,55 +63,69 @@ class Evaluator:
return re.sub(regex, repl, value)
@staticmethod
def jinja2_func_is_group_member(user: "User", group_name: str) -> bool:
def jinja2_func_user_by(**filters) -> Optional[User]:
"""Get user by filters"""
users = User.objects.filter(**filters)
if users:
return users.first()
return None
@staticmethod
def jinja2_func_is_group_member(user: User, group_name: str) -> bool:
"""Check if `user` is member of group with name `group_name`"""
return user.groups.filter(name=group_name).exists()
def _get_expression_context(
self, request: PolicyRequest, **kwargs
) -> Dict[str, Any]:
"""Return dictionary with additional global variables passed to expression"""
def jinja2_func_message(self, message: str):
"""Wrapper to append to messages list, which is returned with PolicyResult"""
self._messages.append(message)
def set_policy_request(self, request: PolicyRequest):
"""Update context based on policy request (if http request is given, update that too)"""
# update passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md
kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member
kwargs["pb_logger"] = get_logger()
kwargs["requests"] = Session()
kwargs["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
self._context["request"] = request
if request.http_request:
kwargs["pb_client_ip"] = (
get_client_ip(request.http_request) or "255.255.255.255"
)
if SESSION_KEY_PLAN in request.http_request.session:
kwargs["pb_flow_plan"] = request.http_request.session[SESSION_KEY_PLAN]
return kwargs
self.set_http_request(request.http_request)
def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult:
"""Parse and evaluate expression.
If the Expression evaluates to a list with 2 items, the first is used as passing bool and
the second as messages.
If the Expression evaluates to a truthy-object, it is used as passing bool."""
def set_http_request(self, request: HttpRequest):
"""Update context based on http request"""
# update passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md
self._context["pb_client_ip"] = (
get_client_ip(request.http_request) or "255.255.255.255"
)
self._context["request"] = request
if SESSION_KEY_PLAN in request.http_request.session:
self._context["pb_flow_plan"] = request.http_request.session[
SESSION_KEY_PLAN
]
def evaluate(self, expression_source: str) -> PolicyResult:
"""Parse and evaluate expression. Policy is expected to return a truthy object.
Messages can be added using 'do pb_message()'."""
try:
expression = self._env.from_string(expression_source)
expression = self._env.from_string(expression_source.lstrip().rstrip())
except TemplateSyntaxError as exc:
return PolicyResult(False, str(exc))
try:
result: Optional[Any] = expression.render(
request=request, **self._get_expression_context(request)
)
result: Optional[Any] = expression.render(self._context)
except Exception as exc: # pylint: disable=broad-except
LOGGER.warning("Expression error", exc=exc)
return PolicyResult(False, str(exc))
else:
policy_result = PolicyResult(False)
policy_result.messages = tuple(self._messages)
if isinstance(result, Undefined):
LOGGER.warning(
"Expression policy returned undefined",
src=expression_source,
req=request,
req=self._context,
)
return PolicyResult(False)
if isinstance(result, (list, tuple)) and len(result) == 2:
return PolicyResult(*result)
policy_result.passing = False
if result:
return PolicyResult(bool(result))
return PolicyResult(False)
except UndefinedError as exc:
return PolicyResult(False, str(exc))
policy_result.passing = bool(result)
return policy_result
def validate(self, expression: str):
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
@ -99,4 +133,4 @@ class Evaluator:
self._env.from_string(expression)
return True
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc

View file

@ -16,7 +16,9 @@ class ExpressionPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult:
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
return Evaluator().evaluate(self.expression, request)
evaluator = Evaluator()
evaluator.set_policy_request(request)
return evaluator.evaluate(self.expression)
def save(self, *args, **kwargs):
Evaluator().validate(self.expression)

View file

@ -17,13 +17,15 @@ class TestEvaluator(TestCase):
"""test simple value expression"""
template = "True"
evaluator = Evaluator()
self.assertEqual(evaluator.evaluate(template, self.request).passing, True)
evaluator.set_policy_request(self.request)
self.assertEqual(evaluator.evaluate(template).passing, True)
def test_messages(self):
"""test expression with message return"""
template = "False, 'some message'"
template = '{% do pb_message("some message") %}False'
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("some message",))
@ -31,7 +33,8 @@ class TestEvaluator(TestCase):
"""test invalid syntax"""
template = "{%"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("tag name expected",))
@ -39,7 +42,8 @@ class TestEvaluator(TestCase):
"""test undefined result"""
template = "{{ foo.bar }}"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("'foo' is undefined",))

View file

@ -3,8 +3,8 @@ from django import forms
from passbook.policies.models import PolicyBinding, PolicyBindingModel
GENERAL_FIELDS = ["name", "negate", "order", "timeout"]
GENERAL_SERIALIZER_FIELDS = ["pk", "name", "negate", "order", "timeout"]
GENERAL_FIELDS = ["name"]
GENERAL_SERIALIZER_FIELDS = ["pk", "name"]
class PolicyBindingForm(forms.ModelForm):
@ -18,9 +18,4 @@ class PolicyBindingForm(forms.ModelForm):
class Meta:
model = PolicyBinding
fields = [
"enabled",
"policy",
"target",
"order",
]
fields = ["enabled", "policy", "target", "order", "timeout"]

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 model_utils.managers import InheritanceManager
from passbook.lib.models import CreatedUpdatedModel
from passbook.lib.models import (
CreatedUpdatedModel,
InheritanceAutoManager,
InheritanceForeignKey,
)
from passbook.policies.exceptions import PolicyException
from passbook.policies.types import PolicyRequest, PolicyResult
@ -22,7 +26,6 @@ class PolicyBindingModel(models.Model):
objects = InheritanceManager()
class Meta:
verbose_name = _("Policy Binding Model")
verbose_name_plural = _("Policy Binding Models")
@ -36,13 +39,19 @@ class PolicyBinding(models.Model):
enabled = models.BooleanField(default=True)
policy = models.ForeignKey("Policy", on_delete=models.CASCADE, related_name="+")
policy = InheritanceForeignKey("Policy", on_delete=models.CASCADE, related_name="+")
target = models.ForeignKey(
PolicyBindingModel, on_delete=models.CASCADE, related_name="+"
)
negate = models.BooleanField(
default=False,
help_text=_("Negates the outcome of the policy. Messages are unaffected."),
)
timeout = models.IntegerField(
default=30, help_text=_("Timeout after which Policy execution is terminated.")
)
# default value and non-unique for compatibility
order = models.IntegerField(default=0)
order = models.IntegerField()
def __str__(self) -> str:
return f"PolicyBinding policy={self.policy} target={self.target} order={self.order}"
@ -51,6 +60,7 @@ class PolicyBinding(models.Model):
verbose_name = _("Policy Binding")
verbose_name_plural = _("Policy Bindings")
unique_together = ("policy", "target", "order")
class Policy(CreatedUpdatedModel):
@ -60,11 +70,8 @@ class Policy(CreatedUpdatedModel):
policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField(blank=True, null=True)
negate = models.BooleanField(default=False)
order = models.IntegerField(default=0)
timeout = models.IntegerField(default=30)
objects = InheritanceManager()
objects = InheritanceAutoManager()
def __str__(self):
return f"Policy {self.name}"
@ -72,3 +79,9 @@ class Policy(CreatedUpdatedModel):
def passes(self, request: PolicyRequest) -> PolicyResult:
"""Check if user instance passes this policy"""
raise PolicyException()
class Meta:
base_manager_name = "objects"
verbose_name = _("Policy")
verbose_name_plural = _("Policies")

View file

@ -6,19 +6,20 @@ from typing import Optional
from django.core.cache import cache
from structlog import get_logger
from passbook.core.models import User
from passbook.policies.exceptions import PolicyException
from passbook.policies.models import Policy
from passbook.policies.models import PolicyBinding
from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
def cache_key(policy: Policy, user: Optional[User] = None) -> str:
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
"""Generate Cache key for policy"""
prefix = f"policy_{policy.pk}"
if user:
prefix += f"#{user.pk}"
prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
if request.http_request:
prefix += f"_{request.http_request.session.session_key}"
if request.user:
prefix += f"#{request.user.pk}"
return prefix
@ -26,40 +27,50 @@ class PolicyProcess(Process):
"""Evaluate a single policy within a seprate process"""
connection: Connection
policy: Policy
binding: PolicyBinding
request: PolicyRequest
def __init__(self, policy: Policy, request: PolicyRequest, connection: Connection):
def __init__(
self,
binding: PolicyBinding,
request: PolicyRequest,
connection: Optional[Connection],
):
super().__init__()
self.policy = policy
self.binding = binding
self.request = request
self.connection = connection
if connection:
self.connection = connection
def run(self):
"""Task wrapper to run policy checking"""
def execute(self) -> PolicyResult:
"""Run actual policy, returns result"""
LOGGER.debug(
"P_ENG(proc): Running policy",
policy=self.policy,
policy=self.binding.policy,
user=self.request.user,
process="PolicyProcess",
)
try:
policy_result = self.policy.passes(self.request)
policy_result = self.binding.policy.passes(self.request)
except PolicyException as exc:
LOGGER.debug("P_ENG(proc): error", exc=exc)
policy_result = PolicyResult(False, str(exc))
# Invert result if policy.negate is set
if self.policy.negate:
if self.binding.negate:
policy_result.passing = not policy_result.passing
LOGGER.debug(
"P_ENG(proc): Finished",
policy=self.policy,
policy=self.binding.policy,
result=policy_result,
process="PolicyProcess",
passing=policy_result.passing,
user=self.request.user,
)
key = cache_key(self.policy, self.request.user)
key = cache_key(self.binding, self.request)
cache.set(key, policy_result)
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
self.connection.send(policy_result)
return policy_result
def run(self):
"""Task wrapper to run policy checking"""
self.connection.send(self.execute())

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.policies.dummy.models import DummyPolicy
from passbook.policies.engine import PolicyEngine
from passbook.policies.models import Policy
from passbook.policies.expression.models import ExpressionPolicy
from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel
class PolicyTestEngine(TestCase):
@ -20,40 +21,64 @@ class PolicyTestEngine(TestCase):
self.policy_true = DummyPolicy.objects.create(
result=True, wait_min=0, wait_max=1
)
self.policy_negate = DummyPolicy.objects.create(
negate=True, result=True, wait_min=0, wait_max=1
self.policy_wrong_type = Policy.objects.create(name="wrong_type")
self.policy_raises = ExpressionPolicy.objects.create(
name="raises", expression="{{ 0/0 }}"
)
self.policy_raises = Policy.objects.create(name="raises")
def test_engine_empty(self):
"""Ensure empty policy list passes"""
engine = PolicyEngine([], self.user)
self.assertEqual(engine.build().passing, True)
pbm = PolicyBindingModel.objects.create()
engine = PolicyEngine(pbm, self.user)
result = engine.build().result
self.assertEqual(result.passing, True)
self.assertEqual(result.messages, ())
def test_engine(self):
"""Ensure all policies passes (Mix of false and true -> false)"""
engine = PolicyEngine(
DummyPolicy.objects.filter(negate__exact=False), self.user
)
self.assertEqual(engine.build().passing, False)
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=1)
engine = PolicyEngine(pbm, self.user)
result = engine.build().result
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("dummy",))
def test_engine_negate(self):
"""Test negate flag"""
engine = PolicyEngine(DummyPolicy.objects.filter(negate__exact=True), self.user)
self.assertEqual(engine.build().passing, False)
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(
target=pbm, policy=self.policy_true, negate=True, order=0
)
engine = PolicyEngine(pbm, self.user)
result = engine.build().result
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("dummy",))
def test_engine_policy_error(self):
"""Test negate flag"""
engine = PolicyEngine(Policy.objects.filter(name="raises"), self.user)
self.assertEqual(engine.build().passing, False)
"""Test policy raising an error flag"""
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(target=pbm, policy=self.policy_raises, order=0)
engine = PolicyEngine(pbm, self.user)
result = engine.build().result
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("division by zero",))
def test_engine_policy_type(self):
"""Test invalid policy type"""
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(target=pbm, policy=self.policy_wrong_type, order=0)
with self.assertRaises(TypeError):
engine = PolicyEngine(pbm, self.user)
engine.build()
def test_engine_cache(self):
"""Ensure empty policy list passes"""
engine = PolicyEngine(
DummyPolicy.objects.filter(negate__exact=False), self.user
)
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
engine = PolicyEngine(pbm, self.user)
self.assertEqual(len(cache.keys("policy_*")), 0)
self.assertEqual(engine.build().passing, False)
self.assertEqual(len(cache.keys("policy_*")), 2)
self.assertEqual(len(cache.keys("policy_*")), 1)
self.assertEqual(engine.build().passing, False)
self.assertEqual(len(cache.keys("policy_*")), 2)
self.assertEqual(len(cache.keys("policy_*")), 1)

View file

@ -39,4 +39,6 @@ class PolicyResult:
self.messages = messages
def __str__(self):
return f"<PolicyResult passing={self.passing}>"
if self.messages:
return f"PolicyResult passing={self.passing} messages={self.messages}"
return f"PolicyResult passing={self.passing}"

View file

@ -50,9 +50,9 @@ class PassbookAuthorizationView(AccessMixin, AuthorizationView):
provider.save()
self._application = application
# Check permissions
passing, policy_messages = self.user_has_access(self._application, request.user)
if not passing:
for policy_message in policy_messages:
result = self.user_has_access(self._application, request.user)
if not result.passing:
for policy_message in result.messages:
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")
# Some clients don't pass response_type, so we default to code

View file

@ -18,7 +18,7 @@ LOGGER = get_logger()
def client_related_provider(client: Client) -> Optional[Provider]:
"""Lookup related Application from Client"""
# because oidc_provider is also used by app_gw, we can't be
# sure an OpenIDPRovider instance exists. hence we look through all related models
# sure an OpenIDProvider instance exists. hence we look through all related models
# and choose the one that inherits from Provider, which is guaranteed to
# have the application property
collector = Collector(using="default")
@ -50,9 +50,9 @@ def check_permissions(
policy_engine.build()
# Check permissions
passing, policy_messages = policy_engine.result
if not passing:
for policy_message in policy_messages:
result = policy_engine.result
if not result.passing:
for policy_message in result.messages:
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")

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",
"user_group_membership_field",
"object_uniqueness_field",
"sync_users",
"sync_groups",
"sync_parent_group",
"property_mappings",

View file

@ -16,26 +16,10 @@ LOGGER = get_logger()
class Connector:
"""Wrapper for ldap3 to easily manage user authentication and creation"""
_server: ldap3.Server
_connection = ldap3.Connection
_source: LDAPSource
def __init__(self, source: LDAPSource):
self._source = source
self._server = ldap3.Server(source.server_uri) # Implement URI parsing
def bind(self):
"""Bind using Source's Credentials"""
self._connection = ldap3.Connection(
self._server,
raise_exceptions=True,
user=self._source.bind_cn,
password=self._source.bind_password,
)
self._connection.bind()
if self._source.start_tls:
self._connection.start_tls()
@staticmethod
def encode_pass(password: str) -> bytes:
@ -45,19 +29,23 @@ class Connector:
@property
def base_dn_users(self) -> str:
"""Shortcut to get full base_dn for user lookups"""
return ",".join([self._source.additional_user_dn, self._source.base_dn])
if self._source.additional_user_dn:
return f"{self._source.additional_user_dn},{self._source.base_dn}"
return self._source.base_dn
@property
def base_dn_groups(self) -> str:
"""Shortcut to get full base_dn for group lookups"""
return ",".join([self._source.additional_group_dn, self._source.base_dn])
if self._source.additional_group_dn:
return f"{self._source.additional_group_dn},{self._source.base_dn}"
return self._source.base_dn
def sync_groups(self):
"""Iterate over all LDAP Groups and create passbook_core.Group instances"""
if not self._source.sync_groups:
LOGGER.debug("Group syncing is disabled for this Source")
LOGGER.warning("Group syncing is disabled for this Source")
return
groups = self._connection.extend.standard.paged_search(
groups = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=ldap3.SUBTREE,
@ -87,7 +75,10 @@ class Connector:
def sync_users(self):
"""Iterate over all LDAP Users and create passbook_core.User instances"""
users = self._connection.extend.standard.paged_search(
if not self._source.sync_users:
LOGGER.warning("User syncing is disabled for this Source")
return
users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
@ -101,9 +92,9 @@ class Connector:
LOGGER.warning("Cannot find uniqueness Field in attributes")
continue
try:
defaults = self._build_object_properties(attributes)
user, created = User.objects.update_or_create(
attributes__ldap_uniq=uniq,
defaults=self._build_object_properties(attributes),
attributes__ldap_uniq=uniq, defaults=defaults,
)
except IntegrityError as exc:
LOGGER.warning("Failed to create user", exc=exc)
@ -123,7 +114,7 @@ class Connector:
def sync_membership(self):
"""Iterate over all Users and assign Groups using memberOf Field"""
users = self._connection.extend.standard.paged_search(
users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
@ -220,7 +211,7 @@ class Connector:
LOGGER.debug("Attempting Binding as user", user=user)
try:
temp_connection = ldap3.Connection(
self._server,
self._source.connection.server,
user=user.attributes.get("distinguishedName"),
password=password,
raise_exceptions=True,

View file

@ -26,6 +26,7 @@ class LDAPSourceForm(forms.ModelForm):
"group_object_filter",
"user_group_membership_field",
"object_uniqueness_field",
"sync_users",
"sync_groups",
"sync_parent_group",
"property_mappings",

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"""
from typing import Optional
from django.core.validators import URLValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from ldap3 import Connection, Server
from passbook.core.models import Group, PropertyMapping, Source
@ -22,10 +24,12 @@ class LDAPSource(Source):
additional_user_dn = models.TextField(
help_text=_("Prepended to Base DN for User-queries."),
verbose_name=_("Addition User DN"),
blank=True,
)
additional_group_dn = models.TextField(
help_text=_("Prepended to Base DN for Group-queries."),
verbose_name=_("Addition Group DN"),
blank=True,
)
user_object_filter = models.TextField(
@ -43,6 +47,7 @@ class LDAPSource(Source):
default="objectSid", help_text=_("Field which contains a unique Identifier.")
)
sync_users = models.BooleanField(default=True)
sync_groups = models.BooleanField(default=True)
sync_parent_group = models.ForeignKey(
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
@ -50,6 +55,25 @@ class LDAPSource(Source):
form = "passbook.sources.ldap.forms.LDAPSourceForm"
_connection: Optional[Connection]
@property
def connection(self) -> Connection:
"""Get a fully connected and bound LDAP Connection"""
if not self._connection:
server = Server(self.server_uri)
self._connection = Connection(
server,
raise_exceptions=True,
user=self.bind_cn,
password=self.bind_password,
)
self._connection.bind()
if self.start_tls:
self._connection.start_tls()
return self._connection
class Meta:
verbose_name = _("LDAP Source")

View file

@ -9,7 +9,6 @@ def sync_groups(source_pk: int):
"""Sync LDAP Groups on background worker"""
source = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source)
connector.bind()
connector.sync_groups()
@ -18,7 +17,6 @@ def sync_users(source_pk: int):
"""Sync LDAP Users on background worker"""
source = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source)
connector.bind()
connector.sync_users()
@ -27,7 +25,6 @@ def sync():
"""Sync all sources"""
for source in LDAPSource.objects.filter(enabled=True):
connector = Connector(source)
connector.bind()
connector.sync_users()
connector.sync_groups()
connector.sync_membership()

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"""
import json
from typing import Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional
from urllib.parse import parse_qs, urlencode
from django.http import HttpRequest
@ -14,24 +14,29 @@ from structlog import get_logger
from passbook import __version__
LOGGER = get_logger()
if TYPE_CHECKING:
from passbook.sources.oauth.models import OAuthSource
class BaseOAuthClient:
"""Base OAuth Client"""
session: Session
source: "OAuthSource"
def __init__(self, source, token=""): # nosec
def __init__(self, source: "OAuthSource", token=""): # nosec
self.source = source
self.token = token
self.session = Session()
self.session.headers.update({"User-Agent": "passbook %s" % __version__})
def get_access_token(self, request, callback=None):
def get_access_token(
self, request: HttpRequest, callback=None
) -> Optional[Dict[str, Any]]:
"Fetch access token from callback request."
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
def get_profile_info(self, token: Dict[str, str]):
def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]:
"Fetch user profile information."
try:
headers = {
@ -45,7 +50,7 @@ class BaseOAuthClient:
LOGGER.warning("Unable to fetch user profile", exc=exc)
return None
else:
return response.json() or response.text
return response.json()
def get_redirect_args(self, request, callback) -> Dict[str, str]:
"Get request parameters for redirect url."

View file

@ -21,7 +21,7 @@ from passbook.flows.planner import (
)
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs
from passbook.sources.oauth.clients import get_client
from passbook.sources.oauth.clients import BaseOAuthClient, get_client
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
@ -34,7 +34,7 @@ class OAuthClientMixin:
client_class: Optional[Callable] = None
def get_client(self, source):
def get_client(self, source: OAuthSource) -> BaseOAuthClient:
"Get instance of the OAuth client for this source."
if self.client_class is not None:
# pylint: disable=not-callable

View file

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

View file

@ -16,7 +16,7 @@ class IdentificationStageForm(forms.ModelForm):
class Meta:
model = IdentificationStage
fields = ["name", "user_fields", "template"]
fields = ["name", "user_fields", "template", "enrollment_flow", "recovery_flow"]
widgets = {
"name": forms.TextInput(),
}

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.utils.translation import gettext_lazy as _
from passbook.flows.models import Stage
from passbook.flows.models import Flow, Stage
class UserFields(models.TextChoices):
@ -29,6 +29,29 @@ class IdentificationStage(Stage):
)
template = models.TextField(choices=Templates.choices)
enrollment_flow = models.ForeignKey(
Flow,
on_delete=models.SET_DEFAULT,
null=True,
blank=True,
related_name="+",
default=None,
help_text=_(
"Optional enrollment flow, which is linked at the bottom of the page."
),
)
recovery_flow = models.ForeignKey(
Flow,
on_delete=models.SET_DEFAULT,
null=True,
blank=True,
related_name="+",
default=None,
help_text=_(
"Optional enrollment flow, which is linked at the bottom of the page."
),
)
type = "passbook.stages.identification.stage.IdentificationStageView"
form = "passbook.stages.identification.forms.IdentificationStageForm"

View file

@ -10,7 +10,6 @@ from django.views.generic import FormView
from structlog import get_logger
from passbook.core.models import Source, User
from passbook.flows.models import FlowDesignation
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import StageView
from passbook.stages.identification.forms import IdentificationForm
@ -34,18 +33,17 @@ class IdentificationStageView(FormView, StageView):
return [current_stage.template]
def get_context_data(self, **kwargs):
current_stage: IdentificationStage = self.executor.current_stage
# Check for related enrollment and recovery flow, add URL to view
enrollment_flow = self.executor.flow.related_flow(FlowDesignation.ENROLLMENT)
if enrollment_flow:
if current_stage.enrollment_flow:
kwargs["enroll_url"] = reverse(
"passbook_flows:flow-executor",
kwargs={"flow_slug": enrollment_flow.slug},
"passbook_flows:flow-executor-shell",
kwargs={"flow_slug": current_stage.enrollment_flow.slug},
)
recovery_flow = self.executor.flow.related_flow(FlowDesignation.RECOVERY)
if recovery_flow:
if current_stage.recovery_flow:
kwargs["recovery_url"] = reverse(
"passbook_flows:flow-executor",
kwargs={"flow_slug": recovery_flow.slug},
"passbook_flows:flow-executor-shell",
kwargs={"flow_slug": current_stage.recovery_flow.slug},
)
kwargs["primary_action"] = _("Log in")

View file

@ -1,4 +1,5 @@
{% load i18n %}
{% load static %}
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
@ -21,3 +22,35 @@
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
{% for source in sources %}
<li class="pf-c-login__main-footer-links-item">
<a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
{% if source.icon_path %}
<img src="{% static source.icon_path %}" alt="{{ source.name }}">
{% elif source.icon_url %}
<img src="icon_url" alt="{{ source.name }}">
{% else %}
<i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% if enroll_url or recovery_url %}
<div class="pf-c-login__main-footer-band">
{% if enroll_url %}
<p class="pf-c-login__main-footer-band-item">
{% trans 'Need an account?' %}
<a href="{{ enroll_url }}">{% trans 'Sign up.' %}</a>
</p>
{% endif %}
{% if recovery_url %}
<p class="pf-c-login__main-footer-band-item">
<a href="{{ recovery_url }}">{% trans 'Forgot username or password?' %}</a>
</p>
{% endif %}
</div>
{% endif %}
</footer>

View file

@ -1,72 +1,29 @@
{% extends 'base/skeleton.html' %}
{% load static %}
{% load i18n %}
{% load static %}
{% block body %}
<div class="pf-c-background-image">
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
<filter id="image_overlay">
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
<feFuncA type="table" tableValues="0 1"></feFuncA>
</feComponentTransfer>
</filter>
</svg>
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% trans 'Trouble Logging In?' %}
</h1>
</header>
<div class="pf-c-login__main-body">
{% block card %}
<form method="POST" class="pf-c-form">
{% block above_form %}
{% endblock %}
{% include 'partials/form.html' %}
{% block beneath_form %}
{% endblock %}
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
</div>
</form>
{% endblock %}
</div>
{% include 'partials/messages.html' %}
<div class="pf-c-login">
<div class="pf-c-login__container">
<header class="pf-c-login__header">
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
alt="passbook icon" />
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
alt="passbook branding" />
</header>
<main class="pf-c-login__main">
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% trans 'Trouble Logging In?' %}
</h1>
</header>
<div class="pf-c-login__main-body">
{% block card %}
<form method="POST" class="pf-c-form">
{% block above_form %}
{% endblock %}
{% include 'partials/form.html' %}
{% block beneath_form %}
{% endblock %}
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
</div>
</form>
{% endblock %}
</div>
<footer class="pf-c-login__main-footer">
{% if config.login.subtext %}
<p>{{ config.login.subtext }}</p>
{% endif %}
</footer>
</main>
<footer class="pf-c-login__footer">
<ul class="pf-c-list pf-m-inline">
<li>
<a href="https://beryju.github.io/passbook/">{% trans 'Documentation' %}</a>
</li>
{% config 'passbook.footer_links' as footer_links %}
{% for link in footer_links %}
<li>
<a href="{{ link.href }}">{{ link.name }}</a>
</li>
{% endfor %}
</ul>
</footer>
</div>
</div>
{% endblock %}
<footer class="pf-c-login__main-footer">
{% if config.login.subtext %}
<p>{{ config.login.subtext }}</p>
{% endif %}
</footer>

View file

@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase):
slug="unique-enrollment-string",
designation=FlowDesignation.ENROLLMENT,
)
self.stage.enrollment_flow = flow
self.stage.save()
FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0,
)
response = self.client.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
)
self.assertEqual(response.status_code, 200)
self.assertIn(flow.name, response.rendered_content)
self.assertIn(flow.slug, response.rendered_content)
def test_recovery_flow(self):
"""Test that recovery flow is linked correctly"""
@ -102,12 +106,16 @@ class TestIdentificationStage(TestCase):
slug="unique-recovery-string",
designation=FlowDesignation.RECOVERY,
)
self.stage.recovery_flow = flow
self.stage.save()
FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0,
)
response = self.client.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
)
self.assertEqual(response.status_code, 200)
self.assertIn(flow.name, response.rendered_content)
self.assertIn(flow.slug, response.rendered_content)

View file

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

View file

@ -31,6 +31,7 @@ class PromptAdminForm(forms.ModelForm):
"type",
"required",
"placeholder",
"order",
]
widgets = {
"label": forms.TextInput(),
@ -48,16 +49,19 @@ class PromptForm(forms.Form):
self.stage = stage
self.plan = plan
super().__init__(*args, **kwargs)
for field in self.stage.fields.all():
# list() is called so we only load the fields once
fields = list(self.stage.fields.all())
for field in fields:
field: Prompt
self.fields[field.field_key] = field.field
self.field_order = sorted(fields, key=lambda x: x.order)
def clean(self):
cleaned_data = super().clean()
user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user())
engine = PolicyEngine(self.stage.policies.all(), user)
engine = PolicyEngine(self.stage, user)
engine.request.context = cleaned_data
engine.build()
passing, messages = engine.result
if not passing:
raise forms.ValidationError(messages)
result = engine.result
if not result.passing:
raise forms.ValidationError(list(result.messages))

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"
PASSWORD = "password" # noqa # nosec
NUMBER = "number"
CHECKBOX = "checkbox"
DATE = "data"
DATE_TIME = "data-time"
SEPARATOR = "separator"
HIDDEN = "hidden"
STATIC = "static"
class Prompt(models.Model):
@ -32,41 +38,37 @@ class Prompt(models.Model):
required = models.BooleanField(default=True)
placeholder = models.TextField()
order = models.IntegerField(default=0)
@property
def field(self):
"""Return instantiated form input field"""
attrs = {"placeholder": _(self.placeholder)}
if self.type == FieldTypes.TEXT:
return forms.CharField(
label=_(self.label),
widget=forms.TextInput(attrs=attrs),
required=self.required,
)
field_class = forms.CharField
widget = forms.TextInput(attrs=attrs)
kwargs = {
"label": _(self.label),
"required": self.required,
}
if self.type == FieldTypes.EMAIL:
return forms.EmailField(
label=_(self.label),
widget=forms.TextInput(attrs=attrs),
required=self.required,
)
field_class = forms.EmailField
if self.type == FieldTypes.PASSWORD:
return forms.CharField(
label=_(self.label),
widget=forms.PasswordInput(attrs=attrs),
required=self.required,
)
widget = forms.PasswordInput(attrs=attrs)
if self.type == FieldTypes.NUMBER:
return forms.IntegerField(
label=_(self.label),
widget=forms.NumberInput(attrs=attrs),
required=self.required,
)
field_class = forms.IntegerField
widget = forms.NumberInput(attrs=attrs)
if self.type == FieldTypes.HIDDEN:
return forms.CharField(
widget=forms.HiddenInput(attrs=attrs),
required=False,
initial=self.placeholder,
)
raise ValueError("field_type is not valid, not one of FieldTypes.")
widget = forms.HiddenInput(attrs=attrs)
kwargs["required"] = False
kwargs["initial"] = self.placeholder
if self.type == FieldTypes.CHECKBOX:
field_class = forms.CheckboxInput
kwargs["required"] = False
# TODO: Implement static
# TODO: Implement separator
kwargs["widget"] = widget
return field_class(**kwargs)
def save(self, *args, **kwargs):
if self.type not in FieldTypes:

View file

@ -93,25 +93,6 @@ class TestPromptStage(TestCase):
FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
def test_invalid_type(self):
"""Test that invalid form type raises an error"""
with self.assertRaises(ValueError):
_ = Prompt.objects.create(
field_key="hidden_prompt",
type="invalid",
required=True,
placeholder="HIDDEN_PLACEHOLDER",
)
with self.assertRaises(ValueError):
prompt = Prompt.objects.create(
field_key="hidden_prompt",
type=FieldTypes.HIDDEN,
required=True,
placeholder="HIDDEN_PLACEHOLDER",
)
with patch.object(prompt, "type", MagicMock(return_value="invalid")):
_ = prompt.field
def test_render(self):
"""Test render of form, check if all prompts are rendered correctly"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
@ -139,7 +120,7 @@ class TestPromptStage(TestCase):
expr_policy = ExpressionPolicy.objects.create(
name="validate-form", expression=expr
)
PolicyBinding.objects.create(policy=expr_policy, target=self.stage)
PolicyBinding.objects.create(policy=expr_policy, target=self.stage, order=0)
form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
self.assertEqual(form.is_valid(), True)
return form
@ -151,7 +132,7 @@ class TestPromptStage(TestCase):
expr_policy = ExpressionPolicy.objects.create(
name="validate-form", expression=expr
)
PolicyBinding.objects.create(policy=expr_policy, target=self.stage)
PolicyBinding.objects.create(policy=expr_policy, target=self.stage, order=0)
form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data)
self.assertEqual(form.is_valid(), False)
return form

View file

@ -25,33 +25,30 @@ class UserWriteStageView(StageView):
LOGGER.debug(message)
return self.executor.stage_invalid()
data = self.executor.plan.context[PLAN_CONTEXT_PROMPT]
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
for key, value in data.items():
setter_name = f"set_{key}"
# Check if user has a setter for this key, like set_password
if hasattr(user, setter_name):
setter = getattr(user, setter_name)
if callable(setter):
setter(value)
# User has this key already
elif hasattr(user, key):
setattr(user, key, value)
# Otherwise we just save it as custom attribute
else:
user.attributes[key] = value
user.save()
LOGGER.debug(
"Updated existing user", user=user, flow_slug=self.executor.flow.slug,
)
else:
user = User.objects.create_user(**data)
# Set created user as pending_user, so this can be chained with user_login
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User()
self.executor.plan.context[
PLAN_CONTEXT_AUTHENTICATION_BACKEND
] = class_to_path(ModelBackend)
LOGGER.debug(
"Created new user", user=user, flow_slug=self.executor.flow.slug,
"Created new user", flow_slug=self.executor.flow.slug,
)
user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
for key, value in data.items():
setter_name = f"set_{key}"
# Check if user has a setter for this key, like set_password
if hasattr(user, setter_name):
setter = getattr(user, setter_name)
if callable(setter):
setter(value)
# User has this key already
elif hasattr(user, key):
setattr(user, key, value)
# Otherwise we just save it as custom attribute
else:
user.attributes[key] = value
user.save()
LOGGER.debug(
"Updated existing user", user=user, flow_slug=self.executor.flow.slug,
)
return self.executor.stage_ok()

View file

@ -72,6 +72,7 @@ class TestUserWriteStage(TestCase):
plan.context[PLAN_CONTEXT_PROMPT] = {
"username": "test-user-new",
"password": new_password,
"some-custom-attribute": "test",
}
session = self.client.session
session[SESSION_KEY_PLAN] = plan
@ -88,6 +89,7 @@ class TestUserWriteStage(TestCase):
)
self.assertTrue(user_qs.exists())
self.assertTrue(user_qs.first().check_password(new_password))
self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test")
def test_without_data(self):
"""Test without data results in error"""

View file

@ -837,7 +837,7 @@ paths:
parameters:
- name: policy_uuid
in: path
description: A UUID string identifying this policy.
description: A UUID string identifying this Policy.
required: true
type: string
format: uuid
@ -5079,19 +5079,6 @@ definitions:
title: Name
type: string
x-nullable: true
negate:
title: Negate
type: boolean
order:
title: Order
type: integer
maximum: 2147483647
minimum: -2147483648
timeout:
title: Timeout
type: integer
maximum: 2147483647
minimum: -2147483648
__type__:
title: 'type '
type: string
@ -5100,6 +5087,7 @@ definitions:
required:
- policy
- target
- order
type: object
properties:
policy:
@ -5118,6 +5106,12 @@ definitions:
type: integer
maximum: 2147483647
minimum: -2147483648
timeout:
title: Timeout
description: Timeout after which Policy execution is terminated.
type: integer
maximum: 2147483647
minimum: -2147483648
DummyPolicy:
type: object
properties:
@ -5130,19 +5124,6 @@ definitions:
title: Name
type: string
x-nullable: true
negate:
title: Negate
type: boolean
order:
title: Order
type: integer
maximum: 2147483647
minimum: -2147483648
timeout:
title: Timeout
type: integer
maximum: 2147483647
minimum: -2147483648
result:
title: Result
type: boolean
@ -5170,19 +5151,6 @@ definitions:
title: Name
type: string
x-nullable: true
negate:
title: Negate
type: boolean
order:
title: Order
type: integer
maximum: 2147483647
minimum: -2147483648
timeout:
title: Timeout
type: integer
maximum: 2147483647
minimum: -2147483648
expression:
title: Expression
type: string
@ -5199,19 +5167,6 @@ definitions:
title: Name
type: string
x-nullable: true
negate:
title: Negate
type: boolean
order:
title: Order
type: integer
maximum: 2147483647
minimum: -2147483648
timeout:
title: Timeout
type: integer
maximum: 2147483647
minimum: -2147483648
allowed_count:
title: Allowed count
type: integer
@ -5231,19 +5186,6 @@ definitions:
title: Name
type: string
x-nullable: true
negate:
title: Negate
type: boolean
order:
title: Order
type: integer
maximum: 2147483647
minimum: -2147483648
timeout:
title: Timeout
type: integer
maximum: 2147483647
minimum: -2147483648
amount_uppercase:
title: Amount uppercase
type: integer
@ -5286,19 +5228,6 @@ definitions:
title: Name
type: string
x-nullable: true
negate:
title: Negate
type: boolean
order:
title: Order
type: integer
maximum: 2147483647
minimum: -2147483648
timeout:
title: Timeout
type: integer
maximum: 2147483647
minimum: -2147483648
days:
title: Days
type: integer
@ -5319,19 +5248,6 @@ definitions:
title: Name
type: string
x-nullable: true
negate:
title: Negate
type: boolean
order:
title: Order
type: integer
maximum: 2147483647
minimum: -2147483648
timeout:
title: Timeout
type: integer
maximum: 2147483647
minimum: -2147483648
check_ip:
title: Check ip
type: boolean
@ -5690,8 +5606,6 @@ definitions:
- bind_cn
- bind_password
- base_dn
- additional_user_dn
- additional_group_dn
type: object
properties:
pk:
@ -5738,12 +5652,10 @@ definitions:
title: Addition User DN
description: Prepended to Base DN for User-queries.
type: string
minLength: 1
additional_group_dn:
title: Addition Group DN
description: Prepended to Base DN for Group-queries.
type: string
minLength: 1
user_object_filter:
title: User object filter
description: Consider Objects matching this filter to be Users.
@ -5764,6 +5676,9 @@ definitions:
description: Field which contains a unique Identifier.
type: string
minLength: 1
sync_users:
title: Sync users
type: boolean
sync_groups:
title: Sync groups
type: boolean
@ -6003,6 +5918,20 @@ definitions:
enum:
- stages/identification/login.html
- stages/identification/recovery.html
enrollment_flow:
title: Enrollment flow
description: Optional enrollment flow, which is linked at the bottom of the
page.
type: string
format: uuid
x-nullable: true
recovery_flow:
title: Recovery flow
description: Optional enrollment flow, which is linked at the bottom of the
page.
type: string
format: uuid
x-nullable: true
InvitationStage:
required:
- name
@ -6112,7 +6041,12 @@ definitions:
- e-mail
- password
- number
- checkbox
- data
- data-time
- separator
- hidden
- static
required:
title: Required
type: boolean
@ -6120,6 +6054,11 @@ definitions:
title: Placeholder
type: string
minLength: 1
order:
title: Order
type: integer
maximum: 2147483647
minimum: -2147483648
PromptStage:
required:
- name