From 114bb1b0bdebc5d0ca7c89d94625f4ea54cfd2c5 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 8 May 2020 14:33:14 +0200 Subject: [PATCH] flows: implement planner, start new executor --- passbook/core/views/authentication.py | 2 +- passbook/factors/password/factor.py | 2 +- passbook/flows/exceptions.py | 5 ++ passbook/flows/factor_base.py | 2 +- .../migrations/0003_auto_20200508_1230.py | 21 ++++++ passbook/flows/models.py | 4 +- passbook/flows/planner.py | 66 +++++++++++++++++++ passbook/flows/tests.py | 2 +- passbook/flows/urls.py | 7 +- passbook/flows/{view.py => views.py} | 66 +++++++++++++++++++ passbook/policies/expression/evaluator.py | 2 +- .../migrations/0002_auto_20200508_1230.py | 20 ++++++ passbook/policies/models.py | 8 +++ passbook/providers/saml/models.py | 2 +- passbook/sources/oauth/views/core.py | 2 +- 15 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 passbook/flows/exceptions.py create mode 100644 passbook/flows/migrations/0003_auto_20200508_1230.py create mode 100644 passbook/flows/planner.py rename passbook/flows/{view.py => views.py} (77%) create mode 100644 passbook/policies/migrations/0002_auto_20200508_1230.py diff --git a/passbook/core/views/authentication.py b/passbook/core/views/authentication.py index 2c34a3fec..70eac1105 100644 --- a/passbook/core/views/authentication.py +++ b/passbook/core/views/authentication.py @@ -16,7 +16,7 @@ from passbook.core.forms.authentication import LoginForm, SignUpForm from passbook.core.models import Invitation, Nonce, Source, User from passbook.core.signals import invitation_used, user_signed_up from passbook.factors.password.exceptions import PasswordPolicyInvalid -from passbook.flows.view import AuthenticationView, _redirect_with_qs +from passbook.flows.views import AuthenticationView, _redirect_with_qs from passbook.lib.config import CONFIG LOGGER = get_logger() diff --git a/passbook/factors/password/factor.py b/passbook/factors/password/factor.py index d71600b99..6c39eb148 100644 --- a/passbook/factors/password/factor.py +++ b/passbook/factors/password/factor.py @@ -13,7 +13,7 @@ from structlog import get_logger from passbook.core.models import User from passbook.factors.password.forms import PasswordForm from passbook.flows.factor_base import AuthenticationFactor -from passbook.flows.view import AuthenticationView +from passbook.flows.views import AuthenticationView from passbook.lib.config import CONFIG from passbook.lib.utils.reflection import path_to_class diff --git a/passbook/flows/exceptions.py b/passbook/flows/exceptions.py new file mode 100644 index 000000000..4f781897f --- /dev/null +++ b/passbook/flows/exceptions.py @@ -0,0 +1,5 @@ +"""flow exceptions""" + + +class FlowNonApplicableError(BaseException): + """Exception raised when a Flow does not apply to a user.""" diff --git a/passbook/flows/factor_base.py b/passbook/flows/factor_base.py index 05766cfd1..0f01401a7 100644 --- a/passbook/flows/factor_base.py +++ b/passbook/flows/factor_base.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext as _ from django.views.generic import TemplateView from passbook.core.models import User -from passbook.flows.view import AuthenticationView +from passbook.flows.views import AuthenticationView from passbook.lib.config import CONFIG diff --git a/passbook/flows/migrations/0003_auto_20200508_1230.py b/passbook/flows/migrations/0003_auto_20200508_1230.py new file mode 100644 index 000000000..645c7a53d --- /dev/null +++ b/passbook/flows/migrations/0003_auto_20200508_1230.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.3 on 2020-05-08 12:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_flows", "0002_flowfactorbinding_re_evaluate_policies"), + ] + + operations = [ + migrations.AlterModelOptions( + name="flowfactorbinding", + options={ + "ordering": ["order", "flow"], + "verbose_name": "Flow Factor Binding", + "verbose_name_plural": "Flow Factor Bindings", + }, + ), + ] diff --git a/passbook/flows/models.py b/passbook/flows/models.py index 7fa595ef5..44cda4f35 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -69,10 +69,12 @@ class FlowFactorBinding(PolicyBindingModel, UUIDModel): order = models.IntegerField() def __str__(self) -> str: - return f"Flow Factor Binding {self.flow} -> {self.factor}" + return f"Flow Factor Binding #{self.order} {self.flow} -> {self.factor}" class Meta: + ordering = ["order", "flow"] + verbose_name = _("Flow Factor Binding") verbose_name_plural = _("Flow Factor Bindings") unique_together = (("flow", "factor", "order"),) diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py new file mode 100644 index 000000000..df0f2f73c --- /dev/null +++ b/passbook/flows/planner.py @@ -0,0 +1,66 @@ +"""Flows Planner""" +from dataclasses import dataclass, field +from time import time +from typing import List, Tuple + +from django.http import HttpRequest +from structlog import get_logger + +from passbook.flows.exceptions import FlowNonApplicableError +from passbook.flows.models import Factor, Flow +from passbook.policies.engine import PolicyEngine + +LOGGER = get_logger() + + +@dataclass +class FlowPlan: + """This data-class is the output of a FlowPlanner. It holds a flat list + of all Factors that should be run.""" + + factors: List[Factor] = field(default_factory=list) + + def next(self) -> Factor: + """Return next pending factor from the bottom of the list""" + factor_cls = self.factors.pop(0) + return factor_cls + + +class FlowPlanner: + """Execute all policies to plan out a flat list of all Factors + that should be applied.""" + + flow: Flow + + def __init__(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(self, request: HttpRequest) -> FlowPlan: + """Check each of the flows' policies, check policies for each factor with PolicyBinding + and return ordered list""" + LOGGER.debug("Starting planning process", flow=self.flow) + start_time = time() + plan = FlowPlan() + # 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 FlowNonApplicableError(root_passing_messages) + # Check Flow policies + for factor in self.flow.factors.order_by("order").select_subclasses(): + engine = PolicyEngine(factor.policies.all(), request.user, request) + engine.build() + passing, _ = engine.result + if passing: + LOGGER.debug("Factor passing", factor=factor) + plan.factors.append(factor) + end_time = time() + LOGGER.debug( + "Finished planning", flow=self.flow, duration_s=end_time - start_time + ) + return plan diff --git a/passbook/flows/tests.py b/passbook/flows/tests.py index ec1f444bf..463f7eacb 100644 --- a/passbook/flows/tests.py +++ b/passbook/flows/tests.py @@ -10,7 +10,7 @@ from django.urls import reverse from passbook.core.models import User from passbook.factors.dummy.models import DummyFactor from passbook.factors.password.models import PasswordFactor -from passbook.flows.view import AuthenticationView +from passbook.flows.views import AuthenticationView class TestFactorAuthentication(TestCase): diff --git a/passbook/flows/urls.py b/passbook/flows/urls.py index a4b507bde..d1563f70a 100644 --- a/passbook/flows/urls.py +++ b/passbook/flows/urls.py @@ -1,7 +1,11 @@ """flow urls""" from django.urls import path -from passbook.flows.view import AuthenticationView, FactorPermissionDeniedView +from passbook.flows.views import ( + AuthenticationView, + FactorPermissionDeniedView, + FlowExecutorView, +) urlpatterns = [ path("auth/process/", AuthenticationView.as_view(), name="auth-process"), @@ -15,4 +19,5 @@ urlpatterns = [ FactorPermissionDeniedView.as_view(), name="auth-denied", ), + path("/", FlowExecutorView.as_view(), name="flow-executor"), ] diff --git a/passbook/flows/view.py b/passbook/flows/views.py similarity index 77% rename from passbook/flows/view.py rename to passbook/flows/views.py index d99387408..bc4c7e0da 100644 --- a/passbook/flows/view.py +++ b/passbook/flows/views.py @@ -11,6 +11,9 @@ from structlog import get_logger from passbook.core.models import Factor, User from passbook.core.views.utils import PermissionDeniedView +from passbook.flows.exceptions import FlowNonApplicableError +from passbook.flows.models import Flow +from passbook.flows.planner import FlowPlan, FlowPlanner from passbook.lib.config import CONFIG from passbook.lib.utils.reflection import class_to_path, path_to_class from passbook.lib.utils.urls import is_url_absolute @@ -218,3 +221,66 @@ class AuthenticationView(UserPassesTestMixin, View): class FactorPermissionDeniedView(PermissionDeniedView): """User could not be authenticated""" + + +SESSION_KEY_PLAN = "passbook_flows_plan" + + +class FlowExecutorView(View): + """Stage 1 Flow executor, passing requests to Factor Views""" + + flow: Flow + + plan: FlowPlan + current_factor: Factor + current_factor_view: View + + def setup(self, request: HttpRequest, flow_slug: str): + super().setup(request, flow_slug=flow_slug) + # TODO: Do we always need this? + self.flow = get_object_or_404(Flow, slug=flow_slug) + + def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: + # Early check if theres an active Plan for the current session + if SESSION_KEY_PLAN not in self.request.session: + LOGGER.debug( + "No active Plan found, initiating planner", flow_slug=flow_slug + ) + try: + self.plan = self._initiate_plan() + except FlowNonApplicableError as exc: + LOGGER.warning("Flow not applicable to current user", exc=exc) + return redirect("passbook_core:index") + else: + LOGGER.debug("Continuing existing plan", flow_slug=flow_slug) + self.plan = self.request.session[SESSION_KEY_PLAN] + # We don't save the Plan after getting the next factor + # as it hasn't been successfully passed yet + self.current_factor = self.plan.next() + LOGGER.debug("Current factor", current_factor=self.current_factor) + factor_cls = path_to_class(self.current_factor.type) + self.current_factor_view = factor_cls(self) + # self.current_factor_view.pending_user = self.pending_user + self.current_factor_view.request = request + return super().dispatch(request) + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """pass get request to current factor""" + LOGGER.debug( + "Passing GET", view_class=class_to_path(self.current_factor_view.__class__), + ) + return self.current_factor_view.get(request, *args, **kwargs) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """pass post request to current factor""" + LOGGER.debug( + "Passing POST", + view_class=class_to_path(self.current_factor_view.__class__), + ) + return self.current_factor_view.post(request, *args, **kwargs) + + def _initiate_plan(self) -> FlowPlan: + planner = FlowPlanner(self.flow) + plan = planner.plan(self.request) + self.request.session[SESSION_KEY_PLAN] = plan + return plan diff --git a/passbook/policies/expression/evaluator.py b/passbook/policies/expression/evaluator.py index fb97e8a29..74b6adf60 100644 --- a/passbook/policies/expression/evaluator.py +++ b/passbook/policies/expression/evaluator.py @@ -8,7 +8,7 @@ from jinja2.exceptions import TemplateSyntaxError, UndefinedError from jinja2.nativetypes import NativeEnvironment from structlog import get_logger -from passbook.flows.view import AuthenticationView +from passbook.flows.views import AuthenticationView from passbook.lib.utils.http import get_client_ip from passbook.policies.types import PolicyRequest, PolicyResult diff --git a/passbook/policies/migrations/0002_auto_20200508_1230.py b/passbook/policies/migrations/0002_auto_20200508_1230.py new file mode 100644 index 000000000..a575fc02e --- /dev/null +++ b/passbook/policies/migrations/0002_auto_20200508_1230.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.3 on 2020-05-08 12:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_policies", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="policybindingmodel", + options={ + "verbose_name": "Policy Binding Model", + "verbose_name_plural": "Policy Binding Models", + }, + ), + ] diff --git a/passbook/policies/models.py b/passbook/policies/models.py index 1fae3ab4b..42f85f74c 100644 --- a/passbook/policies/models.py +++ b/passbook/policies/models.py @@ -11,6 +11,11 @@ class PolicyBindingModel(models.Model): policies = models.ManyToManyField(Policy, through="PolicyBinding", related_name="+") + class Meta: + + verbose_name = _("Policy Binding Model") + verbose_name_plural = _("Policy Binding Models") + class PolicyBinding(UUIDModel): """Relationship between a Policy and a PolicyBindingModel.""" @@ -25,6 +30,9 @@ class PolicyBinding(UUIDModel): # default value and non-unique for compatibility order = models.IntegerField(default=0) + def __str__(self) -> str: + return f"PolicyBinding policy={self.policy} target={self.target} order={self.order}" + class Meta: verbose_name = _("Policy Binding") diff --git a/passbook/providers/saml/models.py b/passbook/providers/saml/models.py index e55f3719c..1cae3fc84 100644 --- a/passbook/providers/saml/models.py +++ b/passbook/providers/saml/models.py @@ -164,5 +164,5 @@ class SAMLPropertyMapping(PropertyMapping): def get_provider_choices(): """Return tuple of class_path, class name of all providers.""" return [ - (class_to_path(x), x.__name__) for x in Processor.__dict__["__subclasses__"]() + (class_to_path(x), x.__name__) for x in getattr(Processor, "__subclasses__")() ] diff --git a/passbook/sources/oauth/views/core.py b/passbook/sources/oauth/views/core.py index f5d822dae..5d9377e38 100644 --- a/passbook/sources/oauth/views/core.py +++ b/passbook/sources/oauth/views/core.py @@ -13,7 +13,7 @@ from django.views.generic import RedirectView, View from structlog import get_logger from passbook.audit.models import Event, EventAction -from passbook.flows.view import AuthenticationView, _redirect_with_qs +from passbook.flows.views import AuthenticationView, _redirect_with_qs from passbook.sources.oauth.clients import get_client from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection