From 0561b8d5788e61f09e6bdda4ebcbdea8fa231b28 Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Wed, 20 Mar 2024 13:49:05 +0100
Subject: [PATCH] make it work
Signed-off-by: Jens Langhammer
---
authentik/api/authentication.py | 12 ++++
authentik/core/expression/evaluator.py | 2 +-
authentik/lib/expression/evaluator.py | 71 ++++++++++++++-----
authentik/policies/expression/api.py | 4 +-
authentik/policies/expression/apps.py | 5 +-
authentik/policies/expression/evaluator.py | 7 +-
.../0005_expressionpolicy_execution_user.py | 26 +++++++
authentik/policies/expression/models.py | 11 ++-
authentik/policies/expression/signals.py | 13 ++++
blueprints/schema.json | 4 ++
schema.yml | 13 ++++
.../expression/ExpressionPolicyForm.ts | 41 ++++++++++-
web/src/common/global.ts | 2 +-
13 files changed, 179 insertions(+), 32 deletions(-)
create mode 100644 authentik/policies/expression/migrations/0005_expressionpolicy_execution_user.py
create mode 100644 authentik/policies/expression/signals.py
diff --git a/authentik/api/authentication.py b/authentik/api/authentication.py
index ab4b67d731..d12f745648 100644
--- a/authentik/api/authentication.py
+++ b/authentik/api/authentication.py
@@ -73,6 +73,11 @@ def auth_user_lookup(raw_header: bytes) -> User | None:
if user:
CTX_AUTH_VIA.set("secret_key")
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")
@@ -90,6 +95,13 @@ def token_secret_key(value: str) -> User | None:
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):
"""Token-based authentication using HTTP Bearer authentication"""
diff --git a/authentik/core/expression/evaluator.py b/authentik/core/expression/evaluator.py
index a8d365a1c0..e6035378eb 100644
--- a/authentik/core/expression/evaluator.py
+++ b/authentik/core/expression/evaluator.py
@@ -36,7 +36,7 @@ class PropertyMappingEvaluator(BaseEvaluator):
_filename = model.name
else:
_filename = str(model)
- super().__init__(filename=_filename)
+ super().__init__(user=user, filename=_filename)
req = PolicyRequest(user=User())
req.obj = model
if user:
diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py
index 02b89faf9f..ada3197df6 100644
--- a/authentik/lib/expression/evaluator.py
+++ b/authentik/lib/expression/evaluator.py
@@ -3,6 +3,8 @@
import re
import socket
from collections.abc import Iterable
+from datetime import timedelta
+from functools import lru_cache
from ipaddress import ip_address, ip_network
from pathlib import Path
from tempfile import gettempdir
@@ -34,7 +36,9 @@ from authentik_client.configuration import Configuration
from cachetools import TLRUCache, cached
from django.conf import settings
from django.core.exceptions import FieldError
+from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user
+from jwt import PyJWTError, decode, encode
from rest_framework.serializers import ValidationError
from RestrictedPython import compile_restricted, limited_builtins, safe_builtins, utility_builtins
from sentry_sdk.hub import Hub
@@ -42,14 +46,12 @@ from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger
from authentik.core.models import (
- USER_ATTRIBUTE_CHANGE_EMAIL,
- USER_ATTRIBUTE_CHANGE_NAME,
- USER_ATTRIBUTE_CHANGE_USERNAME,
User,
)
from authentik.events.models import Event
from authentik.lib.config import CONFIG
-from authentik.lib.utils.http import get_http_session
+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.process import PolicyProcess
@@ -82,6 +84,23 @@ API_CLIENTS = {
"TenantsApi": TenantsApi,
}
+JWT_AUD = "goauthentik.io/api/expression"
+
+
+@lru_cache
+def get_api_token_secret():
+ return "foo"
+
+
+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(uuid=jwt["sub"]).first()
+ except PyJWTError as exc:
+ LOGGER.debug("failed to auth", exc=exc)
+ return None
+
class BaseEvaluator:
"""Validate and evaluate python-based expressions"""
@@ -94,8 +113,14 @@ class BaseEvaluator:
# Filename used for exec
_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._user = user
# update website/docs/expressions/_objects.md
# update website/docs/expressions/_functions.md
self._globals = {
@@ -113,12 +138,6 @@ class BaseEvaluator:
"requests": get_http_session(),
"resolve_dns": BaseEvaluator.expr_resolve_dns,
"reverse_dns": BaseEvaluator.expr_reverse_dns,
- # Temporary addition of config until #7590 is through and this is not needed anymore
- "CONFIG": CONFIG,
- "USER_ATTRIBUTE_CHANGE_EMAIL": USER_ATTRIBUTE_CHANGE_EMAIL,
- "USER_ATTRIBUTE_CHANGE_NAME": USER_ATTRIBUTE_CHANGE_NAME,
- "USER_ATTRIBUTE_CHANGE_USERNAME": USER_ATTRIBUTE_CHANGE_USERNAME,
- "api": self.get_api_client(),
}
for app in get_apps():
# Load models from each app
@@ -127,18 +146,34 @@ class BaseEvaluator:
self._globals.update(API_CLIENTS)
self._context = {}
+ def get_token(self) -> str:
+ """Generate API token to be used by the API Client"""
+ _now = now()
+ return encode(
+ {
+ "aud": JWT_AUD,
+ "iss": f"goauthentik.io/expression/{self._filename}",
+ "sub": str(self._user.uuid),
+ "iat": int(_now.timestamp()),
+ "exp": int((_now + timedelta(seconds=self.timeout)).timestamp()),
+ },
+ get_api_token_secret(),
+ )
+
def get_api_client(self):
- token = ""
+ token = self.get_token()
config = Configuration(
- f"unix://{str(_tmp.joinpath('authentik-core.sock'))}",
+ f"unix://{str(_tmp.joinpath('authentik-core.sock'))}/api/v3",
api_key={
"authentik": token,
},
- api_key_prefix={"authentik": "Bearer "},
+ api_key_prefix={"authentik": "Bearer"},
)
if settings.DEBUG:
- config.host = "http://localhost:8000"
- return ApiClient(config)
+ 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))
@staticmethod
@@ -296,6 +331,9 @@ class BaseEvaluator:
**utility_builtins,
}
_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
# available here, and these policies can only be edited by admins, this is a risk
# we're willing to take.
@@ -303,6 +341,7 @@ class BaseEvaluator:
exec(ast_obj, self._globals, _locals) # nosec # noqa
result = _locals["result"]
except Exception as exc:
+ print(exception_to_string(exc))
# So, this is a bit questionable. Essentially, we are edit the stacktrace
# so the user only sees information relevant to them
# and none of our surrounding error handling
diff --git a/authentik/policies/expression/api.py b/authentik/policies/expression/api.py
index 6e970eb39d..b932aaaa38 100644
--- a/authentik/policies/expression/api.py
+++ b/authentik/policies/expression/api.py
@@ -14,12 +14,12 @@ class ExpressionPolicySerializer(PolicySerializer):
def validate_expression(self, expr: str) -> str:
"""validate the syntax of the expression"""
name = "temp-policy" if not self.instance else self.instance.name
- PolicyEvaluator(name).validate(expr)
+ PolicyEvaluator(self.context["request"].user, name).validate(expr)
return expr
class Meta:
model = ExpressionPolicy
- fields = PolicySerializer.Meta.fields + ["expression"]
+ fields = PolicySerializer.Meta.fields + ["expression", "execution_user"]
class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet):
diff --git a/authentik/policies/expression/apps.py b/authentik/policies/expression/apps.py
index de7df61f90..44a614841a 100644
--- a/authentik/policies/expression/apps.py
+++ b/authentik/policies/expression/apps.py
@@ -1,11 +1,12 @@
"""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"""
name = "authentik.policies.expression"
label = "authentik_policies_expression"
verbose_name = "authentik Policies.Expression"
+ default = True
diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py
index 536b2634d8..96c4c64352 100644
--- a/authentik/policies/expression/evaluator.py
+++ b/authentik/policies/expression/evaluator.py
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
from django.http import HttpRequest
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.policies.exceptions import PolicyException
@@ -24,8 +25,8 @@ class PolicyEvaluator(BaseEvaluator):
policy: Optional["ExpressionPolicy"] = None
- def __init__(self, policy_name: str | None = None):
- super().__init__(policy_name or "PolicyEvaluator")
+ def __init__(self, user: User, policy_name: str | None = None):
+ super().__init__(user, policy_name or "PolicyEvaluator")
self._messages = []
# update website/docs/expressions/_objects.md
# update website/docs/expressions/_functions.md
@@ -44,6 +45,8 @@ class PolicyEvaluator(BaseEvaluator):
if request.http_request:
self.set_http_request(request.http_request)
self._context["request"] = request
+ if not self._user:
+ self._user = request.user
self._context["context"] = request.context
def set_http_request(self, request: HttpRequest):
diff --git a/authentik/policies/expression/migrations/0005_expressionpolicy_execution_user.py b/authentik/policies/expression/migrations/0005_expressionpolicy_execution_user.py
new file mode 100644
index 0000000000..60d52bb6e1
--- /dev/null
+++ b/authentik/policies/expression/migrations/0005_expressionpolicy_execution_user.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/authentik/policies/expression/models.py b/authentik/policies/expression/models.py
index e70f780562..b5c844f549 100644
--- a/authentik/policies/expression/models.py
+++ b/authentik/policies/expression/models.py
@@ -12,6 +12,9 @@ from authentik.policies.types import PolicyRequest, PolicyResult
class ExpressionPolicy(Policy):
"""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()
@property
@@ -26,17 +29,11 @@ class ExpressionPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult:
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
- evaluator = PolicyEvaluator(self.name)
+ evaluator = PolicyEvaluator(self.execution_user, self.name)
evaluator.policy = self
evaluator.set_policy_request(request)
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):
verbose_name = _("Expression Policy")
verbose_name_plural = _("Expression Policies")
diff --git a/authentik/policies/expression/signals.py b/authentik/policies/expression/signals.py
new file mode 100644
index 0000000000..8401824edd
--- /dev/null
+++ b/authentik/policies/expression/signals.py
@@ -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)
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 3b16f142a4..2727671ea0 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -3477,6 +3477,10 @@
"type": "string",
"minLength": 1,
"title": "Expression"
+ },
+ "execution_user": {
+ "type": "integer",
+ "title": "Execution user"
}
},
"required": []
diff --git a/schema.yml b/schema.yml
index 50a6c85ca1..82100a92fb 100644
--- a/schema.yml
+++ b/schema.yml
@@ -11852,6 +11852,10 @@ paths:
name: execution_logging
schema:
type: boolean
+ - in: query
+ name: execution_user
+ schema:
+ type: integer
- in: query
name: expression
schema:
@@ -33565,6 +33569,9 @@ components:
readOnly: true
expression:
type: string
+ execution_user:
+ type: integer
+ nullable: true
required:
- bound_to
- component
@@ -33588,6 +33595,9 @@ components:
expression:
type: string
minLength: 1
+ execution_user:
+ type: integer
+ nullable: true
required:
- expression
- name
@@ -38841,6 +38851,9 @@ components:
expression:
type: string
minLength: 1
+ execution_user:
+ type: integer
+ nullable: true
PatchedFlowRequest:
type: object
description: Flow Serializer
diff --git a/web/src/admin/policies/expression/ExpressionPolicyForm.ts b/web/src/admin/policies/expression/ExpressionPolicyForm.ts
index 01c6039147..29cfb58865 100644
--- a/web/src/admin/policies/expression/ExpressionPolicyForm.ts
+++ b/web/src/admin/policies/expression/ExpressionPolicyForm.ts
@@ -12,7 +12,13 @@ import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.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")
export class ExpressionPolicyForm extends BasePolicyForm {
@@ -92,6 +98,39 @@ export class ExpressionPolicyForm extends BasePolicyForm {
+
+ => {
+ 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
+ >
+
+
+ ${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.",
+ )}
+
+
`;
}
diff --git a/web/src/common/global.ts b/web/src/common/global.ts
index 990303df0d..b0395943c4 100644
--- a/web/src/common/global.ts
+++ b/web/src/common/global.ts
@@ -44,7 +44,7 @@ export function docLink(path: string): string {
const ak = globalAK();
// Default case or beta build which should always point to latest
if (!ak || ak.build !== "") {
- return `https://goauthentik.io${path}`;
+ return `https://docs.goauthentik.io${path}`;
}
return `https://${ak.versionSubdomain}.goauthentik.io${path}`;
}