From 5945b362006094ef15dc8e83e372220252d82d21 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 29 Sep 2023 02:15:23 +0200 Subject: [PATCH] policies/expression: add support for dynamic variables Signed-off-by: Marc 'risson' Schmitt --- .gitignore | 1 + authentik/lib/default.yml | 1 + authentik/policies/expression/api.py | 26 +- authentik/policies/expression/evaluator.py | 9 +- ...sionvariable_expressionpolicy_variables.py | 48 +++ authentik/policies/expression/models.py | 49 +++ authentik/policies/expression/tasks.py | 86 ++++ authentik/policies/expression/urls.py | 7 +- authentik/root/celery.py | 2 + blueprints/schema.json | 61 +++ docker-compose.yml | 1 + lifecycle/ak | 3 +- locale/en/LC_MESSAGES/django.po | 15 +- schema.yml | 377 ++++++++++++++++++ scripts/generate_config.py | 1 + web/src/admin/AdminInterface.ts | 1 + web/src/admin/Routes.ts | 4 + .../expression/ExpressionPolicyForm.ts | 39 +- .../expression/ExpressionVariableForm.ts | 62 +++ .../expression/ExpressionVariableListPage.ts | 106 +++++ web/xliff/de.xlf | 30 ++ web/xliff/en.xlf | 30 ++ web/xliff/es.xlf | 30 ++ web/xliff/fr_FR.xlf | 30 ++ web/xliff/pl.xlf | 30 ++ web/xliff/pseudo-LOCALE.xlf | 30 ++ web/xliff/tr.xlf | 30 ++ web/xliff/zh-Hans.xlf | 76 ++-- web/xliff/zh-Hant.xlf | 30 ++ web/xliff/zh_TW.xlf | 30 ++ website/docs/policies/expression.mdx | 35 ++ 31 files changed, 1247 insertions(+), 33 deletions(-) create mode 100644 authentik/policies/expression/migrations/0005_expressionvariable_expressionpolicy_variables.py create mode 100644 authentik/policies/expression/tasks.py create mode 100644 web/src/admin/policies/expression/ExpressionVariableForm.ts create mode 100644 web/src/admin/policies/expression/ExpressionVariableListPage.ts 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 \n" "Language-Team: LANGUAGE \n" @@ -70,6 +70,7 @@ msgid "authentik Export - %(date)s" msgstr "" #: authentik/blueprints/v1/tasks.py:150 authentik/crypto/tasks.py:93 +#: authentik/policies/expression/tasks.py:59 #, python-format msgid "Successfully imported %(count)d files." msgstr "" @@ -724,11 +725,19 @@ msgstr "" msgid "Password Expiry Policies" msgstr "" -#: authentik/policies/expression/models.py:40 +#: authentik/policies/expression/models.py:53 +msgid "Expression Variable" +msgstr "" + +#: authentik/policies/expression/models.py:54 +msgid "Expression Variables" +msgstr "" + +#: authentik/policies/expression/models.py:89 msgid "Expression Policy" msgstr "" -#: authentik/policies/expression/models.py:41 +#: authentik/policies/expression/models.py:90 msgid "Expression Policies" msgstr "" diff --git a/schema.yml b/schema.yml index 616397b3e..c809d43b1 100644 --- a/schema.yml +++ b/schema.yml @@ -11748,6 +11748,14 @@ paths: description: A search term. schema: type: string + - in: query + name: variables + schema: + type: array + items: + type: integer + explode: true + style: form tags: - policies security: @@ -11984,6 +11992,288 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /policies/expression/variables/: + get: + operationId: policies_expression_variables_list + description: Expression Variable Viewset + parameters: + - in: query + name: created + schema: + type: string + format: date-time + - in: query + name: last_updated + schema: + type: string + format: date-time + - in: query + name: managed + schema: + type: string + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: value + schema: + type: string + tags: + - policies + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedExpressionVariableList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: policies_expression_variables_create + description: Expression Variable Viewset + tags: + - policies + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExpressionVariableRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/ExpressionVariable' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /policies/expression/variables/{id}/: + get: + operationId: policies_expression_variables_retrieve + description: Expression Variable Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Expression Variable. + required: true + tags: + - policies + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ExpressionVariable' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: policies_expression_variables_update + description: Expression Variable Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Expression Variable. + required: true + tags: + - policies + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExpressionVariableRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ExpressionVariable' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: policies_expression_variables_partial_update + description: Expression Variable Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Expression Variable. + required: true + tags: + - policies + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedExpressionVariableRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ExpressionVariable' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: policies_expression_variables_destroy + description: Expression Variable Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Expression Variable. + required: true + tags: + - policies + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /policies/expression/variables/{id}/used_by/: + get: + operationId: policies_expression_variables_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Expression Variable. + required: true + tags: + - policies + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /policies/password/: get: operationId: policies_password_list @@ -29556,6 +29846,7 @@ components: * `authentik_policies_dummy.dummypolicy` - Dummy Policy * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy + * `authentik_policies_expression.expressionvariable` - Expression Variable * `authentik_policies_expression.expressionpolicy` - Expression Policy * `authentik_policies_password.passwordpolicy` - Password Policy * `authentik_policies_reputation.reputationpolicy` - Reputation Policy @@ -29749,6 +30040,7 @@ components: * `authentik_policies_dummy.dummypolicy` - Dummy Policy * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy + * `authentik_policies_expression.expressionvariable` - Expression Variable * `authentik_policies_expression.expressionpolicy` - Expression Policy * `authentik_policies_password.passwordpolicy` - Password Policy * `authentik_policies_reputation.reputationpolicy` - Reputation Policy @@ -29918,6 +30210,10 @@ components: readOnly: true expression: type: string + variables: + type: array + items: + type: integer required: - bound_to - component @@ -29941,9 +30237,61 @@ components: expression: type: string minLength: 1 + variables: + type: array + items: + type: integer required: - expression - name + ExpressionVariable: + type: object + description: Expression Variable Serializer + properties: + id: + type: integer + readOnly: true + created: + type: string + format: date-time + readOnly: true + last_updated: + type: string + format: date-time + readOnly: true + managed: + type: string + readOnly: true + nullable: true + title: Managed by authentik + description: 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. + name: + type: string + value: + type: string + required: + - created + - id + - last_updated + - managed + - name + - value + ExpressionVariableRequest: + type: object + description: Expression Variable Serializer + properties: + name: + type: string + minLength: 1 + value: + type: string + minLength: 1 + required: + - name + - value FilePathRequest: type: object description: Serializer to upload file @@ -31909,6 +32257,7 @@ components: - 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 @@ -31983,6 +32332,7 @@ components: * `authentik_policies_dummy.dummypolicy` - Dummy Policy * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy + * `authentik_policies_expression.expressionvariable` - Expression Variable * `authentik_policies_expression.expressionpolicy` - Expression Policy * `authentik_policies_password.passwordpolicy` - Password Policy * `authentik_policies_reputation.reputationpolicy` - Reputation Policy @@ -33288,6 +33638,18 @@ components: required: - pagination - results + PaginatedExpressionVariableList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/ExpressionVariable' + required: + - pagination + - results PaginatedFlowList: type: object properties: @@ -34973,6 +35335,7 @@ components: * `authentik_policies_dummy.dummypolicy` - Dummy Policy * `authentik_policies_event_matcher.eventmatcherpolicy` - Event Matcher Policy * `authentik_policies_expiry.passwordexpirypolicy` - Password Expiry Policy + * `authentik_policies_expression.expressionvariable` - Expression Variable * `authentik_policies_expression.expressionpolicy` - Expression Policy * `authentik_policies_password.passwordpolicy` - Password Policy * `authentik_policies_reputation.reputationpolicy` - Reputation Policy @@ -35070,6 +35433,20 @@ components: expression: type: string minLength: 1 + variables: + type: array + items: + type: integer + PatchedExpressionVariableRequest: + type: object + description: Expression Variable Serializer + properties: + name: + type: string + minLength: 1 + value: + type: string + minLength: 1 PatchedFlowRequest: type: object description: Flow Serializer diff --git a/scripts/generate_config.py b/scripts/generate_config.py index 187eb3ba5..6bbf05810 100644 --- a/scripts/generate_config.py +++ b/scripts/generate_config.py @@ -17,6 +17,7 @@ with open("local.env.yml", "w", encoding="utf-8") as _config: }, "blueprints_dir": "./blueprints", "cert_discovery_dir": "./certs", + "variables_discovery_dir": "./variables", "geoip": "tests/GeoLite2-City-Test.mmdb", }, _config, diff --git a/web/src/admin/AdminInterface.ts b/web/src/admin/AdminInterface.ts index fa6d4efa5..e40a708e8 100644 --- a/web/src/admin/AdminInterface.ts +++ b/web/src/admin/AdminInterface.ts @@ -201,6 +201,7 @@ export class AdminInterface extends Interface { ["/events/transports", msg("Notification Transports")]]], [null, msg("Customisation"), null, [ ["/policy/policies", msg("Policies")], + ["/policy/expression/variables", msg("Variables")], ["/core/property-mappings", msg("Property Mappings")], ["/blueprints/instances", msg("Blueprints")], ["/policy/reputation", msg("Reputation scores")]]], diff --git a/web/src/admin/Routes.ts b/web/src/admin/Routes.ts index 55a830835..8ac9fcc6a 100644 --- a/web/src/admin/Routes.ts +++ b/web/src/admin/Routes.ts @@ -60,6 +60,10 @@ export const ROUTES: Route[] = [ await import("@goauthentik/admin/policies/PolicyListPage"); return html``; }), + new Route(new RegExp("^/policy/expression/variables"), async () => { + await import("@goauthentik/admin/policies/expression/ExpressionVariableListPage"); + return html``; + }), new Route(new RegExp("^/policy/reputation$"), async () => { await import("@goauthentik/admin/policies/reputation/ReputationListPage"); return html``; diff --git a/web/src/admin/policies/expression/ExpressionPolicyForm.ts b/web/src/admin/policies/expression/ExpressionPolicyForm.ts index 13687eac9..b45e04aa1 100644 --- a/web/src/admin/policies/expression/ExpressionPolicyForm.ts +++ b/web/src/admin/policies/expression/ExpressionPolicyForm.ts @@ -11,7 +11,7 @@ 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 { ExpressionPolicy, PaginatedExpressionVariableList, PoliciesApi } from "@goauthentik/api"; @customElement("ak-policy-expression-form") export class ExpressionPolicyForm extends ModelForm { @@ -21,6 +21,14 @@ export class ExpressionPolicyForm extends ModelForm { }); } + async load(): Promise { + this.variables = await new PoliciesApi(DEFAULT_CONFIG).policiesExpressionVariablesList({ + ordering: "name", + }); + } + + variables?: PaginatedExpressionVariableList; + getSuccessMessage(): string { if (this.instance) { return msg("Successfully updated policy."); @@ -100,6 +108,35 @@ export class ExpressionPolicyForm extends ModelForm {

