diff --git a/.gitignore b/.gitignore
index 17f1a196d..856d52fd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -194,6 +194,7 @@ pip-selfcheck.json
# End of https://www.gitignore.io/api/python,django
/static/
local.env.yml
+/variables/
media/
*mmdb
diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml
index 793bece13..55fd3debe 100644
--- a/authentik/lib/default.yml
+++ b/authentik/lib/default.yml
@@ -106,6 +106,7 @@ default_token_length: 60
impersonation: true
blueprints_dir: /blueprints
+variables_discovery_dir: /data/variables
web:
# No default here as it's set dynamically
diff --git a/authentik/policies/expression/api.py b/authentik/policies/expression/api.py
index c587f1b15..fe10a45e7 100644
--- a/authentik/policies/expression/api.py
+++ b/authentik/policies/expression/api.py
@@ -1,10 +1,32 @@
"""Expression Policy API"""
+from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.expression.evaluator import PolicyEvaluator
-from authentik.policies.expression.models import ExpressionPolicy
+from authentik.policies.expression.models import ExpressionPolicy, ExpressionVariable
+
+
+class ExpressionVariableSerializer(ModelSerializer):
+ """Expression Variable Serializer"""
+
+ class Meta:
+ model = ExpressionVariable
+ fields = "__all__"
+ extra_kwargs = {
+ "managed": {"read_only": True},
+ }
+
+
+class ExpressionVariableViewSet(UsedByMixin, ModelViewSet):
+ """Expression Variable Viewset"""
+
+ queryset = ExpressionVariable.objects.all()
+ serializer_class = ExpressionVariableSerializer
+ filterset_fields = "__all__"
+ ordering = ["name"]
+ search_fields = ["name"]
class ExpressionPolicySerializer(PolicySerializer):
@@ -18,7 +40,7 @@ class ExpressionPolicySerializer(PolicySerializer):
class Meta:
model = ExpressionPolicy
- fields = PolicySerializer.Meta.fields + ["expression"]
+ fields = PolicySerializer.Meta.fields + ["expression", "variables"]
class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet):
diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py
index 7617efdb3..bed0b0dea 100644
--- a/authentik/policies/expression/evaluator.py
+++ b/authentik/policies/expression/evaluator.py
@@ -13,7 +13,7 @@ from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
if TYPE_CHECKING:
- from authentik.policies.expression.models import ExpressionPolicy
+ from authentik.policies.expression.models import ExpressionPolicy, ExpressionVariable
class PolicyEvaluator(BaseEvaluator):
@@ -30,6 +30,7 @@ class PolicyEvaluator(BaseEvaluator):
# 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_variables"] = {}
def expr_func_message(self, message: str):
"""Wrapper to append to messages list, which is returned with PolicyResult"""
@@ -52,6 +53,12 @@ class PolicyEvaluator(BaseEvaluator):
self._context["ak_client_ip"] = ip_address(get_client_ip(request))
self._context["http_request"] = request
+ def set_variables(self, variables: list["ExpressionVariable"]):
+ """Update context base on expression policy variables"""
+ for variable in variables:
+ variable.reload()
+ self._context["ak_variables"][variable.name] = variable.value
+
def handle_error(self, exc: Exception, expression_source: str):
"""Exception Handler"""
raise PolicyException(exc)
diff --git a/authentik/policies/expression/migrations/0005_expressionvariable_expressionpolicy_variables.py b/authentik/policies/expression/migrations/0005_expressionvariable_expressionpolicy_variables.py
new file mode 100644
index 000000000..7328444a9
--- /dev/null
+++ b/authentik/policies/expression/migrations/0005_expressionvariable_expressionpolicy_variables.py
@@ -0,0 +1,48 @@
+# Generated by Django 4.2.5 on 2023-09-29 00:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("authentik_policies_expression", "0004_expressionpolicy_authentik_p_policy__fb6feb_idx"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ExpressionVariable",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("last_updated", models.DateTimeField(auto_now=True)),
+ (
+ "managed",
+ models.TextField(
+ default=None,
+ help_text="Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
+ null=True,
+ unique=True,
+ verbose_name="Managed by authentik",
+ ),
+ ),
+ ("name", models.TextField(unique=True)),
+ ("value", models.TextField()),
+ ],
+ options={
+ "verbose_name": "Expression Variable",
+ "verbose_name_plural": "Expression Variables",
+ },
+ ),
+ migrations.AddField(
+ model_name="expressionpolicy",
+ name="variables",
+ field=models.ManyToManyField(
+ blank=True, to="authentik_policies_expression.expressionvariable"
+ ),
+ ),
+ ]
diff --git a/authentik/policies/expression/models.py b/authentik/policies/expression/models.py
index c1b2c2062..d1eb8bdf1 100644
--- a/authentik/policies/expression/models.py
+++ b/authentik/policies/expression/models.py
@@ -1,18 +1,66 @@
"""authentik expression Policy Models"""
+from pathlib import Path
+
from django.db import models
from django.utils.translation import gettext as _
from rest_framework.serializers import BaseSerializer
+from structlog.stdlib import get_logger
+from authentik.blueprints.models import ManagedModel
+from authentik.lib.config import CONFIG
+from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.policies.expression.evaluator import PolicyEvaluator
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
+LOGGER = get_logger()
+
+MANAGED_DISCOVERED = "goauthentik.io/variables/discovered/%s"
+
+
+class ExpressionVariable(SerializerModel, ManagedModel, CreatedUpdatedModel):
+ """Variable that can be given to expression policies"""
+
+ name = models.TextField(unique=True)
+ value = models.TextField()
+
+ @property
+ def serializer(self) -> type[BaseSerializer]:
+ from authentik.policies.expression.api import ExpressionVariableSerializer
+
+ return ExpressionVariableSerializer
+
+ def reload(self):
+ """Reload a variable from disk if it's managed"""
+ if self.managed != MANAGED_DISCOVERED % self.name:
+ return
+ path = Path(CONFIG.get("variables_discovery_dir")) / Path(self.name)
+ try:
+ with open(path, "r", encoding="utf-8") as _file:
+ body = _file.read()
+ if body != self.value:
+ self.value = body
+ self.save()
+ except (OSError, ValueError) as exc:
+ LOGGER.warning(
+ "Failed to reload variable, continuing anyway",
+ exc=exc,
+ file=path,
+ variable=self.name,
+ )
+
+ class Meta:
+ verbose_name = _("Expression Variable")
+ verbose_name_plural = _("Expression Variables")
+
class ExpressionPolicy(Policy):
"""Execute arbitrary Python code to implement custom checks and validation."""
expression = models.TextField()
+ variables = models.ManyToManyField(ExpressionVariable, blank=True)
+
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.policies.expression.api import ExpressionPolicySerializer
@@ -28,6 +76,7 @@ class ExpressionPolicy(Policy):
evaluator = PolicyEvaluator(self.name)
evaluator.policy = self
evaluator.set_policy_request(request)
+ evaluator.set_variables(self.variables)
return evaluator.evaluate(self.expression)
def save(self, *args, **kwargs):
diff --git a/authentik/policies/expression/tasks.py b/authentik/policies/expression/tasks.py
new file mode 100644
index 000000000..e12d708d0
--- /dev/null
+++ b/authentik/policies/expression/tasks.py
@@ -0,0 +1,86 @@
+"""Expression tasks"""
+from glob import glob
+from pathlib import Path
+
+from django.utils.translation import gettext_lazy as _
+from structlog.stdlib import get_logger
+from watchdog.events import (
+ FileCreatedEvent,
+ FileModifiedEvent,
+ FileSystemEvent,
+ FileSystemEventHandler,
+)
+from watchdog.observers import Observer
+
+from authentik.events.monitored_tasks import (
+ MonitoredTask,
+ TaskResult,
+ TaskResultStatus,
+ prefill_task,
+)
+from authentik.lib.config import CONFIG
+from authentik.policies.expression.models import MANAGED_DISCOVERED, ExpressionVariable
+from authentik.root.celery import CELERY_APP
+
+LOGGER = get_logger()
+_file_watcher_started = False
+
+
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+@prefill_task
+def variable_discovery(self: MonitoredTask):
+ """Discover, import and update variables from the filesystem"""
+ variables = {}
+ discovered = 0
+ base_path = Path(CONFIG.get("variables_discovery_dir")).absolute()
+ for file in glob(str(base_path) + "/**", recursive=True):
+ path = Path(file)
+ if not path.exists():
+ continue
+ if path.is_dir():
+ continue
+ try:
+ with open(path, "r", encoding="utf-8") as _file:
+ body = _file.read()
+ variables[str(path.relative_to(base_path))] = body
+ discovered += 1
+ except (OSError, ValueError) as exc:
+ LOGGER.warning("Failed to open file", exc=exc, file=path)
+ for name, value in variables.items():
+ variable = ExpressionVariable.objects.filter(managed=MANAGED_DISCOVERED % name).first()
+ if not variable:
+ variable = ExpressionVariable(name=name, managed=MANAGED_DISCOVERED % name)
+ if variable.value != value:
+ variable.value = value
+ variable.save()
+ self.set_status(
+ TaskResult(
+ TaskResultStatus.SUCCESSFUL,
+ messages=[_("Successfully imported %(count)d files." % {"count": discovered})],
+ )
+ )
+
+
+class VariableEventHandler(FileSystemEventHandler):
+ """Event handler for variable events"""
+
+ def on_any_event(self, event: FileSystemEvent):
+ if not isinstance(event, (FileCreatedEvent, FileModifiedEvent)):
+ return
+ if event.is_directory:
+ return
+ LOGGER.debug("variable file changed, starting discovery", file=event.src_path)
+ variable_discovery.delay()
+
+
+def start_variables_watcher():
+ """Start variables watcher, if it's not running already."""
+ # This function might be called twice since it's called on celery startup
+ # pylint: disable=global-statement
+ global _file_watcher_started
+ if _file_watcher_started:
+ return
+ observer = Observer()
+ observer.schedule(VariableEventHandler(), CONFIG.get("variables_discovery_dir"), recursive=True)
+ observer.start()
+ _file_watcher_started = True
diff --git a/authentik/policies/expression/urls.py b/authentik/policies/expression/urls.py
index ad554fea9..5e0c3a3c3 100644
--- a/authentik/policies/expression/urls.py
+++ b/authentik/policies/expression/urls.py
@@ -1,4 +1,7 @@
"""API URLs"""
-from authentik.policies.expression.api import ExpressionPolicyViewSet
+from authentik.policies.expression.api import ExpressionPolicyViewSet, ExpressionVariableViewSet
-api_urlpatterns = [("policies/expression", ExpressionPolicyViewSet)]
+api_urlpatterns = [
+ ("policies/expression/variables", ExpressionVariableViewSet),
+ ("policies/expression", ExpressionPolicyViewSet),
+]
diff --git a/authentik/root/celery.py b/authentik/root/celery.py
index 2747bae45..65d7126ec 100644
--- a/authentik/root/celery.py
+++ b/authentik/root/celery.py
@@ -105,8 +105,10 @@ def worker_ready_hook(*args, **kwargs):
except ProgrammingError as exc:
LOGGER.warning("Startup task failed", task=task, exc=exc)
from authentik.blueprints.v1.tasks import start_blueprint_watcher
+ from authentik.policies.expression.tasks import start_variables_watcher
start_blueprint_watcher()
+ start_variables_watcher()
class LivenessProbe(bootsteps.StartStopStep):
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 2ddec653d..ad4515d52 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -559,6 +559,43 @@
}
}
},
+ {
+ "type": "object",
+ "required": [
+ "model",
+ "identifiers"
+ ],
+ "properties": {
+ "model": {
+ "const": "authentik_policies_expression.expressionvariable"
+ },
+ "id": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string",
+ "enum": [
+ "absent",
+ "present",
+ "created",
+ "must_created"
+ ],
+ "default": "present"
+ },
+ "conditions": {
+ "type": "array",
+ "items": {
+ "type": "boolean"
+ }
+ },
+ "attrs": {
+ "$ref": "#/$defs/model_authentik_policies_expression.expressionvariable"
+ },
+ "identifiers": {
+ "$ref": "#/$defs/model_authentik_policies_expression.expressionvariable"
+ }
+ }
+ },
{
"type": "object",
"required": [
@@ -3426,6 +3463,7 @@
"authentik_policies_dummy.dummypolicy",
"authentik_policies_event_matcher.eventmatcherpolicy",
"authentik_policies_expiry.passwordexpirypolicy",
+ "authentik_policies_expression.expressionvariable",
"authentik_policies_expression.expressionpolicy",
"authentik_policies_password.passwordpolicy",
"authentik_policies_reputation.reputationpolicy",
@@ -3517,6 +3555,22 @@
},
"required": []
},
+ "model_authentik_policies_expression.expressionvariable": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Name"
+ },
+ "value": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Value"
+ }
+ },
+ "required": []
+ },
"model_authentik_policies_expression.expressionpolicy": {
"type": "object",
"properties": {
@@ -3534,6 +3588,13 @@
"type": "string",
"minLength": 1,
"title": "Expression"
+ },
+ "variables": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "title": "Variables"
}
},
"required": []
diff --git a/docker-compose.yml b/docker-compose.yml
index 8cbf644d5..9131c6b8d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -74,6 +74,7 @@ services:
- ./media:/media
- ./certs:/certs
- ./custom-templates:/templates
+ - ./data:/data
env_file:
- .env
depends_on:
diff --git a/lifecycle/ak b/lifecycle/ak
index 2ea6a4f59..777eb7649 100755
--- a/lifecycle/ak
+++ b/lifecycle/ak
@@ -1,4 +1,5 @@
-#!/bin/bash -e
+#!/usr/bin/env bash
+set -e
MODE_FILE="${TMPDIR}/authentik-mode"
function log {
diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po
index db2b66eb5..a7afcbf0a 100644
--- a/locale/en/LC_MESSAGES/django.po
+++ b/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-09-15 09:51+0000\n"
+"POT-Creation-Date: 2023-09-29 00:26+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME
+ ${msg( + "Select variables that will be made available to this expression.", + )} +
++ ${msg("Hold control/command to select multiple items.")} +
+