![gcp-cherry-pick-bot[bot]](/assets/img/avatar_default.png)
enterprise/stages/source: fix dispatch method signature (#13321) Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Jens L. <jens@goauthentik.io>
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""Flows Planner"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from django.core.cache import cache
|
|
from django.http import HttpRequest, HttpResponse
|
|
from sentry_sdk import start_span
|
|
from sentry_sdk.tracing import Span
|
|
from structlog.stdlib import BoundLogger, get_logger
|
|
|
|
from authentik.core.models import User
|
|
from authentik.events.models import cleanse_dict
|
|
from authentik.flows.apps import HIST_FLOWS_PLAN_TIME
|
|
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
|
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
|
from authentik.flows.models import (
|
|
Flow,
|
|
FlowAuthenticationRequirement,
|
|
FlowDesignation,
|
|
FlowStageBinding,
|
|
Stage,
|
|
in_memory_stage,
|
|
)
|
|
from authentik.lib.config import CONFIG
|
|
from authentik.lib.utils.urls import redirect_with_qs
|
|
from authentik.outposts.models import Outpost
|
|
from authentik.policies.engine import PolicyEngine
|
|
from authentik.root.middleware import ClientIPMiddleware
|
|
|
|
if TYPE_CHECKING:
|
|
from authentik.flows.stage import StageView
|
|
|
|
|
|
LOGGER = get_logger()
|
|
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
|
PLAN_CONTEXT_SSO = "is_sso"
|
|
PLAN_CONTEXT_REDIRECT = "redirect"
|
|
PLAN_CONTEXT_APPLICATION = "application"
|
|
PLAN_CONTEXT_SOURCE = "source"
|
|
PLAN_CONTEXT_OUTPOST = "outpost"
|
|
# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
|
|
# was restored.
|
|
PLAN_CONTEXT_IS_RESTORED = "is_restored"
|
|
PLAN_CONTEXT_IS_REDIRECTED = "is_redirected"
|
|
PLAN_CONTEXT_REDIRECT_STAGE_TARGET = "redirect_stage_target"
|
|
CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_flows")
|
|
CACHE_PREFIX = "goauthentik.io/flows/planner/"
|
|
|
|
|
|
def cache_key(flow: Flow, user: User | None = None) -> str:
|
|
"""Generate Cache key for flow"""
|
|
prefix = CACHE_PREFIX + str(flow.pk)
|
|
if user:
|
|
prefix += f"#{user.pk}"
|
|
return prefix
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class FlowPlan:
|
|
"""This data-class is the output of a FlowPlanner. It holds a flat list
|
|
of all Stages that should be run."""
|
|
|
|
flow_pk: str
|
|
|
|
bindings: list[FlowStageBinding] = field(default_factory=list)
|
|
context: dict[str, Any] = field(default_factory=dict)
|
|
markers: list[StageMarker] = field(default_factory=list)
|
|
|
|
def append_stage(self, stage: Stage, marker: StageMarker | None = None):
|
|
"""Append `stage` to the end of the plan, optionally with stage marker"""
|
|
return self.append(FlowStageBinding(stage=stage), marker)
|
|
|
|
def append(self, binding: FlowStageBinding, marker: StageMarker | None = None):
|
|
"""Append `stage` to the end of the plan, optionally with stage marker"""
|
|
self.bindings.append(binding)
|
|
self.markers.append(marker or StageMarker())
|
|
|
|
def insert_stage(self, stage: Stage, marker: StageMarker | None = None, index=1):
|
|
"""Insert stage into plan, as immediate next stage"""
|
|
self.bindings.insert(index, FlowStageBinding(stage=stage, order=0))
|
|
self.markers.insert(index, marker or StageMarker())
|
|
|
|
def redirect(self, destination: str):
|
|
"""Insert a redirect stage as next stage"""
|
|
from authentik.flows.stage import RedirectStage
|
|
|
|
self.insert_stage(in_memory_stage(RedirectStage, destination=destination))
|
|
|
|
def next(self, http_request: HttpRequest | None) -> FlowStageBinding | None:
|
|
"""Return next pending stage from the bottom of the list"""
|
|
if not self.has_stages:
|
|
return None
|
|
binding = self.bindings[0]
|
|
marker = self.markers[0]
|
|
|
|
if marker.__class__ is not StageMarker:
|
|
LOGGER.debug("f(plan_inst): stage has marker", binding=binding, marker=marker)
|
|
marked_stage = marker.process(self, binding, http_request)
|
|
if not marked_stage:
|
|
LOGGER.debug("f(plan_inst): marker returned none, next stage", binding=binding)
|
|
self.bindings.remove(binding)
|
|
self.markers.remove(marker)
|
|
if not self.has_stages:
|
|
return None
|
|
|
|
return self.next(http_request)
|
|
return marked_stage
|
|
|
|
def pop(self):
|
|
"""Pop next pending stage from bottom of list"""
|
|
if not self.markers and not self.bindings:
|
|
return
|
|
self.markers.pop(0)
|
|
self.bindings.pop(0)
|
|
|
|
@property
|
|
def has_stages(self) -> bool:
|
|
"""Check if there are any stages left in this plan"""
|
|
return len(self.markers) + len(self.bindings) > 0
|
|
|
|
def requires_flow_executor(
|
|
self,
|
|
allowed_silent_types: list["StageView"] | None = None,
|
|
):
|
|
# Check if we actually need to show the Flow executor, or if we can jump straight to the end
|
|
found_unskippable = True
|
|
if allowed_silent_types:
|
|
LOGGER.debug("Checking if we can skip the flow executor...")
|
|
# Policies applied to the flow have already been evaluated, so we're checking for stages
|
|
# allow-listed or bindings that require a policy re-eval
|
|
found_unskippable = False
|
|
for binding, marker in zip(self.bindings, self.markers, strict=True):
|
|
if binding.stage.view not in allowed_silent_types:
|
|
found_unskippable = True
|
|
if marker and isinstance(marker, ReevaluateMarker):
|
|
found_unskippable = True
|
|
LOGGER.debug("Required flow executor status", status=found_unskippable)
|
|
return found_unskippable
|
|
|
|
def to_redirect(
|
|
self,
|
|
request: HttpRequest,
|
|
flow: Flow,
|
|
allowed_silent_types: list["StageView"] | None = None,
|
|
) -> HttpResponse:
|
|
"""Redirect to the flow executor for this flow plan"""
|
|
from authentik.flows.views.executor import (
|
|
SESSION_KEY_PLAN,
|
|
FlowExecutorView,
|
|
)
|
|
|
|
request.session[SESSION_KEY_PLAN] = self
|
|
requires_flow_executor = self.requires_flow_executor(allowed_silent_types)
|
|
|
|
if not requires_flow_executor:
|
|
# No unskippable stages found, so we can directly return the response of the last stage
|
|
final_stage: type[StageView] = self.bindings[-1].stage.view
|
|
temp_exec = FlowExecutorView(flow=flow, request=request, plan=self)
|
|
temp_exec.current_stage = self.bindings[-1].stage
|
|
temp_exec.current_stage_view = final_stage
|
|
temp_exec.setup(request, flow.slug)
|
|
stage = final_stage(request=request, executor=temp_exec)
|
|
response = stage.dispatch(request)
|
|
# Ensure we clean the flow state we have in the session before we redirect away
|
|
temp_exec.stage_ok()
|
|
return response
|
|
|
|
get_qs = request.GET.copy()
|
|
if request.user.is_authenticated and (
|
|
# Object-scoped permission or global permission
|
|
request.user.has_perm("authentik_flows.inspect_flow", flow)
|
|
or request.user.has_perm("authentik_flows.inspect_flow")
|
|
):
|
|
get_qs["inspector"] = "available"
|
|
|
|
return redirect_with_qs(
|
|
"authentik_core:if-flow",
|
|
get_qs,
|
|
flow_slug=flow.slug,
|
|
)
|
|
|
|
|
|
class FlowPlanner:
|
|
"""Execute all policies to plan out a flat list of all Stages
|
|
that should be applied."""
|
|
|
|
use_cache: bool
|
|
allow_empty_flows: bool
|
|
|
|
flow: Flow
|
|
|
|
_logger: BoundLogger
|
|
|
|
def __init__(self, flow: Flow):
|
|
self.use_cache = True
|
|
self.allow_empty_flows = False
|
|
self.flow = flow
|
|
self._logger = get_logger().bind(flow_slug=flow.slug)
|
|
|
|
def _check_authentication(self, request: HttpRequest, context: dict[str, Any]):
|
|
"""Check the flow's authentication level is matched by `request`"""
|
|
if (
|
|
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED
|
|
and not request.user.is_authenticated
|
|
):
|
|
raise FlowNonApplicableException()
|
|
if (
|
|
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED
|
|
and request.user.is_authenticated
|
|
):
|
|
raise FlowNonApplicableException()
|
|
if (
|
|
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_SUPERUSER
|
|
and not request.user.is_superuser
|
|
):
|
|
raise FlowNonApplicableException()
|
|
if (
|
|
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_REDIRECT
|
|
and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None
|
|
):
|
|
raise FlowNonApplicableException()
|
|
outpost_user = ClientIPMiddleware.get_outpost_user(request)
|
|
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
|
|
if not outpost_user:
|
|
raise FlowNonApplicableException()
|
|
if outpost_user:
|
|
outpost = Outpost.objects.filter(
|
|
# TODO: Since Outpost and user are not directly connected, we have to look up a user
|
|
# like this. This should ideally by in authentik/outposts/models.py
|
|
pk=outpost_user.username.replace("ak-outpost-", "")
|
|
).first()
|
|
if outpost:
|
|
return {
|
|
PLAN_CONTEXT_OUTPOST: {
|
|
"instance": outpost,
|
|
}
|
|
}
|
|
return {}
|
|
|
|
def plan(self, request: HttpRequest, default_context: dict[str, Any] | None = None) -> FlowPlan:
|
|
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
|
|
and return ordered list"""
|
|
with start_span(op="authentik.flow.planner.plan", name=self.flow.slug) as span:
|
|
span: Span
|
|
span.set_data("flow", self.flow)
|
|
span.set_data("request", request)
|
|
|
|
self._logger.debug(
|
|
"f(plan): starting planning process",
|
|
)
|
|
context = default_context or {}
|
|
# 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 context and PLAN_CONTEXT_PENDING_USER in context:
|
|
user = context[PLAN_CONTEXT_PENDING_USER]
|
|
else:
|
|
user = request.user
|
|
|
|
context.update(self._check_authentication(request, context))
|
|
# 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)
|
|
engine.use_cache = self.use_cache
|
|
span.set_data("context", cleanse_dict(context))
|
|
engine.request.context.update(context)
|
|
engine.build()
|
|
result = engine.result
|
|
if not result.passing:
|
|
exc = FlowNonApplicableException()
|
|
exc.policy_result = result
|
|
raise exc
|
|
# 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 self.flow.designation not in [FlowDesignation.STAGE_CONFIGURATION]:
|
|
if cached_plan and self.use_cache:
|
|
self._logger.debug(
|
|
"f(plan): taking plan from cache",
|
|
key=cached_plan_key,
|
|
)
|
|
# Reset the context as this isn't factored into caching
|
|
cached_plan.context = context
|
|
return cached_plan
|
|
self._logger.debug(
|
|
"f(plan): building plan",
|
|
)
|
|
plan = self._build_plan(user, request, context)
|
|
if self.use_cache:
|
|
cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
|
|
if not plan.bindings and not self.allow_empty_flows:
|
|
raise EmptyFlowException()
|
|
return plan
|
|
|
|
def _build_plan(
|
|
self,
|
|
user: User,
|
|
request: HttpRequest,
|
|
default_context: dict[str, Any] | None,
|
|
) -> FlowPlan:
|
|
"""Build flow plan by checking each stage in their respective
|
|
order and checking the applied policies"""
|
|
with (
|
|
start_span(
|
|
op="authentik.flow.planner.build_plan",
|
|
name=self.flow.slug,
|
|
) as span,
|
|
HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time(),
|
|
):
|
|
span: Span
|
|
span.set_data("flow", self.flow)
|
|
span.set_data("user", user)
|
|
span.set_data("request", request)
|
|
|
|
plan = FlowPlan(flow_pk=self.flow.pk.hex)
|
|
if default_context:
|
|
plan.context = default_context
|
|
# Check Flow policies
|
|
bindings = list(
|
|
FlowStageBinding.objects.filter(target__pk=self.flow.pk).order_by("order")
|
|
)
|
|
stages = Stage.objects.filter(flowstagebinding__in=[binding.pk for binding in bindings])
|
|
for binding in bindings:
|
|
binding: FlowStageBinding
|
|
stage = [stage for stage in stages if stage.pk == binding.stage_id][0]
|
|
marker = StageMarker()
|
|
if binding.evaluate_on_plan:
|
|
self._logger.debug(
|
|
"f(plan): evaluating on plan",
|
|
stage=stage,
|
|
)
|
|
engine = PolicyEngine(binding, user, request)
|
|
engine.use_cache = self.use_cache
|
|
engine.request.context["flow_plan"] = plan
|
|
engine.request.context.update(plan.context)
|
|
engine.build()
|
|
if engine.passing:
|
|
self._logger.debug(
|
|
"f(plan): stage passing",
|
|
stage=stage,
|
|
)
|
|
else:
|
|
stage = None
|
|
else:
|
|
self._logger.debug(
|
|
"f(plan): not evaluating on plan",
|
|
stage=stage,
|
|
)
|
|
if binding.re_evaluate_policies and stage:
|
|
self._logger.debug(
|
|
"f(plan): stage has re-evaluate marker",
|
|
stage=stage,
|
|
)
|
|
marker = ReevaluateMarker(binding=binding)
|
|
if stage:
|
|
plan.append(binding, marker)
|
|
self._logger.debug(
|
|
"f(plan): finished building",
|
|
)
|
|
return plan
|