Compare commits
	
		
			11 Commits
		
	
	
		
			manualdeps
			...
			restricted
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| efdecf949d | |||
| 9ea5f56715 | |||
| 8228b56b75 | |||
| 518e10dbdb | |||
| 79ddad28a8 | |||
| beeec85c15 | |||
| 0561b8d578 | |||
| 201481bde3 | |||
| 7d04903d5b | |||
| db7d880116 | |||
| 259cc81723 | 
| @ -73,6 +73,11 @@ def auth_user_lookup(raw_header: bytes) -> User | None: | |||||||
|     if user: |     if user: | ||||||
|         CTX_AUTH_VIA.set("secret_key") |         CTX_AUTH_VIA.set("secret_key") | ||||||
|         return user |         return user | ||||||
|  |     # then try to auth via expression JWT | ||||||
|  |     user = token_expression_jwt(auth_credentials) | ||||||
|  |     if user: | ||||||
|  |         CTX_AUTH_VIA.set("expression_jwt") | ||||||
|  |         return user | ||||||
|     raise AuthenticationFailed("Token invalid/expired") |     raise AuthenticationFailed("Token invalid/expired") | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -90,6 +95,13 @@ def token_secret_key(value: str) -> User | None: | |||||||
|     return outpost.user |     return outpost.user | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def token_expression_jwt(value: str) -> User | None: | ||||||
|  |     """Authenticate API call made by Expressions""" | ||||||
|  |     from authentik.lib.expression.evaluator import authenticate_token | ||||||
|  |  | ||||||
|  |     return authenticate_token(value) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenAuthentication(BaseAuthentication): | class TokenAuthentication(BaseAuthentication): | ||||||
|     """Token-based authentication using HTTP Bearer authentication""" |     """Token-based authentication using HTTP Bearer authentication""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -36,7 +36,7 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|             _filename = model.name |             _filename = model.name | ||||||
|         else: |         else: | ||||||
|             _filename = str(model) |             _filename = str(model) | ||||||
|         super().__init__(filename=_filename) |         super().__init__(None, filename=_filename) | ||||||
|         req = PolicyRequest(user=User()) |         req = PolicyRequest(user=User()) | ||||||
|         req.obj = model |         req.obj = model | ||||||
|         if user: |         if user: | ||||||
|  | |||||||
| @ -95,6 +95,9 @@ outposts: | |||||||
|   discover: true |   discover: true | ||||||
|   disable_embedded_outpost: false |   disable_embedded_outpost: false | ||||||
|  |  | ||||||
|  | expressions: | ||||||
|  |   global_runtime: python # or python_restricted | ||||||
|  |  | ||||||
| ldap: | ldap: | ||||||
|   task_timeout_hours: 2 |   task_timeout_hours: 2 | ||||||
|   page_size: 50 |   page_size: 50 | ||||||
|  | |||||||
| @ -3,27 +3,117 @@ | |||||||
| import re | import re | ||||||
| import socket | import socket | ||||||
| from collections.abc import Iterable | from collections.abc import Iterable | ||||||
|  | from datetime import timedelta | ||||||
|  | from functools import lru_cache | ||||||
| from ipaddress import ip_address, ip_network | from ipaddress import ip_address, ip_network | ||||||
|  | from pathlib import Path | ||||||
|  | from tempfile import gettempdir | ||||||
| from textwrap import indent | from textwrap import indent | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
|  | from authentik_client.api.admin_api import AdminApi | ||||||
|  | from authentik_client.api.authenticators_api import AuthenticatorsApi | ||||||
|  | from authentik_client.api.core_api import CoreApi | ||||||
|  | from authentik_client.api.crypto_api import CryptoApi | ||||||
|  | from authentik_client.api.enterprise_api import EnterpriseApi | ||||||
|  | from authentik_client.api.events_api import EventsApi | ||||||
|  | from authentik_client.api.flows_api import FlowsApi | ||||||
|  | from authentik_client.api.managed_api import ManagedApi | ||||||
|  | from authentik_client.api.oauth2_api import Oauth2Api | ||||||
|  | from authentik_client.api.outposts_api import OutpostsApi | ||||||
|  | from authentik_client.api.policies_api import PoliciesApi | ||||||
|  | from authentik_client.api.propertymappings_api import PropertymappingsApi | ||||||
|  | from authentik_client.api.providers_api import ProvidersApi | ||||||
|  | from authentik_client.api.rac_api import RacApi | ||||||
|  | from authentik_client.api.rbac_api import RbacApi | ||||||
|  | from authentik_client.api.root_api import RootApi | ||||||
|  | from authentik_client.api.schema_api import SchemaApi | ||||||
|  | from authentik_client.api.sources_api import SourcesApi | ||||||
|  | from authentik_client.api.stages_api import StagesApi | ||||||
|  | from authentik_client.api.tenants_api import TenantsApi | ||||||
|  | from authentik_client.api_client import ApiClient | ||||||
|  | from authentik_client.configuration import Configuration | ||||||
| from cachetools import TLRUCache, cached | from cachetools import TLRUCache, cached | ||||||
|  | from django.conf import settings | ||||||
| from django.core.exceptions import FieldError | from django.core.exceptions import FieldError | ||||||
|  | from django.utils.timezone import now | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  | from jwt import PyJWTError, decode, encode | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
|  | from RestrictedPython import compile_restricted, limited_builtins, safe_builtins, utility_builtins | ||||||
| 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.events.models import Event | ||||||
| from authentik.lib.utils.http import get_http_session | from authentik.lib.config import CONFIG | ||||||
|  | from authentik.lib.generators import generate_key | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
|  | from authentik.lib.utils.http import authentik_user_agent, get_http_session | ||||||
|  | from authentik.lib.utils.reflection import get_apps | ||||||
| from authentik.policies.models import Policy, PolicyBinding | from authentik.policies.models import Policy, PolicyBinding | ||||||
| from authentik.policies.process import PolicyProcess | from authentik.policies.process import PolicyProcess | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
| from authentik.stages.authenticator import devices_for_user | from authentik.stages.authenticator import devices_for_user | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | _tmp = Path(gettempdir()) | ||||||
|  | token_path = _tmp / "authentik-evaluator-token" | ||||||
|  |  | ||||||
|  | API_CLIENTS = { | ||||||
|  |     "AdminApi": AdminApi, | ||||||
|  |     "AuthenticatorsApi": AuthenticatorsApi, | ||||||
|  |     "CoreApi": CoreApi, | ||||||
|  |     "CryptoApi": CryptoApi, | ||||||
|  |     "EnterpriseApi": EnterpriseApi, | ||||||
|  |     "EventsApi": EventsApi, | ||||||
|  |     "FlowsApi": FlowsApi, | ||||||
|  |     "ManagedApi": ManagedApi, | ||||||
|  |     "Oauth2Api": Oauth2Api, | ||||||
|  |     "OutpostsApi": OutpostsApi, | ||||||
|  |     "PoliciesApi": PoliciesApi, | ||||||
|  |     "PropertymappingsApi": PropertymappingsApi, | ||||||
|  |     "ProvidersApi": ProvidersApi, | ||||||
|  |     "RacApi": RacApi, | ||||||
|  |     "RbacApi": RbacApi, | ||||||
|  |     "RootApi": RootApi, | ||||||
|  |     "SchemaApi": SchemaApi, | ||||||
|  |     "SourcesApi": SourcesApi, | ||||||
|  |     "StagesApi": StagesApi, | ||||||
|  |     "TenantsApi": TenantsApi, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | JWT_AUD = "goauthentik.io/api/expression" | ||||||
|  |  | ||||||
|  | _SAFE_MODULES = frozenset(("authentik_client",)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _safe_import(name, *args, **kwargs): | ||||||
|  |     if name not in _SAFE_MODULES: | ||||||
|  |         raise Exception(f"Don't you even think about {name!r}") | ||||||
|  |     return __import__(name, *args, **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @lru_cache | ||||||
|  | def get_api_token_secret(): | ||||||
|  |     if token_path.exists(): | ||||||
|  |         with open(token_path) as _token_file: | ||||||
|  |             return _token_file.read() | ||||||
|  |     key = generate_key() | ||||||
|  |     with open(_tmp / "authentik-evaluator-token", "w") as _token_file: | ||||||
|  |         _token_file.write(key) | ||||||
|  |     return key | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def authenticate_token(raw_value: str): | ||||||
|  |     """Authenticate API call from evaluator token""" | ||||||
|  |     try: | ||||||
|  |         jwt = decode(raw_value, get_api_token_secret(), ["HS256"], audience=JWT_AUD) | ||||||
|  |         return User.objects.filter(pk=jwt["sub"]).first() | ||||||
|  |     except PyJWTError as exc: | ||||||
|  |         LOGGER.debug("failed to auth", exc=exc) | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseEvaluator: | class BaseEvaluator: | ||||||
| @ -37,8 +127,14 @@ class BaseEvaluator: | |||||||
|     # Filename used for exec |     # Filename used for exec | ||||||
|     _filename: str |     _filename: str | ||||||
|  |  | ||||||
|     def __init__(self, filename: str | None = None): |     _user: User | ||||||
|  |  | ||||||
|  |     # Timeout in seconds, used for the expiration of the API key | ||||||
|  |     timeout = 30 | ||||||
|  |  | ||||||
|  |     def __init__(self, user: User, filename: str | None = None): | ||||||
|         self._filename = filename if filename else "BaseEvaluator" |         self._filename = filename if filename else "BaseEvaluator" | ||||||
|  |         self._user = user | ||||||
|         # 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 = { | ||||||
| @ -57,8 +153,44 @@ class BaseEvaluator: | |||||||
|             "resolve_dns": BaseEvaluator.expr_resolve_dns, |             "resolve_dns": BaseEvaluator.expr_resolve_dns, | ||||||
|             "reverse_dns": BaseEvaluator.expr_reverse_dns, |             "reverse_dns": BaseEvaluator.expr_reverse_dns, | ||||||
|         } |         } | ||||||
|  |         for app in get_apps(): | ||||||
|  |             # Load models from each app | ||||||
|  |             for model in app.get_models(): | ||||||
|  |                 self._globals[model.__name__] = model | ||||||
|  |         self._globals.update(API_CLIENTS) | ||||||
|         self._context = {} |         self._context = {} | ||||||
|  |  | ||||||
|  |     def get_token(self) -> str: | ||||||
|  |         """Generate API token to be used by the API Client""" | ||||||
|  |         _now = now() | ||||||
|  |         if not self._user: | ||||||
|  |             self._user = get_anonymous_user() | ||||||
|  |         return encode( | ||||||
|  |             { | ||||||
|  |                 "aud": JWT_AUD, | ||||||
|  |                 "iss": f"goauthentik.io/expression/{self._filename}", | ||||||
|  |                 "sub": str(self._user.pk), | ||||||
|  |                 "iat": int(_now.timestamp()), | ||||||
|  |                 "exp": int((_now + timedelta(seconds=self.timeout)).timestamp()), | ||||||
|  |             }, | ||||||
|  |             get_api_token_secret(), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def get_api_client(self): | ||||||
|  |         token = self.get_token() | ||||||
|  |         config = Configuration( | ||||||
|  |             f"unix://{str(_tmp.joinpath('authentik-core.sock'))}/api/v3", | ||||||
|  |             api_key={ | ||||||
|  |                 "authentik": token, | ||||||
|  |             }, | ||||||
|  |             api_key_prefix={"authentik": "Bearer"}, | ||||||
|  |         ) | ||||||
|  |         if settings.DEBUG: | ||||||
|  |             config.host = "http://localhost:8000/api/v3" | ||||||
|  |         client = ApiClient(config) | ||||||
|  |         client.user_agent = authentik_user_agent() | ||||||
|  |         return client | ||||||
|  |  | ||||||
|     @cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180)) |     @cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180)) | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_resolve_dns(host: str, ip_version: int | None = None) -> list[str]: |     def expr_resolve_dns(host: str, ip_version: int | None = None) -> list[str]: | ||||||
| @ -185,7 +317,16 @@ class BaseEvaluator: | |||||||
|     def compile(self, expression: str) -> Any: |     def compile(self, expression: str) -> Any: | ||||||
|         """Parse expression. Raises SyntaxError or ValueError if the syntax is incorrect.""" |         """Parse expression. Raises SyntaxError or ValueError if the syntax is incorrect.""" | ||||||
|         param_keys = self._context.keys() |         param_keys = self._context.keys() | ||||||
|         return compile(self.wrap_expression(expression, param_keys), self._filename, "exec") |         compiler = ( | ||||||
|  |             compile_restricted | ||||||
|  |             if CONFIG.get("expressions.global_runtime") == "python_restricted" | ||||||
|  |             else compile | ||||||
|  |         ) | ||||||
|  |         return compiler( | ||||||
|  |             self.wrap_expression(expression, param_keys), | ||||||
|  |             self._filename, | ||||||
|  |             "exec", | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def evaluate(self, expression_source: str) -> Any: |     def evaluate(self, expression_source: str) -> Any: | ||||||
|         """Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised. |         """Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised. | ||||||
| @ -201,7 +342,17 @@ class BaseEvaluator: | |||||||
|                 self.handle_error(exc, expression_source) |                 self.handle_error(exc, expression_source) | ||||||
|                 raise exc |                 raise exc | ||||||
|             try: |             try: | ||||||
|  |                 if CONFIG.get("expressions.global_runtime") == "python_restricted": | ||||||
|  |                     self._globals["__builtins__"] = { | ||||||
|  |                         **safe_builtins, | ||||||
|  |                         **limited_builtins, | ||||||
|  |                         **utility_builtins, | ||||||
|  |                         "__import__": _safe_import, | ||||||
|  |                     } | ||||||
|                 _locals = self._context |                 _locals = self._context | ||||||
|  |                 # We need to create the API Client later so that the token is valid | ||||||
|  |                 # from when the execution starts | ||||||
|  |                 self._globals["api"] = self.get_api_client() | ||||||
|                 # Yes this is an exec, yes it is potentially bad. Since we limit what variables are |                 # Yes this is an exec, yes it is potentially bad. Since we limit what variables are | ||||||
|                 # available here, and these policies can only be edited by admins, this is a risk |                 # available here, and these policies can only be edited by admins, this is a risk | ||||||
|                 # we're willing to take. |                 # we're willing to take. | ||||||
| @ -209,6 +360,7 @@ class BaseEvaluator: | |||||||
|                 exec(ast_obj, self._globals, _locals)  # nosec # noqa |                 exec(ast_obj, self._globals, _locals)  # nosec # noqa | ||||||
|                 result = _locals["result"] |                 result = _locals["result"] | ||||||
|             except Exception as exc: |             except Exception as exc: | ||||||
|  |                 print(exception_to_string(exc)) | ||||||
|                 # So, this is a bit questionable. Essentially, we are edit the stacktrace |                 # So, this is a bit questionable. Essentially, we are edit the stacktrace | ||||||
|                 # so the user only sees information relevant to them |                 # so the user only sees information relevant to them | ||||||
|                 # and none of our surrounding error handling |                 # and none of our surrounding error handling | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| """Test Evaluator base functions""" | """Test Evaluator base functions""" | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.tests.utils import create_test_admin_user | from authentik.core.tests.utils import create_test_admin_user | ||||||
| from authentik.events.models import Event | from authentik.events.models import Event | ||||||
| @ -33,7 +34,7 @@ class TestEvaluator(TestCase): | |||||||
|  |  | ||||||
|     def test_expr_event_create(self): |     def test_expr_event_create(self): | ||||||
|         """Test expr_event_create""" |         """Test expr_event_create""" | ||||||
|         evaluator = BaseEvaluator(generate_id()) |         evaluator = BaseEvaluator(get_anonymous_user(), generate_id()) | ||||||
|         evaluator._context = { |         evaluator._context = { | ||||||
|             "foo": "bar", |             "foo": "bar", | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -14,12 +14,13 @@ class ExpressionPolicySerializer(PolicySerializer): | |||||||
|     def validate_expression(self, expr: str) -> str: |     def validate_expression(self, expr: str) -> str: | ||||||
|         """validate the syntax of the expression""" |         """validate the syntax of the expression""" | ||||||
|         name = "temp-policy" if not self.instance else self.instance.name |         name = "temp-policy" if not self.instance else self.instance.name | ||||||
|         PolicyEvaluator(name).validate(expr) |         request = self.context.get("request") | ||||||
|  |         PolicyEvaluator(request.user if request else None, name).validate(expr) | ||||||
|         return expr |         return expr | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = ExpressionPolicy |         model = ExpressionPolicy | ||||||
|         fields = PolicySerializer.Meta.fields + ["expression"] |         fields = PolicySerializer.Meta.fields + ["expression", "execution_user"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet): | class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet): | ||||||
|  | |||||||
| @ -1,11 +1,12 @@ | |||||||
| """Authentik policy_expression app config""" | """Authentik policy_expression app config""" | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from authentik.blueprints.apps import ManagedAppConfig | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikPolicyExpressionConfig(AppConfig): | class AuthentikPolicyExpressionConfig(ManagedAppConfig): | ||||||
|     """Authentik policy_expression app config""" |     """Authentik policy_expression app config""" | ||||||
|  |  | ||||||
|     name = "authentik.policies.expression" |     name = "authentik.policies.expression" | ||||||
|     label = "authentik_policies_expression" |     label = "authentik_policies_expression" | ||||||
|     verbose_name = "authentik Policies.Expression" |     verbose_name = "authentik Policies.Expression" | ||||||
|  |     default = True | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| 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.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| @ -24,8 +25,8 @@ class PolicyEvaluator(BaseEvaluator): | |||||||
|  |  | ||||||
|     policy: Optional["ExpressionPolicy"] = None |     policy: Optional["ExpressionPolicy"] = None | ||||||
|  |  | ||||||
|     def __init__(self, policy_name: str | None = None): |     def __init__(self, user: User, policy_name: str | None = None): | ||||||
|         super().__init__(policy_name or "PolicyEvaluator") |         super().__init__(user, policy_name or "PolicyEvaluator") | ||||||
|         self._messages = [] |         self._messages = [] | ||||||
|         # update website/docs/expressions/_objects.md |         # update website/docs/expressions/_objects.md | ||||||
|         # update website/docs/expressions/_functions.md |         # update website/docs/expressions/_functions.md | ||||||
| @ -44,6 +45,8 @@ class PolicyEvaluator(BaseEvaluator): | |||||||
|         if request.http_request: |         if request.http_request: | ||||||
|             self.set_http_request(request.http_request) |             self.set_http_request(request.http_request) | ||||||
|         self._context["request"] = request |         self._context["request"] = request | ||||||
|  |         if not self._user: | ||||||
|  |             self._user = request.user | ||||||
|         self._context["context"] = request.context |         self._context["context"] = request.context | ||||||
|  |  | ||||||
|     def set_http_request(self, request: HttpRequest): |     def set_http_request(self, request: HttpRequest): | ||||||
|  | |||||||
| @ -0,0 +1,26 @@ | |||||||
|  | # Generated by Django 5.0.3 on 2024-03-20 12:14 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.conf import settings | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_policies_expression", "0004_expressionpolicy_authentik_p_policy__fb6feb_idx"), | ||||||
|  |         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="expressionpolicy", | ||||||
|  |             name="execution_user", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 default=None, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.SET_DEFAULT, | ||||||
|  |                 to=settings.AUTH_USER_MODEL, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -12,6 +12,9 @@ from authentik.policies.types import PolicyRequest, PolicyResult | |||||||
| class ExpressionPolicy(Policy): | class ExpressionPolicy(Policy): | ||||||
|     """Execute arbitrary Python code to implement custom checks and validation.""" |     """Execute arbitrary Python code to implement custom checks and validation.""" | ||||||
|  |  | ||||||
|  |     execution_user = models.ForeignKey( | ||||||
|  |         "authentik_core.User", default=None, null=True, on_delete=models.SET_DEFAULT | ||||||
|  |     ) | ||||||
|     expression = models.TextField() |     expression = models.TextField() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @ -26,17 +29,11 @@ class ExpressionPolicy(Policy): | |||||||
|  |  | ||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: |     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||||
|         """Evaluate and render expression. Returns PolicyResult(false) on error.""" |         """Evaluate and render expression. Returns PolicyResult(false) on error.""" | ||||||
|         evaluator = PolicyEvaluator(self.name) |         evaluator = PolicyEvaluator(self.execution_user, self.name) | ||||||
|         evaluator.policy = self |         evaluator.policy = self | ||||||
|         evaluator.set_policy_request(request) |         evaluator.set_policy_request(request) | ||||||
|         return evaluator.evaluate(self.expression) |         return evaluator.evaluate(self.expression) | ||||||
|  |  | ||||||
|     def save(self, *args, **kwargs): |  | ||||||
|         evaluator = PolicyEvaluator(self.name) |  | ||||||
|         evaluator.policy = self |  | ||||||
|         evaluator.validate(self.expression) |  | ||||||
|         return super().save(*args, **kwargs) |  | ||||||
|  |  | ||||||
|     class Meta(Policy.PolicyMeta): |     class Meta(Policy.PolicyMeta): | ||||||
|         verbose_name = _("Expression Policy") |         verbose_name = _("Expression Policy") | ||||||
|         verbose_name_plural = _("Expression Policies") |         verbose_name_plural = _("Expression Policies") | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								authentik/policies/expression/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								authentik/policies/expression/signals.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | from django.db.models.signals import pre_save | ||||||
|  | from django.dispatch import receiver | ||||||
|  |  | ||||||
|  | from authentik.policies.expression.evaluator import PolicyEvaluator | ||||||
|  | from authentik.policies.expression.models import ExpressionPolicy | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(pre_save, sender=ExpressionPolicy) | ||||||
|  | def pre_save_expression_policy(sender: type[ExpressionPolicy], instance: ExpressionPolicy, **_): | ||||||
|  |     """Ensure policy is valid before saving""" | ||||||
|  |     evaluator = PolicyEvaluator(instance.execution_user, instance.name) | ||||||
|  |     evaluator.policy = instance | ||||||
|  |     evaluator.validate(instance.expression) | ||||||
| @ -41,14 +41,14 @@ class TestEvaluator(TestCase): | |||||||
|     def test_valid(self): |     def test_valid(self): | ||||||
|         """test simple value expression""" |         """test simple value expression""" | ||||||
|         template = "return True" |         template = "return True" | ||||||
|         evaluator = PolicyEvaluator("test") |         evaluator = PolicyEvaluator(self.request.user, "test") | ||||||
|         evaluator.set_policy_request(self.request) |         evaluator.set_policy_request(self.request) | ||||||
|         self.assertEqual(evaluator.evaluate(template).passing, True) |         self.assertEqual(evaluator.evaluate(template).passing, True) | ||||||
|  |  | ||||||
|     def test_messages(self): |     def test_messages(self): | ||||||
|         """test expression with message return""" |         """test expression with message return""" | ||||||
|         template = 'ak_message("some message");return False' |         template = 'ak_message("some message");return False' | ||||||
|         evaluator = PolicyEvaluator("test") |         evaluator = PolicyEvaluator(self.request.user, "test") | ||||||
|         evaluator.set_policy_request(self.request) |         evaluator.set_policy_request(self.request) | ||||||
|         result = evaluator.evaluate(template) |         result = evaluator.evaluate(template) | ||||||
|         self.assertEqual(result.passing, False) |         self.assertEqual(result.passing, False) | ||||||
| @ -57,7 +57,7 @@ class TestEvaluator(TestCase): | |||||||
|     def test_invalid_syntax(self): |     def test_invalid_syntax(self): | ||||||
|         """test invalid syntax""" |         """test invalid syntax""" | ||||||
|         template = ";" |         template = ";" | ||||||
|         evaluator = PolicyEvaluator("test") |         evaluator = PolicyEvaluator(self.request.user, "test") | ||||||
|         evaluator.set_policy_request(self.request) |         evaluator.set_policy_request(self.request) | ||||||
|         with self.assertRaises(PolicyException): |         with self.assertRaises(PolicyException): | ||||||
|             evaluator.evaluate(template) |             evaluator.evaluate(template) | ||||||
| @ -65,14 +65,14 @@ class TestEvaluator(TestCase): | |||||||
|     def test_validate(self): |     def test_validate(self): | ||||||
|         """test validate""" |         """test validate""" | ||||||
|         template = "True" |         template = "True" | ||||||
|         evaluator = PolicyEvaluator("test") |         evaluator = PolicyEvaluator(self.request.user, "test") | ||||||
|         result = evaluator.validate(template) |         result = evaluator.validate(template) | ||||||
|         self.assertEqual(result, True) |         self.assertEqual(result, True) | ||||||
|  |  | ||||||
|     def test_validate_invalid(self): |     def test_validate_invalid(self): | ||||||
|         """test validate""" |         """test validate""" | ||||||
|         template = ";" |         template = ";" | ||||||
|         evaluator = PolicyEvaluator("test") |         evaluator = PolicyEvaluator(self.request.user, "test") | ||||||
|         with self.assertRaises(ValidationError): |         with self.assertRaises(ValidationError): | ||||||
|             evaluator.validate(template) |             evaluator.validate(template) | ||||||
|  |  | ||||||
| @ -83,7 +83,7 @@ class TestEvaluator(TestCase): | |||||||
|             execution_logging=True, |             execution_logging=True, | ||||||
|             expression="ak_message(request.http_request.path)\nreturn True", |             expression="ak_message(request.http_request.path)\nreturn True", | ||||||
|         ) |         ) | ||||||
|         evaluator = PolicyEvaluator("test") |         evaluator = PolicyEvaluator(self.request.user, "test") | ||||||
|         evaluator.set_policy_request(self.request) |         evaluator.set_policy_request(self.request) | ||||||
|         proc = PolicyProcess(PolicyBinding(policy=expr), request=self.request, connection=None) |         proc = PolicyProcess(PolicyBinding(policy=expr), request=self.request, connection=None) | ||||||
|         res = proc.profiling_wrapper() |         res = proc.profiling_wrapper() | ||||||
|  | |||||||
| @ -85,30 +85,19 @@ entries: | |||||||
|     model: authentik_stages_prompt.prompt |     model: authentik_stages_prompt.prompt | ||||||
|   - attrs: |   - attrs: | ||||||
|       expression: | |       expression: | | ||||||
|         from authentik.core.models import ( |  | ||||||
|             USER_ATTRIBUTE_CHANGE_EMAIL, |  | ||||||
|             USER_ATTRIBUTE_CHANGE_NAME, |  | ||||||
|             USER_ATTRIBUTE_CHANGE_USERNAME |  | ||||||
|         ) |  | ||||||
|         prompt_data = request.context.get("prompt_data") |         prompt_data = request.context.get("prompt_data") | ||||||
|  |         user_group_attributes = request.user.group_attributes(request.http_request) | ||||||
|         if not request.user.group_attributes(request.http_request).get( |         if not user_group_attributes.get(USER_ATTRIBUTE_CHANGE_EMAIL, request.http_request.tenant.default_user_change_email): | ||||||
|             USER_ATTRIBUTE_CHANGE_EMAIL, request.http_request.tenant.default_user_change_email |  | ||||||
|         ): |  | ||||||
|             if prompt_data.get("email") != request.user.email: |             if prompt_data.get("email") != request.user.email: | ||||||
|                 ak_message("Not allowed to change email address.") |                 ak_message("Not allowed to change email address.") | ||||||
|                 return False |                 return False | ||||||
|  |  | ||||||
|         if not request.user.group_attributes(request.http_request).get( |         if not user_group_attributes.get(USER_ATTRIBUTE_CHANGE_NAME, request.http_request.tenant.default_user_change_name): | ||||||
|             USER_ATTRIBUTE_CHANGE_NAME, request.http_request.tenant.default_user_change_name |  | ||||||
|         ): |  | ||||||
|             if prompt_data.get("name") != request.user.name: |             if prompt_data.get("name") != request.user.name: | ||||||
|                 ak_message("Not allowed to change name.") |                 ak_message("Not allowed to change name.") | ||||||
|                 return False |                 return False | ||||||
|  |  | ||||||
|         if not request.user.group_attributes(request.http_request).get( |         if not user_group_attributes.get(USER_ATTRIBUTE_CHANGE_USERNAME, request.http_request.tenant.default_user_change_username): | ||||||
|             USER_ATTRIBUTE_CHANGE_USERNAME, request.http_request.tenant.default_user_change_username |  | ||||||
|         ): |  | ||||||
|             if prompt_data.get("username") != request.user.username: |             if prompt_data.get("username") != request.user.username: | ||||||
|                 ak_message("Not allowed to change username.") |                 ak_message("Not allowed to change username.") | ||||||
|                 return False |                 return False | ||||||
|  | |||||||
| @ -89,10 +89,9 @@ entries: | |||||||
|     expression: | |     expression: | | ||||||
|       # This policy ensures that the setup flow can only be |       # This policy ensures that the setup flow can only be | ||||||
|       # used one time |       # used one time | ||||||
|       from authentik.flows.models import Flow, FlowAuthenticationRequirement |       FlowsApi(api).flows_instances_partial_update("initial-setup", { | ||||||
|       Flow.objects.filter(slug="initial-setup").update( |           "authentication": "REQUIRE_SUPERUSER" | ||||||
|           authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER, |       }) | ||||||
|       ) |  | ||||||
|       return True |       return True | ||||||
|   id: policy-default-oobe-flow-set-authentication |   id: policy-default-oobe-flow-set-authentication | ||||||
|   identifiers: |   identifiers: | ||||||
|  | |||||||
| @ -3477,6 +3477,10 @@ | |||||||
|                     "type": "string", |                     "type": "string", | ||||||
|                     "minLength": 1, |                     "minLength": 1, | ||||||
|                     "title": "Expression" |                     "title": "Expression" | ||||||
|  |                 }, | ||||||
|  |                 "execution_user": { | ||||||
|  |                     "type": "integer", | ||||||
|  |                     "title": "Execution user" | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|             "required": [] |             "required": [] | ||||||
|  | |||||||
							
								
								
									
										34
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										34
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @ -269,6 +269,23 @@ tests = ["attrs[tests-no-zope]", "zope-interface"] | |||||||
| tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] | tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] | ||||||
| tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] | tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "authentik-client" | ||||||
|  | version = "2024.4.1.post1714149882" | ||||||
|  | description = "authentik" | ||||||
|  | optional = false | ||||||
|  | python-versions = "<4.0,>=3.7" | ||||||
|  | files = [ | ||||||
|  |     {file = "authentik_client-2024.4.1.post1714149882-py3-none-any.whl", hash = "sha256:867b66b6a0fd59fac5a96d2098889fca1fa17409d042c00923d06ba21db27622"}, | ||||||
|  |     {file = "authentik_client-2024.4.1.post1714149882.tar.gz", hash = "sha256:2917c6f531dcbe44392d6f758fe10065399972de0c324d5431ea183e4b11ed7b"}, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | [package.dependencies] | ||||||
|  | pydantic = ">=2" | ||||||
|  | python-dateutil = ">=2.8.2" | ||||||
|  | typing-extensions = ">=4.7.1" | ||||||
|  | urllib3 = ">=1.25.3" | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "autobahn" | name = "autobahn" | ||||||
| version = "23.6.2" | version = "23.6.2" | ||||||
| @ -3385,6 +3402,21 @@ requests = ">=2.0.0" | |||||||
| [package.extras] | [package.extras] | ||||||
| rsa = ["oauthlib[signedtoken] (>=3.0.0)"] | rsa = ["oauthlib[signedtoken] (>=3.0.0)"] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "restrictedpython" | ||||||
|  | version = "7.1" | ||||||
|  | description = "RestrictedPython is a defined subset of the Python language which allows to provide a program input into a trusted environment." | ||||||
|  | optional = false | ||||||
|  | python-versions = ">=3.7, <3.13" | ||||||
|  | files = [ | ||||||
|  |     {file = "RestrictedPython-7.1-py3-none-any.whl", hash = "sha256:56d0c73e5de1757702053383601b0fcd3fb2e428039ee1df860409ad67b17d2b"}, | ||||||
|  |     {file = "RestrictedPython-7.1.tar.gz", hash = "sha256:875aeb51c139d78e34cef8605dc65309b449168060dd08551a1fe9edb47cb9a5"}, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | [package.extras] | ||||||
|  | docs = ["Sphinx", "sphinx-rtd-theme"] | ||||||
|  | test = ["pytest", "pytest-mock"] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "rich" | name = "rich" | ||||||
| version = "13.7.0" | version = "13.7.0" | ||||||
| @ -4683,4 +4715,4 @@ files = [ | |||||||
| [metadata] | [metadata] | ||||||
| lock-version = "2.0" | lock-version = "2.0" | ||||||
| python-versions = "~3.12" | python-versions = "~3.12" | ||||||
| content-hash = "a5774b4e09217805c887700b8a0f457a39c7af40ca59823f00c1f6e8678469e1" | content-hash = "647a426ecb06caf2b8f3165c4e85ed0439568e692b6ade8c7933683985c90b18" | ||||||
|  | |||||||
| @ -83,6 +83,7 @@ filterwarnings = [ | |||||||
| ] | ] | ||||||
|  |  | ||||||
| [tool.poetry.dependencies] | [tool.poetry.dependencies] | ||||||
|  | authentik_client = "2024.4.1.post1714149882" | ||||||
| argon2-cffi = "*" | argon2-cffi = "*" | ||||||
| celery = "*" | celery = "*" | ||||||
| channels = { version = "*", extras = ["daphne"] } | channels = { version = "*", extras = ["daphne"] } | ||||||
| @ -132,6 +133,7 @@ pyjwt = "*" | |||||||
| python = "~3.12" | python = "~3.12" | ||||||
| pyyaml = "*" | pyyaml = "*" | ||||||
| requests-oauthlib = "*" | requests-oauthlib = "*" | ||||||
|  | restrictedpython = "*" | ||||||
| scim2-filter-parser = "*" | scim2-filter-parser = "*" | ||||||
| sentry-sdk = "*" | sentry-sdk = "*" | ||||||
| service_identity = "*" | service_identity = "*" | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								schema.yml
									
									
									
									
									
								
							| @ -11852,6 +11852,10 @@ paths: | |||||||
|         name: execution_logging |         name: execution_logging | ||||||
|         schema: |         schema: | ||||||
|           type: boolean |           type: boolean | ||||||
|  |       - in: query | ||||||
|  |         name: execution_user | ||||||
|  |         schema: | ||||||
|  |           type: integer | ||||||
|       - in: query |       - in: query | ||||||
|         name: expression |         name: expression | ||||||
|         schema: |         schema: | ||||||
| @ -33565,6 +33569,9 @@ components: | |||||||
|           readOnly: true |           readOnly: true | ||||||
|         expression: |         expression: | ||||||
|           type: string |           type: string | ||||||
|  |         execution_user: | ||||||
|  |           type: integer | ||||||
|  |           nullable: true | ||||||
|       required: |       required: | ||||||
|       - bound_to |       - bound_to | ||||||
|       - component |       - component | ||||||
| @ -33588,6 +33595,9 @@ components: | |||||||
|         expression: |         expression: | ||||||
|           type: string |           type: string | ||||||
|           minLength: 1 |           minLength: 1 | ||||||
|  |         execution_user: | ||||||
|  |           type: integer | ||||||
|  |           nullable: true | ||||||
|       required: |       required: | ||||||
|       - expression |       - expression | ||||||
|       - name |       - name | ||||||
| @ -38841,6 +38851,9 @@ components: | |||||||
|         expression: |         expression: | ||||||
|           type: string |           type: string | ||||||
|           minLength: 1 |           minLength: 1 | ||||||
|  |         execution_user: | ||||||
|  |           type: integer | ||||||
|  |           nullable: true | ||||||
|     PatchedFlowRequest: |     PatchedFlowRequest: | ||||||
|       type: object |       type: object | ||||||
|       description: Flow Serializer |       description: Flow Serializer | ||||||
|  | |||||||
| @ -12,7 +12,13 @@ import { TemplateResult, html } from "lit"; | |||||||
| import { customElement } from "lit/decorators.js"; | import { customElement } from "lit/decorators.js"; | ||||||
| import { ifDefined } from "lit/directives/if-defined.js"; | import { ifDefined } from "lit/directives/if-defined.js"; | ||||||
|  |  | ||||||
| import { ExpressionPolicy, PoliciesApi } from "@goauthentik/api"; | import { | ||||||
|  |     CoreApi, | ||||||
|  |     CoreUsersListRequest, | ||||||
|  |     ExpressionPolicy, | ||||||
|  |     PoliciesApi, | ||||||
|  |     User, | ||||||
|  | } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| @customElement("ak-policy-expression-form") | @customElement("ak-policy-expression-form") | ||||||
| export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> { | export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> { | ||||||
| @ -92,6 +98,39 @@ export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> { | |||||||
|                             </a> |                             </a> | ||||||
|                         </p> |                         </p> | ||||||
|                     </ak-form-element-horizontal> |                     </ak-form-element-horizontal> | ||||||
|  |                     <ak-form-element-horizontal label=${msg("Execution user")} name="executionUser"> | ||||||
|  |                         <ak-search-select | ||||||
|  |                             .fetchObjects=${async (query?: string): Promise<User[]> => { | ||||||
|  |                                 const args: CoreUsersListRequest = { | ||||||
|  |                                     ordering: "username", | ||||||
|  |                                 }; | ||||||
|  |                                 if (query !== undefined) { | ||||||
|  |                                     args.search = query; | ||||||
|  |                                 } | ||||||
|  |                                 const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args); | ||||||
|  |                                 return users.results; | ||||||
|  |                             }} | ||||||
|  |                             .renderElement=${(user: User): string => { | ||||||
|  |                                 return user.username; | ||||||
|  |                             }} | ||||||
|  |                             .renderDescription=${(user: User): TemplateResult => { | ||||||
|  |                                 return html`${user.name}`; | ||||||
|  |                             }} | ||||||
|  |                             .value=${(user: User | undefined): number | undefined => { | ||||||
|  |                                 return user?.pk; | ||||||
|  |                             }} | ||||||
|  |                             .selected=${(user: User): boolean => { | ||||||
|  |                                 return this.instance?.executionUser === user.pk; | ||||||
|  |                             }} | ||||||
|  |                             blankable | ||||||
|  |                         > | ||||||
|  |                         </ak-search-select> | ||||||
|  |                         <p class="pf-c-form__helper-text"> | ||||||
|  |                             ${msg( | ||||||
|  |                                 "Configure which user the bundled API client authenticates as. When left empty, the API client will inherit the permissions of the user triggering the policy execution.", | ||||||
|  |                             )} | ||||||
|  |                         </p> | ||||||
|  |                     </ak-form-element-horizontal> | ||||||
|                 </div> |                 </div> | ||||||
|             </ak-form-group>`; |             </ak-form-group>`; | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -44,7 +44,7 @@ export function docLink(path: string): string { | |||||||
|     const ak = globalAK(); |     const ak = globalAK(); | ||||||
|     // Default case or beta build which should always point to latest |     // Default case or beta build which should always point to latest | ||||||
|     if (!ak || ak.build !== "") { |     if (!ak || ak.build !== "") { | ||||||
|         return `https://goauthentik.io${path}`; |         return `https://docs.goauthentik.io${path}`; | ||||||
|     } |     } | ||||||
|     return `https://${ak.versionSubdomain}.goauthentik.io${path}`; |     return `https://${ak.versionSubdomain}.goauthentik.io${path}`; | ||||||
| } | } | ||||||
|  | |||||||
| @ -13,14 +13,12 @@ Depending on what kind of device you want to require the user to have: | |||||||
| #### WebAuthn | #### WebAuthn | ||||||
|  |  | ||||||
| ```python | ```python | ||||||
| from authentik.stages.authenticator_webauthn.models import WebAuthnDevice |  | ||||||
| return WebAuthnDevice.objects.filter(user=request.context['pending_user'], confirmed=True).exists() | return WebAuthnDevice.objects.filter(user=request.context['pending_user'], confirmed=True).exists() | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| #### Duo | #### Duo | ||||||
|  |  | ||||||
| ```python | ```python | ||||||
| from authentik.stages.authenticator_duo.models import DuoDevice |  | ||||||
| return DuoDevice.objects.filter(user=request.context['pending_user'], confirmed=True).exists() | return DuoDevice.objects.filter(user=request.context['pending_user'], confirmed=True).exists() | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,7 +11,6 @@ Newly created users can be created as inactive and can be assigned to a selected | |||||||
| Starting with authentik 2022.5, users can be added to dynamic groups. To do so, simply set `groups` in the flow plan context before this stage is run, for example | Starting with authentik 2022.5, users can be added to dynamic groups. To do so, simply set `groups` in the flow plan context before this stage is run, for example | ||||||
|  |  | ||||||
| ```python | ```python | ||||||
| from authentik.core.models import Group |  | ||||||
| group, _ = Group.objects.get_or_create(name="some-group") | group, _ = Group.objects.get_or_create(name="some-group") | ||||||
| # ["groups"] *must* be set to an array of Group objects, names alone are not enough. | # ["groups"] *must* be set to an array of Group objects, names alone are not enough. | ||||||
| request.context["flow_plan"].context["groups"] = [group] | request.context["flow_plan"].context["groups"] = [group] | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	