Repository: goauthentik/authentik

Issue:
Global python functions/vars for expression policies
Add an option to globaly define some variables and functions to reuse python code in expression policys.
Currently i find myself having similar code and variables chaotically distributed in policies.


Discussion:
You can already achieve this... Well sort of.

**Option 1:**

1. Define your common functionality in an expression policy
2. Add each method/value to the context dict
3. Return `True` from the policy so it always passes

Then:
1. Attach the policy to the beginning of each flow
2. Use the custom method and variable definitions in other policies. 

**Option 2:**

1. Define your common functionality in an expression policy
2. Take parameters for this functionality from the context
3. Add `ak_message(...)` for whatever the user needs to be notified about 
4. Return `True`/`False` depending on the use case

Then:
1. Call the policy from another policy and add the needed parameters to its context  (`ak_call_policy(...)`)
2. Collect the returned messages and policy result
3. Display the messages by calling `ak_message(<returned_message>)`
4. Pass/Fail policy

**Option 3**
If it is just variables (without custom python code) you can also define them as tenant or group attributes. 

Hope that helps.

Hey @sdimovv, thanks for the detailed information. I will try Option 2 and report back with my results. Do you think this approach should become the recommended approach for authentik or is it worth to add a seperate Feature/Model for this? Both sides have their pros/cons.

Seperate Model:
(+) could be made intuitive
(+) (depending on the implementation) no additional config needed for policies using it
(-) more models/terminoligies to learn
(-) additional code+testing needed

"Helper" Expression Policys:
(+) no additional code/abstraction/testing needed
(-) requires additional config in every policy which uses the helper
(-) not very intuitive

No matter which way is choosen, we should document how to share code/variables and present a recommended approach in the docs.
Ny recommendation would also be the 2nd option, basically using polices as functions and then calling them via `ak_call_policy()`, which will return whatever the policy called returns (it doesn't have to be boolean)

It would be a bit clunky to call `ak_call_policy()` multiple times as each call does a database lookup for the policy, so I think changing it to `ak_import_policy()` that returns a callable function would be cleaner `my_policy = ak_import_policy("foo"); my_policy("foo", "bar")`
@BeryJu 
> Ny recommendation would also be the 2nd option, basically using polices as functions and then calling them via `ak_call_policy()`, which will return whatever the policy called returns (it doesn't have to be boolean)
> 

This does not seem to work, that call will always return a PolicyResult object, that does not seem to have the output result. Only the methods `passing` and `messages` which are of type `bool` and `tuple[str]`.

Is this something that is not implemented yet or am I doing something wrong?
It is however possible to achieve this indirectly by storing the return value in some key of the context dict, and then read it in the parent policy after calling the child policy.



Return does not work. Even in the PolicyResult object there is not field for the return value.
My current workaround is using the request context to passing data between policies.
Another downsite of the workaround no. 2 is that the policies used by `ak_call_policy` are marked as "Warning: Policy is not assigned.".
I found another challange: Where to store secrets like API Tokens? From a security perspective it wouldn't be great to store them in the policy which is saved as plain-text in the DB. Maybe a K8 Secret + volume mount works, however i haven't tested it yet.
With the latest changes to prompts (#4822) we can create dynamic prompts. 
Would be nice to use method 2, so using `ak_call_policy` in placeholder (used as expression) in prompts. At the moment following stacktrace appears:
```
Traceback (most recent call last):
  File "/authentik/stages/prompt/api.py", line 102, in preview
    ).get_prompt_challenge_fields([prompt_model], {}, dry_run=True)
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/authentik/stages/prompt/stage.py", line 208, in get_prompt_challenge_fields
    field.get_placeholder(context, self.get_pending_user(), self.request, dry_run)
  File "/authentik/stages/prompt/models.py", line 211, in get_placeholder
    raise wrapped from exc
authentik.core.exceptions.PropertyMappingExpressionException: name 'ak_call_policy' is not defined
```


Resolving patch:
diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py
--- a/authentik/lib/expression/evaluator.py
+++ b/authentik/lib/expression/evaluator.py
@@ -8,6 +8,7 @@
 from cachetools import TLRUCache, cached
 from django.core.exceptions import FieldError
 from django_otp import devices_for_user
+from guardian.shortcuts import get_anonymous_user
 from rest_framework.serializers import ValidationError
 from sentry_sdk.hub import Hub
 from sentry_sdk.tracing import Span
@@ -16,7 +17,9 @@
 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
+from authentik.policies.models import Policy, PolicyBinding
+from authentik.policies.process import PolicyProcess
+from authentik.policies.types import PolicyRequest, PolicyResult
 
 LOGGER = get_logger()
 
@@ -37,19 +40,20 @@ def __init__(self, filename: Optional[str] = None):
         # update website/docs/expressions/_objects.md
         # update website/docs/expressions/_functions.md
         self._globals = {
-            "regex_match": BaseEvaluator.expr_regex_match,
-            "regex_replace": BaseEvaluator.expr_regex_replace,
-            "list_flatten": BaseEvaluator.expr_flatten,
+            "ak_call_policy": self.expr_func_call_policy,
+            "ak_create_event": self.expr_event_create,
             "ak_is_group_member": BaseEvaluator.expr_is_group_member,
+            "ak_logger": get_logger(self._filename).bind(),
             "ak_user_by": BaseEvaluator.expr_user_by,
             "ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator,
-            "resolve_dns": BaseEvaluator.expr_resolve_dns,
-            "reverse_dns": BaseEvaluator.expr_reverse_dns,
-            "ak_create_event": self.expr_event_create,
-            "ak_logger": get_logger(self._filename).bind(),
-            "requests": get_http_session(),
             "ip_address": ip_address,
             "ip_network": ip_network,
+            "list_flatten": BaseEvaluator.expr_flatten,
+            "regex_match": BaseEvaluator.expr_regex_match,
+            "regex_replace": BaseEvaluator.expr_regex_replace,
+            "requests": get_http_session(),
+            "resolve_dns": BaseEvaluator.expr_resolve_dns,
+            "reverse_dns": BaseEvaluator.expr_reverse_dns,
         }
         self._context = {}
 
@@ -152,6 +156,19 @@ def expr_event_create(self, action: str, **kwargs):
                 return
         event.save()
 
+    def expr_func_call_policy(self, name: str, **kwargs) -> PolicyResult:
+        """Call policy by name, with current request"""
+        policy = Policy.objects.filter(name=name).select_subclasses().first()
+        if not policy:
+            raise ValueError(f"Policy '{name}' not found.")
+        user = self._context.get("user", get_anonymous_user())
+        req = PolicyRequest(user)
+        if "request" in self._context:
+            req = self._context["request"]
+        req.context.update(kwargs)
+        proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
+        return proc.profiling_wrapper()
+
     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)
diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py
--- a/authentik/policies/expression/evaluator.py
+++ b/authentik/policies/expression/evaluator.py
@@ -9,8 +9,6 @@
 from authentik.lib.expression.evaluator import BaseEvaluator
 from authentik.lib.utils.http import get_client_ip
 from authentik.policies.exceptions import PolicyException
-from authentik.policies.models import Policy, PolicyBinding
-from authentik.policies.process import PolicyProcess
 from authentik.policies.types import PolicyRequest, PolicyResult
 
 LOGGER = get_logger()
@@ -32,22 +30,11 @@ def __init__(self, policy_name: Optional[str] = None):
         # 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
 
     def expr_func_message(self, message: str):
         """Wrapper to append to messages list, which is returned with PolicyResult"""
         self._messages.append(message)
 
-    def expr_func_call_policy(self, name: str, **kwargs) -> PolicyResult:
-        """Call policy by name, with current request"""
-        policy = Policy.objects.filter(name=name).select_subclasses().first()
-        if not policy:
-            raise ValueError(f"Policy '{name}' not found.")
-        req: PolicyRequest = self._context["request"]
-        req.context.update(kwargs)
-        proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
-        return proc.profiling_wrapper()
-
     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
@@ -83,6 +70,7 @@ def evaluate(self, expression_source: str) -> PolicyResult:
             return PolicyResult(False, str(exc))
         else:
             policy_result = PolicyResult(False, *self._messages)
+            policy_result.raw_result = result
             if result is None:
                 LOGGER.warning(
                     "Expression policy returned None",
diff --git a/authentik/policies/types.py b/authentik/policies/types.py
--- a/authentik/policies/types.py
+++ b/authentik/policies/types.py
@@ -69,10 +69,11 @@ def __str__(self):
 
 @dataclass
 class PolicyResult:
-    """Small data-class to hold policy results"""
+    """Result from evaluating a policy."""
 
     passing: bool
     messages: tuple[str, ...]
+    raw_result: Any
 
     source_binding: Optional["PolicyBinding"]
     source_results: Optional[list["PolicyResult"]]
@@ -83,6 +84,7 @@ def __init__(self, passing: bool, *messages: str):
         super().__init__()
         self.passing = passing
         self.messages = messages
+        self.raw_result = None
         self.source_binding = None
         self.source_results = []
         self.log_messages = []


Test patch:
diff --git a/authentik/core/tests/test_property_mapping.py b/authentik/core/tests/test_property_mapping.py
--- a/authentik/core/tests/test_property_mapping.py
+++ b/authentik/core/tests/test_property_mapping.py
@@ -4,7 +4,10 @@
 
 from authentik.core.exceptions import PropertyMappingExpressionException
 from authentik.core.models import PropertyMapping
+from authentik.core.tests.utils import create_test_admin_user
 from authentik.events.models import Event, EventAction
+from authentik.lib.generators import generate_id
+from authentik.policies.expression.models import ExpressionPolicy
 
 
 class TestPropertyMappings(TestCase):
@@ -12,23 +15,24 @@ class TestPropertyMappings(TestCase):
 
     def setUp(self) -> None:
         super().setUp()
+        self.user = create_test_admin_user()
         self.factory = RequestFactory()
 
     def test_expression(self):
         """Test expression"""
-        mapping = PropertyMapping.objects.create(name="test", expression="return 'test'")
+        mapping = PropertyMapping.objects.create(name=generate_id(), expression="return 'test'")
         self.assertEqual(mapping.evaluate(None, None), "test")
 
     def test_expression_syntax(self):
         """Test expression syntax error"""
-        mapping = PropertyMapping.objects.create(name="test", expression="-")
+        mapping = PropertyMapping.objects.create(name=generate_id(), expression="-")
         with self.assertRaises(PropertyMappingExpressionException):
             mapping.evaluate(None, None)
 
     def test_expression_error_general(self):
         """Test expression error"""
         expr = "return aaa"
-        mapping = PropertyMapping.objects.create(name="test", expression=expr)
+        mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr)
         with self.assertRaises(PropertyMappingExpressionException):
             mapping.evaluate(None, None)
         events = Event.objects.filter(
@@ -41,7 +45,7 @@ def test_expression_error_extended(self):
         """Test expression error (with user and http request"""
         expr = "return aaa"
         request = self.factory.get("/")
-        mapping = PropertyMapping.objects.create(name="test", expression=expr)
+        mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr)
         with self.assertRaises(PropertyMappingExpressionException):
             mapping.evaluate(get_anonymous_user(), request)
         events = Event.objects.filter(
@@ -52,3 +56,23 @@ def test_expression_error_extended(self):
         event = events.first()
         self.assertEqual(event.user["username"], "AnonymousUser")
         self.assertEqual(event.client_ip, "127.0.0.1")
+
+    def test_call_policy(self):
+        """test ak_call_policy"""
+        expr = ExpressionPolicy.objects.create(
+            name=generate_id(),
+            execution_logging=True,
+            expression="return request.http_request.path",
+        )
+        http_request = self.factory.get("/")
+        tmpl = (
+            """
+        res = ak_call_policy('%s')
+        result = [request.http_request.path, res.raw_result]
+        return result
+        """
+            % expr.name
+        )
+        evaluator = PropertyMapping(expression=tmpl, name=generate_id())
+        res = evaluator.evaluate(self.user, http_request)
+        self.assertEqual(res, ["/", "/"])