core: add helper function to create events from expressions, move ak_user_has_authenticator to base evaluator

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2022-09-14 21:52:41 +02:00
parent ab28370f20
commit 9f5c019daa
9 changed files with 107 additions and 62 deletions

View File

@ -17,7 +17,7 @@ from authentik.api.decorators import permission_required
from authentik.blueprints.api import ManagedSerializer from authentik.blueprints.api import ManagedSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer 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.core.models import PropertyMapping
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.api.exec import PolicyTestSerializer from authentik.policies.api.exec import PolicyTestSerializer
@ -41,7 +41,9 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
def validate_expression(self, expression: str) -> str: def validate_expression(self, expression: str) -> str:
"""Test Syntax""" """Test Syntax"""
evaluator = PropertyMappingEvaluator() evaluator = PropertyMappingEvaluator(
self.instance,
)
evaluator.validate(expression) evaluator.validate(expression)
return expression return expression

View File

View File

@ -2,28 +2,33 @@
from traceback import format_tb from traceback import format_tb
from typing import Optional from typing import Optional
from django.db.models import Model
from django.http import HttpRequest from django.http import HttpRequest
from guardian.utils import get_anonymous_user 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.events.models import Event, EventAction
from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
class PropertyMappingEvaluator(BaseEvaluator): 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, self,
user: Optional[User], model: Model,
request: Optional[HttpRequest], user: Optional[User] = None,
mapping: PropertyMapping, request: Optional[HttpRequest] = None,
**kwargs, **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 = PolicyRequest(user=get_anonymous_user())
req.obj = mapping req.obj = model
if user: if user:
req.user = user req.user = user
self._context["user"] = user self._context["user"] = user

View File

