diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py index 0a5cace89..1f99fff74 100644 --- a/authentik/core/api/propertymappings.py +++ b/authentik/core/api/propertymappings.py @@ -17,7 +17,7 @@ from authentik.api.decorators import permission_required from authentik.blueprints.api import ManagedSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer -from authentik.core.expression import PropertyMappingEvaluator +from authentik.core.expression.evaluator import PropertyMappingEvaluator from authentik.core.models import PropertyMapping from authentik.lib.utils.reflection import all_subclasses from authentik.policies.api.exec import PolicyTestSerializer @@ -41,7 +41,9 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri def validate_expression(self, expression: str) -> str: """Test Syntax""" - evaluator = PropertyMappingEvaluator() + evaluator = PropertyMappingEvaluator( + self.instance, + ) evaluator.validate(expression) return expression diff --git a/authentik/core/expression/__init__.py b/authentik/core/expression/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/core/expression.py b/authentik/core/expression/evaluator.py similarity index 73% rename from authentik/core/expression.py rename to authentik/core/expression/evaluator.py index 13d196136..3d1ad6b01 100644 --- a/authentik/core/expression.py +++ b/authentik/core/expression/evaluator.py @@ -2,28 +2,33 @@ from traceback import format_tb from typing import Optional +from django.db.models import Model from django.http import HttpRequest from guardian.utils import get_anonymous_user -from authentik.core.models import PropertyMapping, User +from authentik.core.models import User from authentik.events.models import Event, EventAction from authentik.lib.expression.evaluator import BaseEvaluator from authentik.policies.types import PolicyRequest class PropertyMappingEvaluator(BaseEvaluator): - """Custom Evalautor that adds some different context variables.""" + """Custom Evaluator that adds some different context variables.""" - def set_context( + def __init__( self, - user: Optional[User], - request: Optional[HttpRequest], - mapping: PropertyMapping, + model: Model, + user: Optional[User] = None, + request: Optional[HttpRequest] = None, **kwargs, ): - """Update context with context from PropertyMapping's evaluate""" + if hasattr(model, "name"): + _filename = model.name + else: + _filename = str(model) + super().__init__(filename=_filename) req = PolicyRequest(user=get_anonymous_user()) - req.obj = mapping + req.obj = model if user: req.user = user self._context["user"] = user diff --git a/authentik/core/models.py b/authentik/core/models.py index b2a178b76..fef304082 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -617,10 +617,9 @@ class PropertyMapping(SerializerModel, ManagedModel): def evaluate(self, user: Optional[User], request: Optional[HttpRequest], **kwargs) -> Any: """Evaluate `self.expression` using `**kwargs` as Context.""" - from authentik.core.expression import PropertyMappingEvaluator + from authentik.core.expression.evaluator import PropertyMappingEvaluator - evaluator = PropertyMappingEvaluator() - evaluator.set_context(user, request, self, **kwargs) + evaluator = PropertyMappingEvaluator(self, user, request, **kwargs) try: return evaluator.evaluate(self.expression) except Exception as exc: diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py index aedc68aa5..6eb65a2a7 100644 --- a/authentik/lib/expression/evaluator.py +++ b/authentik/lib/expression/evaluator.py @@ -1,16 +1,20 @@ """authentik expression policy evaluator""" import re +from ipaddress import ip_address, ip_network from textwrap import indent from typing import Any, Iterable, Optional from django.core.exceptions import FieldError +from django_otp import devices_for_user from rest_framework.serializers import ValidationError from sentry_sdk.hub import Hub from sentry_sdk.tracing import Span from structlog.stdlib import get_logger from authentik.core.models import User +from authentik.events.models import Event from authentik.lib.utils.http import get_http_session +from authentik.policies.types import PolicyRequest LOGGER = get_logger() @@ -26,7 +30,8 @@ class BaseEvaluator: # Filename used for exec _filename: str - def __init__(self): + def __init__(self, filename: Optional[str] = None): + self._filename = filename if filename else "BaseEvaluator" # update website/docs/expressions/_objects.md # update website/docs/expressions/_functions.md self._globals = { @@ -35,11 +40,14 @@ class BaseEvaluator: "list_flatten": BaseEvaluator.expr_flatten, "ak_is_group_member": BaseEvaluator.expr_is_group_member, "ak_user_by": BaseEvaluator.expr_user_by, - "ak_logger": get_logger(), + "ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator, + "ak_create_event": self.expr_event_create, + "ak_logger": get_logger(self._filename), "requests": get_http_session(), + "ip_address": ip_address, + "ip_network": ip_network, } self._context = {} - self._filename = "BaseEvalautor" @staticmethod def expr_flatten(value: list[Any] | Any) -> Optional[Any]: @@ -60,6 +68,11 @@ class BaseEvaluator: """Expression Filter to run re.sub""" return re.sub(regex, repl, value) + @staticmethod + def expr_is_group_member(user: User, **group_filters) -> bool: + """Check if `user` is member of group with name `group_name`""" + return user.ak_groups.filter(**group_filters).exists() + @staticmethod def expr_user_by(**filters) -> Optional[User]: """Get user by filters""" @@ -72,15 +85,37 @@ class BaseEvaluator: return None @staticmethod - def expr_is_group_member(user: User, **group_filters) -> bool: - """Check if `user` is member of group with name `group_name`""" - return user.ak_groups.filter(**group_filters).exists() + def expr_func_user_has_authenticator(user: User, device_type: Optional[str] = None) -> bool: + """Check if a user has any authenticator devices, optionally matching *device_type*""" + user_devices = devices_for_user(user) + if device_type: + for device in user_devices: + device_class = device.__class__.__name__.lower().replace("device", "") + if device_class == device_type: + return True + return False + return len(list(user_devices)) > 0 + + def expr_event_create(self, action: str, **kwargs): + """Create event with supplied data and try to extract as much relevant data + from the context""" + kwargs["context"] = self._context + event = Event.new( + action, + app=self._filename, + **kwargs, + ) + if "request" in self._context and isinstance(PolicyRequest, self._context["request"]): + policy_request: PolicyRequest = self._context["request"] + if policy_request.http_request: + event.from_http(policy_request) + return + event.save() def wrap_expression(self, expression: str, params: Iterable[str]) -> str: """Wrap expression in a function, call it, and save the result as `result`""" handler_signature = ",".join(params) full_expression = "" - full_expression += "from ipaddress import ip_address, ip_network\n" full_expression += f"def handler({handler_signature}):\n" full_expression += indent(expression, " ") full_expression += f"\nresult = handler({handler_signature})" diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py index 04df20d55..bb04d1f15 100644 --- a/authentik/policies/expression/evaluator.py +++ b/authentik/policies/expression/evaluator.py @@ -1,12 +1,10 @@ """authentik expression policy evaluator""" -from ipaddress import ip_address, ip_network +from ipaddress import ip_address from typing import TYPE_CHECKING, Optional from django.http import HttpRequest -from django_otp import devices_for_user from structlog.stdlib import get_logger -from authentik.core.models import User from authentik.flows.planner import PLAN_CONTEXT_SSO from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.utils.http import get_client_ip @@ -27,16 +25,14 @@ class PolicyEvaluator(BaseEvaluator): policy: Optional["ExpressionPolicy"] = None - def __init__(self, policy_name: str): - super().__init__() + def __init__(self, policy_name: Optional[str] = None): + super().__init__(policy_name or "PolicyEvaluator") self._messages = [] - self._context["ak_logger"] = get_logger(policy_name) + # update website/docs/expressions/_objects.md + # update website/docs/expressions/_functions.md self._context["ak_message"] = self.expr_func_message self._context["ak_user_has_authenticator"] = self.expr_func_user_has_authenticator self._context["ak_call_policy"] = self.expr_func_call_policy - self._context["ip_address"] = ip_address - self._context["ip_network"] = ip_network - self._filename = policy_name or "PolicyEvaluator" def expr_func_message(self, message: str): """Wrapper to append to messages list, which is returned with PolicyResult""" @@ -52,19 +48,6 @@ class PolicyEvaluator(BaseEvaluator): proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None) return proc.profiling_wrapper() - def expr_func_user_has_authenticator( - self, user: User, device_type: Optional[str] = None - ) -> bool: - """Check if a user has any authenticator devices, optionally matching *device_type*""" - user_devices = devices_for_user(user) - if device_type: - for device in user_devices: - device_class = device.__class__.__name__.lower().replace("device", "") - if device_class == device_type: - return True - return False - return len(list(user_devices)) > 0 - def set_policy_request(self, request: PolicyRequest): """Update context based on policy request (if http request is given, update that too)""" # update website/docs/expressions/_objects.md diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py index 658055408..69ff29ff8 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -22,7 +22,7 @@ from rest_framework.serializers import BaseSerializer from structlog.stdlib import get_logger from authentik.core.exceptions import PropertyMappingExpressionException -from authentik.core.expression import PropertyMappingEvaluator +from authentik.core.expression.evaluator import PropertyMappingEvaluator from authentik.core.models import User from authentik.flows.models import Stage from authentik.lib.models import SerializerModel @@ -124,8 +124,7 @@ class Prompt(SerializerModel): return prompt_context[self.field_key] if self.placeholder_expression: - evaluator = PropertyMappingEvaluator() - evaluator.set_context(user, request, self, prompt_context=prompt_context) + evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context) try: return evaluator.evaluate(self.placeholder) except Exception as exc: # pylint:disable=broad-except diff --git a/website/docs/expressions/_functions.md b/website/docs/expressions/_functions.md index 8b5fe365d..984de8e8d 100644 --- a/website/docs/expressions/_functions.md +++ b/website/docs/expressions/_functions.md @@ -51,6 +51,45 @@ Example: other_user = ak_user_by(username="other_user") ``` +### `ak_user_has_authenticator(user: User, device_type: Optional[str] = None) -> bool` (2021.9+) + +:::info +Only available in property mappings with authentik 2022.9 and newer +::: + +Check if a user has any authenticator devices. Only fully validated devices are counted. + +Optionally, you can filter a specific device type. The following options are valid: + +- `totp` +- `duo` +- `static` +- `webauthn` + +Example: + +```python +return ak_user_has_authenticator(request.user) +``` + +### `ak_create_event(action: str, **kwargs) -> None` + +:::info +Requires authentik 2022.9 +::: + +Create a new event with the action set to `action`. Any additional key-word parameters will be saved in the event context. Additionally, `context` will be set to the context in which this function is called. + +Before saving, any data-structure which are not representable in JSON are flattened, and credentials are removed. + +The event is saved automatically + +Example: + +```python +ak_create_event("my_custom_event", foo=request.user) +``` + ## Comparing IP Addresses To compare IP Addresses or check if an IP Address is within a given subnet, you can use the functions `ip_address('192.0.2.1')` and `ip_network('192.0.2.0/24')`. With these objects you can do [arithmetic operations](https://docs.python.org/3/library/ipaddress.html#operators). diff --git a/website/docs/policies/expression.mdx b/website/docs/policies/expression.mdx index a424b6f37..256cf88f4 100644 --- a/website/docs/policies/expression.mdx +++ b/website/docs/policies/expression.mdx @@ -29,23 +29,6 @@ ak_message("Access denied") return False ``` -### `ak_user_has_authenticator(user: User, device_type: Optional[str] = None) -> bool` (2021.9+) - -Check if a user has any authenticator devices. Only fully validated devices are counted. - -Optionally, you can filter a specific device type. The following options are valid: - -- `totp` -- `duo` -- `static` -- `webauthn` - -Example: - -```python -return ak_user_has_authenticator(request.user) -``` - ### `ak_call_policy(name: str, **kwargs) -> PolicyResult` (2021.12+) Call another policy with the name _name_. Current request is passed to policy. Key-word arguments