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.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

View file

View file

@ -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

View file

@ -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:

View file

@ -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})"

View file

@ -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

View file

@ -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

View file

@ -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).

View file

@ -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