@ -617,10 +617,9 @@ class PropertyMapping(SerializerModel, ManagedModel):
def evaluate(self, user: Optional[User], request: Optional[HttpRequest], **kwargs) -> Any: def evaluate(self, user: Optional[User], request: Optional[HttpRequest], **kwargs) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context.""" """Evaluate `self.expression` using `**kwargs` as Context."""
from authentik.core.expression import PropertyMappingEvaluator from authentik.core.expression.evaluator import PropertyMappingEvaluator
evaluator = PropertyMappingEvaluator() evaluator = PropertyMappingEvaluator(self, user, request, **kwargs)
evaluator.set_context(user, request, self, **kwargs)
try: try:
return evaluator.evaluate(self.expression) return evaluator.evaluate(self.expression)
except Exception as exc: except Exception as exc:

View File

@ -1,16 +1,20 @@
"""authentik expression policy evaluator""" """authentik expression policy evaluator"""
import re import re
from ipaddress import ip_address, ip_network
from textwrap import indent from textwrap import indent
from typing import Any, Iterable, Optional from typing import Any, Iterable, Optional
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django_otp import devices_for_user
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from sentry_sdk.hub import Hub from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import User from authentik.core.models import User
from authentik.events.models import Event
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
from authentik.policies.types import PolicyRequest
LOGGER = get_logger() LOGGER = get_logger()
@ -26,7 +30,8 @@ class BaseEvaluator:
# Filename used for exec # Filename used for exec
_filename: str _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/_objects.md
# update website/docs/expressions/_functions.md # update website/docs/expressions/_functions.md
self._globals = { self._globals = {
@ -35,11 +40,14 @@ class BaseEvaluator:
"list_flatten": BaseEvaluator.expr_flatten, "list_flatten": BaseEvaluator.expr_flatten,
"ak_is_group_member": BaseEvaluator.expr_is_group_member, "ak_is_group_member": BaseEvaluator.expr_is_group_member,
"ak_user_by": BaseEvaluator.expr_user_by, "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(), "requests": get_http_session(),
"ip_address": ip_address,
"ip_network": ip_network,
} }
self._context = {} self._context = {}
self._filename = "BaseEvalautor"
@staticmethod @staticmethod
def expr_flatten(value: list[Any] | Any) -> Optional[Any]: def expr_flatten(value: list[Any] | Any) -> Optional[Any]:
@ -60,6 +68,11 @@ class BaseEvaluator:
"""Expression Filter to run re.sub""" """Expression Filter to run re.sub"""
return re.sub(regex, repl, value) 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 @staticmethod
def expr_user_by(**filters) -> Optional[User]: def expr_user_by(**filters) -> Optional[User]:
"""Get user by filters""" """Get user by filters"""
@ -72,15 +85,37 @@ class BaseEvaluator:
return None return None
@staticmethod @staticmethod
def expr_is_group_member(user: User, **group_filters) -> bool: def expr_func_user_has_authenticator(user: User, device_type: Optional[str] = None) -> bool:
"""Check if `user` is member of group with name `group_name`""" """Check if a user has any authenticator devices, optionally matching *device_type*"""
return user.ak_groups.filter(**group_filters).exists() 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: def wrap_expression(self, expression: str, params: Iterable[str]) -> str:
"""Wrap expression in a function, call it, and save the result as `result`""" """Wrap expression in a function, call it, and save the result as `result`"""
handler_signature = ",".join(params) handler_signature = ",".join(params)
full_expression = "" full_expression = ""
full_expression += "from ipaddress import ip_address, ip_network\n"
full_expression += f"def handler({handler_signature}):\n" full_expression += f"def handler({handler_signature}):\n"
full_expression += indent(expression, " ") full_expression += indent(expression, " ")
full_expression += f"\nresult = handler({handler_signature})" full_expression += f"\nresult = handler({handler_signature})"

View File

@ -1,12 +1,10 @@
"""authentik expression policy evaluator""" """authentik expression policy evaluator"""
from ipaddress import ip_address, ip_network from ipaddress import ip_address
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from django.http import HttpRequest from django.http import HttpRequest
from django_otp import devices_for_user
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.flows.planner import PLAN_CONTEXT_SSO from authentik.flows.planner import PLAN_CONTEXT_SSO
from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.lib.utils.http import get_client_ip from authentik.lib.utils.http import get_client_ip
@ -27,16 +25,14 @@ class PolicyEvaluator(BaseEvaluator):
policy: Optional["ExpressionPolicy"] = None policy: Optional["ExpressionPolicy"] = None
def __init__(self, policy_name: str): def __init__(self, policy_name: Optional[str] = None):
super().__init__() super().__init__(policy_name or "PolicyEvaluator")
self._messages = [] 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_message"] = self.expr_func_message
self._context["ak_user_has_authenticator"] = self.expr_func_user_has_authenticator self._context["ak_user_has_authenticator"] = self.expr_func_user_has_authenticator
self._context["ak_call_policy"] = self.expr_func_call_policy 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): def expr_func_message(self, message: str):
"""Wrapper to append to messages list, which is returned with PolicyResult""" """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) proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
return proc.profiling_wrapper() 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): def set_policy_request(self, request: PolicyRequest):
"""Update context based on policy request (if http request is given, update that too)""" """Update context based on policy request (if http request is given, update that too)"""
# update website/docs/expressions/_objects.md # update website/docs/expressions/_objects.md

View File

@ -22,7 +22,7 @@ from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.exceptions import PropertyMappingExpressionException 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.core.models import User
from authentik.flows.models import Stage from authentik.flows.models import Stage
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
@ -124,8 +124,7 @@ class Prompt(SerializerModel):
return prompt_context[self.field_key] return prompt_context[self.field_key]
if self.placeholder_expression: if self.placeholder_expression:
evaluator = PropertyMappingEvaluator() evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context)
evaluator.set_context(user, request, self, prompt_context=prompt_context)
try: try:
return evaluator.evaluate(self.placeholder) return evaluator.evaluate(self.placeholder)
except Exception as exc: # pylint:disable=broad-except except Exception as exc: # pylint:disable=broad-except

View File

@ -51,6 +51,45 @@ Example:
other_user = ak_user_by(username="other_user") 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 ## 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). 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).

View File

@ -29,23 +29,6 @@ ak_message("Access denied")
return False 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+) ### `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 Call another policy with the name _name_. Current request is passed to policy. Key-word arguments