2020-05-08 13:33:14 +01:00
|
|
|
"""Flows Planner"""
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
from time import time
|
2020-05-28 20:45:54 +01:00
|
|
|
from typing import Any, Dict, List, Optional
|
2020-05-08 13:33:14 +01:00
|
|
|
|
2020-05-11 10:39:58 +01:00
|
|
|
from django.core.cache import cache
|
2020-05-08 13:33:14 +01:00
|
|
|
from django.http import HttpRequest
|
2020-07-07 16:43:10 +01:00
|
|
|
from elasticapm import capture_span
|
2020-05-08 13:33:14 +01:00
|
|
|
from structlog import get_logger
|
|
|
|
|
2020-05-11 10:39:58 +01:00
|
|
|
from passbook.core.models import User
|
2020-05-09 19:54:56 +01:00
|
|
|
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
2020-06-18 21:43:51 +01:00
|
|
|
from passbook.flows.markers import ReevaluateMarker, StageMarker
|
|
|
|
from passbook.flows.models import Flow, FlowStageBinding, Stage
|
2020-05-08 13:33:14 +01:00
|
|
|
from passbook.policies.engine import PolicyEngine
|
|
|
|
|
|
|
|
LOGGER = get_logger()
|
|
|
|
|
2020-05-08 15:10:27 +01:00
|
|
|
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
|
|
|
PLAN_CONTEXT_SSO = "is_sso"
|
2020-06-07 15:35:08 +01:00
|
|
|
PLAN_CONTEXT_APPLICATION = "application"
|
2020-05-08 15:10:27 +01:00
|
|
|
|
2020-05-08 13:33:14 +01:00
|
|
|
|
2020-05-11 10:39:58 +01:00
|
|
|
def cache_key(flow: Flow, user: Optional[User] = None) -> str:
|
|
|
|
"""Generate Cache key for flow"""
|
|
|
|
prefix = f"flow_{flow.pk}"
|
|
|
|
if user:
|
|
|
|
prefix += f"#{user.pk}"
|
|
|
|
return prefix
|
|
|
|
|
|
|
|
|
2020-05-08 13:33:14 +01:00
|
|
|
@dataclass
|
|
|
|
class FlowPlan:
|
|
|
|
"""This data-class is the output of a FlowPlanner. It holds a flat list
|
2020-05-08 18:46:39 +01:00
|
|
|
of all Stages that should be run."""
|
2020-05-08 13:33:14 +01:00
|
|
|
|
2020-05-10 19:15:24 +01:00
|
|
|
flow_pk: str
|
2020-06-18 21:43:51 +01:00
|
|
|
|
2020-05-08 18:46:39 +01:00
|
|
|
stages: List[Stage] = field(default_factory=list)
|
2020-05-08 15:10:27 +01:00
|
|
|
context: Dict[str, Any] = field(default_factory=dict)
|
2020-06-18 21:43:51 +01:00
|
|
|
markers: List[StageMarker] = field(default_factory=list)
|
2020-05-08 13:33:14 +01:00
|
|
|
|
2020-06-19 18:34:27 +01:00
|
|
|
def append(self, stage: Stage, marker: Optional[StageMarker] = None):
|
|
|
|
"""Append `stage` to all stages, optionall with stage marker"""
|
|
|
|
self.stages.append(stage)
|
|
|
|
self.markers.append(marker or StageMarker())
|
|
|
|
|
2020-06-18 21:43:51 +01:00
|
|
|
def next(self) -> Optional[Stage]:
|
2020-05-08 18:46:39 +01:00
|
|
|
"""Return next pending stage from the bottom of the list"""
|
2020-06-18 21:43:51 +01:00
|
|
|
if not self.has_stages:
|
|
|
|
return None
|
|
|
|
stage = self.stages[0]
|
|
|
|
marker = self.markers[0]
|
|
|
|
|
|
|
|
LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker)
|
|
|
|
marked_stage = marker.process(self, stage)
|
|
|
|
if not marked_stage:
|
|
|
|
LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage)
|
|
|
|
self.stages.remove(stage)
|
|
|
|
self.markers.remove(marker)
|
|
|
|
if not self.has_stages:
|
|
|
|
return None
|
2020-06-19 19:33:35 +01:00
|
|
|
# pylint: disable=not-callable
|
2020-06-18 21:43:51 +01:00
|
|
|
return self.next()
|
|
|
|
return marked_stage
|
|
|
|
|
|
|
|
def pop(self):
|
|
|
|
"""Pop next pending stage from bottom of list"""
|
|
|
|
self.markers.pop(0)
|
|
|
|
self.stages.pop(0)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def has_stages(self) -> bool:
|
|
|
|
"""Check if there are any stages left in this plan"""
|
|
|
|
return len(self.markers) + len(self.stages) > 0
|
2020-05-08 13:33:14 +01:00
|
|
|
|
|
|
|
|
|
|
|
class FlowPlanner:
|
2020-05-08 18:46:39 +01:00
|
|
|
"""Execute all policies to plan out a flat list of all Stages
|
2020-05-08 13:33:14 +01:00
|
|
|
that should be applied."""
|
|
|
|
|
2020-05-11 10:39:58 +01:00
|
|
|
use_cache: bool
|
2020-06-07 15:35:08 +01:00
|
|
|
allow_empty_flows: bool
|
|
|
|
|
2020-05-08 13:33:14 +01:00
|
|
|
flow: Flow
|
|
|
|
|
|
|
|
def __init__(self, flow: Flow):
|
2020-05-11 10:39:58 +01:00
|
|
|
self.use_cache = True
|
2020-06-07 15:35:08 +01:00
|
|
|
self.allow_empty_flows = False
|
2020-05-08 13:33:14 +01:00
|
|
|
self.flow = flow
|
|
|
|
|
2020-07-07 16:43:10 +01:00
|
|
|
@capture_span(name="FlowPlanner", span_type="flow.planner.plan")
|
2020-05-20 15:15:16 +01:00
|
|
|
def plan(
|
|
|
|
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
|
|
|
|
) -> FlowPlan:
|
2020-05-08 18:46:39 +01:00
|
|
|
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
|
2020-05-08 13:33:14 +01:00
|
|
|
and return ordered list"""
|
2020-05-10 19:15:24 +01:00
|
|
|
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
|
2020-05-20 15:15:16 +01:00
|
|
|
# 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
|
|
|
|
if default_context and PLAN_CONTEXT_PENDING_USER in default_context:
|
|
|
|
user = default_context[PLAN_CONTEXT_PENDING_USER]
|
|
|
|
else:
|
|
|
|
user = request.user
|
2020-05-23 19:23:09 +01:00
|
|
|
# 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
|
2020-05-20 15:15:16 +01:00
|
|
|
cached_plan_key = cache_key(self.flow, user)
|
|
|
|
cached_plan = cache.get(cached_plan_key, None)
|
2020-05-11 10:39:58 +01:00
|
|
|
if cached_plan and self.use_cache:
|
2020-05-20 15:15:16 +01:00
|
|
|
LOGGER.debug(
|
|
|
|
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
|
|
|
|
)
|
2020-06-07 15:35:08 +01:00
|
|
|
# Reset the context as this isn't factored into caching
|
|
|
|
cached_plan.context = default_context or {}
|
2020-05-11 10:39:58 +01:00
|
|
|
return cached_plan
|
2020-05-23 19:23:09 +01:00
|
|
|
LOGGER.debug("f(plan): building plan", flow=self.flow)
|
2020-05-20 15:15:16 +01:00
|
|
|
plan = self._build_plan(user, request, default_context)
|
|
|
|
cache.set(cache_key(self.flow, user), plan)
|
2020-06-07 15:35:08 +01:00
|
|
|
if not plan.stages and not self.allow_empty_flows:
|
2020-05-20 15:15:16 +01:00
|
|
|
raise EmptyFlowException()
|
|
|
|
return plan
|
|
|
|
|
2020-07-07 16:43:10 +01:00
|
|
|
@capture_span(name="FlowPlanner", span_type="flow.planner.build_plan")
|
2020-05-20 15:15:16 +01:00
|
|
|
def _build_plan(
|
|
|
|
self,
|
|
|
|
user: User,
|
|
|
|
request: HttpRequest,
|
|
|
|
default_context: Optional[Dict[str, Any]],
|
|
|
|
) -> FlowPlan:
|
2020-06-18 21:43:51 +01:00
|
|
|
"""Build flow plan by checking each stage in their respective
|
|
|
|
order and checking the applied policies"""
|
2020-05-11 10:39:58 +01:00
|
|
|
start_time = time()
|
|
|
|
plan = FlowPlan(flow_pk=self.flow.pk.hex)
|
2020-05-20 15:15:16 +01:00
|
|
|
if default_context:
|
|
|
|
plan.context = default_context
|
2020-05-08 13:33:14 +01:00
|
|
|
# Check Flow policies
|
2020-05-08 18:46:39 +01:00
|
|
|
for stage in (
|
|
|
|
self.flow.stages.order_by("flowstagebinding__order")
|
|
|
|
.select_subclasses()
|
|
|
|
.select_related()
|
|
|
|
):
|
2020-06-18 21:43:51 +01:00
|
|
|
binding: FlowStageBinding = stage.flowstagebinding_set.get(
|
2020-07-03 22:34:44 +01:00
|
|
|
target__pk=self.flow.pk
|
2020-06-18 21:43:51 +01:00
|
|
|
)
|
2020-05-28 20:45:54 +01:00
|
|
|
engine = PolicyEngine(binding, user, request)
|
2020-05-20 15:15:16 +01:00
|
|
|
engine.request.context = plan.context
|
2020-05-08 13:33:14 +01:00
|
|
|
engine.build()
|
2020-05-28 20:45:54 +01:00
|
|
|
if engine.passing:
|
2020-05-11 10:39:58 +01:00
|
|
|
LOGGER.debug("f(plan): Stage passing", stage=stage, flow=self.flow)
|
2020-05-08 18:46:39 +01:00
|
|
|
plan.stages.append(stage)
|
2020-06-18 21:43:51 +01:00
|
|
|
marker = StageMarker()
|
|
|
|
if binding.re_evaluate_policies:
|
|
|
|
LOGGER.debug(
|
|
|
|
"f(plan): Stage has re-evaluate marker",
|
|
|
|
stage=stage,
|
|
|
|
flow=self.flow,
|
|
|
|
)
|
|
|
|
marker = ReevaluateMarker(binding=binding, user=user)
|
|
|
|
plan.markers.append(marker)
|
2020-05-08 13:33:14 +01:00
|
|
|
end_time = time()
|
|
|
|
LOGGER.debug(
|
2020-05-20 15:15:16 +01:00
|
|
|
"f(plan): Finished building",
|
2020-05-10 19:15:24 +01:00
|
|
|
flow=self.flow,
|
|
|
|
duration_s=end_time - start_time,
|
2020-05-08 13:33:14 +01:00
|
|
|
)
|
|
|
|
return plan
|