This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
authentik/authentik/policies/engine.py

176 lines
7.0 KiB
Python

"""authentik policy engine"""
from multiprocessing import Pipe, current_process
from multiprocessing.connection import Connection
from timeit import default_timer
from typing import Iterator, Optional
from django.core.cache import cache
from django.http import HttpRequest
from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import User
from authentik.lib.utils.reflection import class_to_path
from authentik.policies.apps import HIST_POLICIES_ENGINE_TOTAL_TIME, HIST_POLICIES_EXECUTION_TIME
from authentik.policies.exceptions import PolicyEngineException
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode
from authentik.policies.process import PolicyProcess, cache_key
from authentik.policies.types import PolicyRequest, PolicyResult
CURRENT_PROCESS = current_process()
class PolicyProcessInfo:
"""Dataclass to hold all information and communication channels to a process"""
process: PolicyProcess
connection: Connection
result: Optional[PolicyResult]
binding: PolicyBinding
def __init__(self, process: PolicyProcess, connection: Connection, binding: PolicyBinding):
self.process = process
self.connection = connection
self.binding = binding
self.result = None
class PolicyEngine:
"""Orchestrate policy checking, launch tasks and return result"""
use_cache: bool
request: PolicyRequest
logger: BoundLogger
mode: PolicyEngineMode
# Allow objects with no policies attached to pass
empty_result: bool
def __init__(self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None):
self.logger = get_logger().bind()
self.mode = pbm.policy_engine_mode
# For backwards compatibility, set empty_result to true
# objects with no policies attached will pass.
self.empty_result = True
if not isinstance(pbm, PolicyBindingModel): # pragma: no cover
raise PolicyEngineException(f"{pbm} is not instance of PolicyBindingModel")
if not user:
raise PolicyEngineException("User must be set")
self.__pbm = pbm
self.request = PolicyRequest(user)
self.request.obj = pbm
if request:
self.request.set_http_request(request)
self.__cached_policies: list[PolicyResult] = []
self.__processes: list[PolicyProcessInfo] = []
self.use_cache = True
self.__expected_result_count = 0
def iterate_bindings(self) -> Iterator[PolicyBinding]:
"""Make sure all Policies are their respective classes"""
return (
PolicyBinding.objects.filter(target=self.__pbm, enabled=True)
.order_by("order")
.iterator()
)
def _check_policy_type(self, binding: PolicyBinding):
"""Check policy type, make sure it's not the root class as that has no logic implemented"""
if binding.policy is not None and binding.policy.__class__ == Policy:
raise PolicyEngineException(f"Policy '{binding.policy}' is root type")
def _check_cache(self, binding: PolicyBinding):
if not self.use_cache:
return False
before = default_timer()
key = cache_key(binding, self.request)
cached_policy = cache.get(key, None)
duration = max(default_timer() - before, 0)
if not cached_policy:
return False
self.logger.debug(
"P_ENG: Taking result from cache",
binding=binding,
cache_key=key,
request=self.request,
)
HIST_POLICIES_EXECUTION_TIME.labels(
binding_order=binding.order,
binding_target_type=binding.target_type,
binding_target_name=binding.target_name,
object_pk=str(self.request.obj.pk),
object_type=class_to_path(self.request.obj.__class__),
mode="cache_retrieve",
).observe(duration)
# It's a bit silly to time this, but
self.__cached_policies.append(cached_policy)
return True
def build(self) -> "PolicyEngine":
"""Build wrapper which monitors performance"""
with (
Hub.current.start_span(
op="authentik.policy.engine.build",
description=self.__pbm,
) as span,
HIST_POLICIES_ENGINE_TOTAL_TIME.labels(
obj_type=class_to_path(self.__pbm.__class__),
obj_pk=str(self.__pbm.pk),
).time(),
):
span: Span
span.set_data("pbm", self.__pbm)
span.set_data("request", self.request)
for binding in self.iterate_bindings():
self.__expected_result_count += 1
self._check_policy_type(binding)
if self._check_cache(binding):
continue
self.logger.debug("P_ENG: Evaluating policy", binding=binding, request=self.request)
our_end, task_end = Pipe(False)
task = PolicyProcess(binding, self.request, task_end)
task.daemon = False
self.logger.debug("P_ENG: Starting Process", binding=binding, request=self.request)
if not CURRENT_PROCESS._config.get("daemon"):
task.run()
else:
task.start()
self.__processes.append(
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:
if proc_info.process.is_alive():
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) -> PolicyResult:
"""Get policy-checking result"""
process_results: list[PolicyResult] = [x.result for x in self.__processes if x.result]
all_results = list(process_results + self.__cached_policies)
if len(all_results) < self.__expected_result_count: # pragma: no cover
raise AssertionError("Got less results than polices")
# No results, no policies attached -> passing
if len(all_results) == 0:
return PolicyResult(self.empty_result)
passing = False
if self.mode == PolicyEngineMode.MODE_ALL:
passing = all(x.passing for x in all_results)
if self.mode == PolicyEngineMode.MODE_ANY:
passing = any(x.passing for x in all_results)
result = PolicyResult(passing)
result.source_results = all_results
result.messages = tuple(y for x in all_results for y in x.messages)
return result
@property
def passing(self) -> bool:
"""Only get true/false if user passes"""
return self.result.passing