+ + +

+ ${msg( + "Select variables that will be made available to this expression.", + )} +

+

+ ${msg("Hold control/command to select multiple items.")} +

+
`; diff --git a/web/src/admin/policies/expression/ExpressionVariableForm.ts b/web/src/admin/policies/expression/ExpressionVariableForm.ts new file mode 100644 index 000000000..6b38a8c95 --- /dev/null +++ b/web/src/admin/policies/expression/ExpressionVariableForm.ts @@ -0,0 +1,62 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { ExpressionVariable, PoliciesApi } from "@goauthentik/api"; + +@customElement("ak-expression-variable-form") +export class ExpressionVariableForm extends ModelForm { + loadInstance(pk: number): Promise { + return new PoliciesApi(DEFAULT_CONFIG).policiesExpressionVariablesRetrieve({ + id: pk, + }); + } + + getSuccessMessage(): string { + if (this.instance) { + return msg("Successfully updated variable."); + } else { + return msg("Successfully created variable."); + } + } + + async send(data: ExpressionVariable): Promise { + if (this.instance) { + return new PoliciesApi(DEFAULT_CONFIG).policiesExpressionVariablesUpdate({ + id: this.instance.id || 0, + expressionVariableRequest: data, + }); + } else { + return new PoliciesApi(DEFAULT_CONFIG).policiesExpressionVariablesCreate({ + expressionVariableRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html`
+ ${msg("Variable that can be passed to an expression policy")} + + + + + + +
`; + } +} diff --git a/web/src/admin/policies/expression/ExpressionVariableListPage.ts b/web/src/admin/policies/expression/ExpressionVariableListPage.ts new file mode 100644 index 000000000..6731687f6 --- /dev/null +++ b/web/src/admin/policies/expression/ExpressionVariableListPage.ts @@ -0,0 +1,106 @@ +import "@goauthentik/admin/policies/expression/ExpressionVariableForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { uiConfig } from "@goauthentik/common/ui/config"; +import "@goauthentik/elements/forms/ConfirmationForm"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +import "@goauthentik/elements/forms/ModalForm"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; +import { TableColumn } from "@goauthentik/elements/table/Table"; +import { TablePage } from "@goauthentik/elements/table/TablePage"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { ExpressionVariable, PoliciesApi } from "@goauthentik/api"; + +@customElement("ak-expression-variable-list") +export class ExpressionVariableListPage extends TablePage { + searchEnabled(): boolean { + return true; + } + pageTitle(): string { + return msg("Variables"); + } + pageDescription(): string { + return msg("Variables that can be passed on to expressions."); + } + pageIcon(): string { + // TODO: ask Jens what to put here + return "pf-icon pf-icon-infrastructure"; + } + + checkbox = true; + + @property() + order = "name"; + + async apiEndpoint(page: number): Promise> { + return new PoliciesApi(DEFAULT_CONFIG).policiesExpressionVariablesList({ + ordering: this.order, + page: page, + pageSize: (await uiConfig()).pagination.perPage, + search: this.search || "", + }); + } + + columns(): TableColumn[] { + return [new TableColumn(msg("Name"), "name"), new TableColumn(msg("Actions"))]; + } + + row(item: ExpressionVariable): TemplateResult[] { + let managedSubText = msg("Managed by authentik"); + if (item.managed && item.managed.startsWith("goauthentik.io/variables/discovered")) { + managedSubText = msg("Managed by authentik (Discovered)"); + } + return [ + html`
${item.name}
+ ${item.managed ? html`${managedSubText}` : html``}`, + html` + ${msg("Update")} + ${msg("Update Variable")} + + + + `, + ]; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return new PoliciesApi(DEFAULT_CONFIG).policiesExpressionVariablesUsedByList({ + id: item.id, + }); + }} + .delete=${(item: ExpressionVariable) => { + return new PoliciesApi(DEFAULT_CONFIG).policiesExpressionVariablesDestroy({ + id: item.id, + }); + }} + > + + `; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${msg("Create")} + ${msg("Create Variable")} + + + + `; + } +} diff --git a/web/xliff/de.xlf b/web/xliff/de.xlf index dbf951574..4983f8869 100644 --- a/web/xliff/de.xlf +++ b/web/xliff/de.xlf @@ -5925,6 +5925,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/web/xliff/en.xlf b/web/xliff/en.xlf index 658eba084..45fcaac5c 100644 --- a/web/xliff/en.xlf +++ b/web/xliff/en.xlf @@ -6239,6 +6239,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/web/xliff/es.xlf b/web/xliff/es.xlf index cc46a33f8..6c99a973f 100644 --- a/web/xliff/es.xlf +++ b/web/xliff/es.xlf @@ -5833,6 +5833,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/web/xliff/fr_FR.xlf b/web/xliff/fr_FR.xlf index 861d7ab89..1f31aa149 100644 --- a/web/xliff/fr_FR.xlf +++ b/web/xliff/fr_FR.xlf @@ -5941,6 +5941,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/web/xliff/pl.xlf b/web/xliff/pl.xlf index 99d097f04..9adc02147 100644 --- a/web/xliff/pl.xlf +++ b/web/xliff/pl.xlf @@ -6072,6 +6072,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/web/xliff/pseudo-LOCALE.xlf b/web/xliff/pseudo-LOCALE.xlf index dd53b9b84..73e0c7222 100644 --- a/web/xliff/pseudo-LOCALE.xlf +++ b/web/xliff/pseudo-LOCALE.xlf @@ -6174,6 +6174,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/web/xliff/tr.xlf b/web/xliff/tr.xlf index 34c746642..54343b7a7 100644 --- a/web/xliff/tr.xlf +++ b/web/xliff/tr.xlf @@ -5826,6 +5826,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/web/xliff/zh-Hans.xlf b/web/xliff/zh-Hans.xlf index 1d2ff7720..4c51567b8 100644 --- a/web/xliff/zh-Hans.xlf +++ b/web/xliff/zh-Hans.xlf @@ -1,4 +1,4 @@ - + @@ -613,9 +613,9 @@ - The URL "" was not found. - 未找到 URL " - "。 + The URL "" was not found. + 未找到 URL " + "。 @@ -1067,8 +1067,8 @@ - To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. - 要允许任何重定向 URI,请将此值设置为 ".*"。请注意这可能带来的安全影响。 + To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have. + 要允许任何重定向 URI,请将此值设置为 ".*"。请注意这可能带来的安全影响。 @@ -1814,8 +1814,8 @@ - Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". - 输入完整 URL、相对路径,或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。 + Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test". + 输入完整 URL、相对路径,或者使用 'fa://fa-test' 来使用 Font Awesome 图标 "fa-test"。 @@ -3238,8 +3238,8 @@ doesn't pass when either or both of the selected options are equal or above the - Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' - 包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...' + Field which contains members of a group. Note that if using the "memberUid" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...' + 包含组成员的字段。请注意,如果使用 "memberUid" 字段,则假定该值包含相对可分辨名称。例如,'memberUid=some-user' 而不是 'memberUid=cn=some-user,ou=groups,...' @@ -4031,8 +4031,8 @@ doesn't pass when either or both of the selected options are equal or above the - When using an external logging solution for archiving, this can be set to "minutes=5". - 使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。 + When using an external logging solution for archiving, this can be set to "minutes=5". + 使用外部日志记录解决方案进行存档时,可以将其设置为 "minutes=5"。 @@ -4041,8 +4041,8 @@ doesn't pass when either or both of the selected options are equal or above the - Format: "weeks=3;days=2;hours=3,seconds=2". - 格式:"weeks=3;days=2;hours=3,seconds=2"。 + Format: "weeks=3;days=2;hours=3,seconds=2". + 格式:"weeks=3;days=2;hours=3,seconds=2"。 @@ -4238,10 +4238,10 @@ doesn't pass when either or both of the selected options are equal or above the - Are you sure you want to update ""? + Are you sure you want to update ""? 您确定要更新 - " - " 吗? + " + " 吗? @@ -5342,7 +5342,7 @@ doesn't pass when either or both of the selected options are equal or above the - A "roaming" authenticator, like a YubiKey + A "roaming" authenticator, like a YubiKey 像 YubiKey 这样的“漫游”身份验证器 @@ -5677,10 +5677,10 @@ doesn't pass when either or both of the selected options are equal or above the - ("", of type ) + ("", of type ) - (" - ",类型为 + (" + ",类型为 @@ -5729,7 +5729,7 @@ doesn't pass when either or both of the selected options are equal or above the - If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. + If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here. 如果设置时长大于 0,用户可以选择“保持登录”选项,这将使用户的会话延长此处设置的时间。 @@ -7818,7 +7818,37 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. 浏览器不支持 WebAuthn。 + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable - \ No newline at end of file + diff --git a/web/xliff/zh-Hant.xlf b/web/xliff/zh-Hant.xlf index 2919040eb..d2ab306b8 100644 --- a/web/xliff/zh-Hant.xlf +++ b/web/xliff/zh-Hant.xlf @@ -5878,6 +5878,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/web/xliff/zh_TW.xlf b/web/xliff/zh_TW.xlf index a4c2ec374..24e4df663 100644 --- a/web/xliff/zh_TW.xlf +++ b/web/xliff/zh_TW.xlf @@ -5877,6 +5877,36 @@ Bindings to groups/users are checked against the user of the event. WebAuthn not supported by browser. + + + Variables + + + Select variables that will be made available to this expression. + + + Successfully updated variable. + + + Successfully created variable. + + + Variable that can be passed to an expression policy + + + Value + + + Variables that can be passed on to expressions. + + + Update Variable + + + Variable / Variables + + + Create Variable diff --git a/website/docs/policies/expression.mdx b/website/docs/policies/expression.mdx index c98803f65..c1290df4c 100644 --- a/website/docs/policies/expression.mdx +++ b/website/docs/policies/expression.mdx @@ -16,6 +16,39 @@ return False to fail it. +## Variables + +You can create variables that will be made available to the policy under an `ak_variables` dictionary. + +Creating a variable named `my_var` with a value `this is the value` and associating this variable to the expression will result in the following `ak_variables` dictionary being available to use in the expression: + +```python +ak_variables["my_var"] # This is equal to `this_is_the_value` +``` + +### External variables + +To use externally managed variables (for instance, to pass a secret as a file to authentik), you can use the discovery feature. + +The docker-compose installation maps `data/variables` directory to `/data/variables`, you can simply use this directory to store your variables. + +For Kubernetes, you can map custom configmaps/secrets/volumes under /data/variables. + +You can also bind mount single files into the folder. + +The name of the variable will be the full path from the `/data/variables/` directory. For instance: + +``` +data/variables/ +├── baz +│ └── bar.baz # The variable will be named `baz/bar.baz` +└── foo.bar # The variable will be named `foo.bar` +``` + +Note that file contents are not stripped, and may contain an extra `\n` at the end. + +External variables are reloaded from disk on every policy execution. If the reload fails, the previous value is used. + ## Available Functions ### `ak_message(message: str)` @@ -65,6 +98,8 @@ import Objects from "../expressions/_objects.md"; See also [Python documentation](https://docs.python.org/3/library/ipaddress.html#ipaddress.ip_address) +- `ak_variables`: dictionary of name, value obtained from the variables bound to the expression policy. + Additionally, when the policy is executed from a flow, every variable from the flow's current context is accessible under the `context` object. This includes the following: