diff --git a/authentik/admin/api/metrics.py b/authentik/admin/api/metrics.py
index 6909fd8bd..0344c21f5 100644
--- a/authentik/admin/api/metrics.py
+++ b/authentik/admin/api/metrics.py
@@ -17,7 +17,7 @@ from rest_framework.response import Response
from rest_framework.serializers import Serializer
from rest_framework.viewsets import ViewSet
-from authentik.audit.models import Event, EventAction
+from authentik.events.models import Event, EventAction
def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]:
diff --git a/authentik/admin/api/version.py b/authentik/admin/api/version.py
index 441b41f6f..607de64b2 100644
--- a/authentik/admin/api/version.py
+++ b/authentik/admin/api/version.py
@@ -51,7 +51,7 @@ class VersionViewSet(ListModelMixin, GenericViewSet):
permission_classes = [IsAdminUser]
- def get_queryset(self):
+ def get_queryset(self): # pragma: no cover
return None
@swagger_auto_schema(responses={200: VersionSerializer(many=True)})
diff --git a/authentik/admin/api/workers.py b/authentik/admin/api/workers.py
index 998ffaa80..6b400b44d 100644
--- a/authentik/admin/api/workers.py
+++ b/authentik/admin/api/workers.py
@@ -15,7 +15,7 @@ class WorkerViewSet(ListModelMixin, GenericViewSet):
serializer_class = Serializer
permission_classes = [IsAdminUser]
- def get_queryset(self):
+ def get_queryset(self): # pragma: no cover
return None
def list(self, request: Request) -> Response:
diff --git a/authentik/admin/tasks.py b/authentik/admin/tasks.py
index 2bff1ecba..a2a889ea2 100644
--- a/authentik/admin/tasks.py
+++ b/authentik/admin/tasks.py
@@ -1,8 +1,11 @@
"""authentik admin tasks"""
from django.core.cache import cache
+from packaging.version import parse
from requests import RequestException, get
from structlog import get_logger
+from authentik import __version__
+from authentik.events.models import Event, EventAction
from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
from authentik.root.celery import CELERY_APP
@@ -19,12 +22,24 @@ def update_latest_version(self: MonitoredTask):
response.raise_for_status()
data = response.json()
tag_name = data.get("tag_name")
- cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], VERSION_CACHE_TIMEOUT)
+ upstream_version = tag_name.split("/")[1]
+ cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT)
self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]
)
)
+ # Check if upstream version is newer than what we're running,
+ # and if no event exists yet, create one.
+ local_version = parse(__version__)
+ if local_version < parse(upstream_version):
+ # Event has already been created, don't create duplicate
+ if Event.objects.filter(
+ action=EventAction.UPDATE_AVAILABLE,
+ context__new_version=upstream_version,
+ ).exists():
+ return
+ Event.new(EventAction.UPDATE_AVAILABLE, new_version=upstream_version).save()
except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
diff --git a/authentik/admin/tests/test_tasks.py b/authentik/admin/tests/test_tasks.py
new file mode 100644
index 000000000..b4e34b8e0
--- /dev/null
+++ b/authentik/admin/tests/test_tasks.py
@@ -0,0 +1,76 @@
+"""test admin tasks"""
+import json
+from dataclasses import dataclass
+from unittest.mock import Mock, patch
+
+from django.core.cache import cache
+from django.test import TestCase
+from requests.exceptions import RequestException
+
+from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
+from authentik.events.models import Event, EventAction
+
+
+@dataclass
+class MockResponse:
+ """Mock class to emulate the methods of requests's Response we need"""
+
+ status_code: int
+ response: str
+
+ def json(self) -> dict:
+ """Get json parsed response"""
+ return json.loads(self.response)
+
+ def raise_for_status(self):
+ """raise RequestException if status code is 400 or more"""
+ if self.status_code >= 400:
+ raise RequestException
+
+
+REQUEST_MOCK_VALID = Mock(
+ return_value=MockResponse(
+ 200,
+ """{
+ "tag_name": "version/1.2.3"
+ }""",
+ )
+)
+
+REQUEST_MOCK_INVALID = Mock(return_value=MockResponse(400, "{}"))
+
+
+class TestAdminTasks(TestCase):
+ """test admin tasks"""
+
+ @patch("authentik.admin.tasks.get", REQUEST_MOCK_VALID)
+ def test_version_valid_response(self):
+ """Test Update checker with valid response"""
+ update_latest_version.delay().get()
+ self.assertEqual(cache.get(VERSION_CACHE_KEY), "1.2.3")
+ self.assertTrue(
+ Event.objects.filter(
+ action=EventAction.UPDATE_AVAILABLE, context__new_version="1.2.3"
+ ).exists()
+ )
+ # test that a consecutive check doesn't create a duplicate event
+ update_latest_version.delay().get()
+ self.assertEqual(
+ len(
+ Event.objects.filter(
+ action=EventAction.UPDATE_AVAILABLE, context__new_version="1.2.3"
+ )
+ ),
+ 1,
+ )
+
+ @patch("authentik.admin.tasks.get", REQUEST_MOCK_INVALID)
+ def test_version_error(self):
+ """Test Update checker with invalid response"""
+ update_latest_version.delay().get()
+ self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
+ self.assertFalse(
+ Event.objects.filter(
+ action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
+ ).exists()
+ )
diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py
index 8e7e636a3..9c2a35496 100644
--- a/authentik/api/v2/urls.py
+++ b/authentik/api/v2/urls.py
@@ -11,7 +11,6 @@ from authentik.admin.api.version import VersionViewSet
from authentik.admin.api.workers import WorkerViewSet
from authentik.api.v2.config import ConfigsViewSet
from authentik.api.v2.messages import MessagesViewSet
-from authentik.audit.api import EventViewSet
from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.groups import GroupViewSet
from authentik.core.api.propertymappings import PropertyMappingViewSet
@@ -20,6 +19,7 @@ from authentik.core.api.sources import SourceViewSet
from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.users import UserViewSet
from authentik.crypto.api import CertificateKeyPairViewSet
+from authentik.events.api import EventViewSet
from authentik.flows.api import (
FlowCacheViewSet,
FlowStageBindingViewSet,
@@ -96,7 +96,7 @@ router.register("flows/bindings", FlowStageBindingViewSet)
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
-router.register("audit/events", EventViewSet)
+router.register("events/events", EventViewSet)
router.register("sources/all", SourceViewSet)
router.register("sources/ldap", LDAPSourceViewSet)
diff --git a/authentik/audit/apps.py b/authentik/audit/apps.py
deleted file mode 100644
index a88e89640..000000000
--- a/authentik/audit/apps.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""authentik audit app"""
-from importlib import import_module
-
-from django.apps import AppConfig
-
-
-class AuthentikAuditConfig(AppConfig):
- """authentik audit app"""
-
- name = "authentik.audit"
- label = "authentik_audit"
- verbose_name = "authentik Audit"
- mountpoint = "audit/"
-
- def ready(self):
- import_module("authentik.audit.signals")
diff --git a/authentik/audit/urls.py b/authentik/audit/urls.py
deleted file mode 100644
index 13fd64dfe..000000000
--- a/authentik/audit/urls.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""authentik audit urls"""
-from django.urls import path
-
-from authentik.audit.views import EventListView
-
-urlpatterns = [
- # Audit Log
- path("audit/", EventListView.as_view(), name="log"),
-]
diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py
index 9f481261a..5136e09b8 100644
--- a/authentik/core/api/applications.py
+++ b/authentik/core/api/applications.py
@@ -12,8 +12,8 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.admin.api.metrics import get_events_per_1h
-from authentik.audit.models import EventAction
from authentik.core.models import Application
+from authentik.events.models import EventAction
from authentik.policies.engine import PolicyEngine
@@ -78,7 +78,7 @@ class ApplicationViewSet(ModelViewSet):
get_objects_for_user(request.user, "authentik_core.view_application"),
slug=slug,
)
- if not request.user.has_perm("authentik_audit.view_event"):
+ if not request.user.has_perm("authentik_events.view_event"):
raise Http404
return Response(
get_events_per_1h(
diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py
index bdaedf915..a2066dae9 100644
--- a/authentik/core/api/tokens.py
+++ b/authentik/core/api/tokens.py
@@ -6,8 +6,8 @@ from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
-from authentik.audit.models import Event, EventAction
from authentik.core.models import Token
+from authentik.events.models import Event, EventAction
class TokenSerializer(ModelSerializer):
diff --git a/authentik/core/expression.py b/authentik/core/expression.py
index 534ba4775..bcec7a227 100644
--- a/authentik/core/expression.py
+++ b/authentik/core/expression.py
@@ -1,9 +1,11 @@
"""Property Mapping Evaluator"""
+from traceback import format_tb
from typing import Optional
from django.http import HttpRequest
from authentik.core.models import User
+from authentik.events.models import Event, EventAction
from authentik.lib.expression.evaluator import BaseEvaluator
@@ -19,3 +21,18 @@ class PropertyMappingEvaluator(BaseEvaluator):
if request:
self._context["request"] = request
self._context.update(**kwargs)
+
+ def handle_error(self, exc: Exception, expression_source: str):
+ """Exception Handler"""
+ error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
+ event = Event.new(
+ EventAction.PROPERTY_MAPPING_EXCEPTION,
+ expression=expression_source,
+ error=error_string,
+ )
+ if "user" in self._context:
+ event.set_user(self._context["user"])
+ if "request" in self._context:
+ event.from_http(self._context["request"])
+ return
+ event.save()
diff --git a/authentik/core/tests/test_property_mapping.py b/authentik/core/tests/test_property_mapping.py
new file mode 100644
index 000000000..d40cdf8d4
--- /dev/null
+++ b/authentik/core/tests/test_property_mapping.py
@@ -0,0 +1,56 @@
+"""authentik core property mapping tests"""
+from django.test import RequestFactory, TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from authentik.core.exceptions import PropertyMappingExpressionException
+from authentik.core.models import PropertyMapping
+from authentik.events.models import Event, EventAction
+
+
+class TestPropertyMappings(TestCase):
+ """authentik core property mapping tests"""
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.factory = RequestFactory()
+
+ def test_expression(self):
+ """Test expression"""
+ mapping = PropertyMapping.objects.create(
+ name="test", expression="return 'test'"
+ )
+ self.assertEqual(mapping.evaluate(None, None), "test")
+
+ def test_expression_syntax(self):
+ """Test expression syntax error"""
+ mapping = PropertyMapping.objects.create(name="test", expression="-")
+ with self.assertRaises(PropertyMappingExpressionException):
+ mapping.evaluate(None, None)
+
+ def test_expression_error_general(self):
+ """Test expression error"""
+ expr = "return aaa"
+ mapping = PropertyMapping.objects.create(name="test", expression=expr)
+ with self.assertRaises(NameError):
+ mapping.evaluate(None, None)
+ events = Event.objects.filter(
+ action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
+ )
+ self.assertTrue(events.exists())
+ self.assertEqual(len(events), 1)
+
+ def test_expression_error_extended(self):
+ """Test expression error (with user and http request"""
+ expr = "return aaa"
+ request = self.factory.get("/")
+ mapping = PropertyMapping.objects.create(name="test", expression=expr)
+ with self.assertRaises(NameError):
+ mapping.evaluate(get_anonymous_user(), request)
+ events = Event.objects.filter(
+ action=EventAction.PROPERTY_MAPPING_EXCEPTION, context__expression=expr
+ )
+ self.assertTrue(events.exists())
+ self.assertEqual(len(events), 1)
+ event = events.first()
+ self.assertEqual(event.user["username"], "AnonymousUser")
+ self.assertEqual(event.client_ip, "127.0.0.1")
diff --git a/authentik/core/views/impersonate.py b/authentik/core/views/impersonate.py
index ef94e607d..85620d1e5 100644
--- a/authentik/core/views/impersonate.py
+++ b/authentik/core/views/impersonate.py
@@ -5,12 +5,12 @@ from django.shortcuts import get_object_or_404, redirect
from django.views import View
from structlog import get_logger
-from authentik.audit.models import Event, EventAction
from authentik.core.middleware import (
SESSION_IMPERSONATE_ORIGINAL_USER,
SESSION_IMPERSONATE_USER,
)
from authentik.core.models import User
+from authentik.events.models import Event, EventAction
LOGGER = get_logger()
diff --git a/authentik/audit/__init__.py b/authentik/events/__init__.py
similarity index 100%
rename from authentik/audit/__init__.py
rename to authentik/events/__init__.py
diff --git a/authentik/audit/api.py b/authentik/events/api.py
similarity index 96%
rename from authentik/audit/api.py
rename to authentik/events/api.py
index c2c165773..906d8d2b5 100644
--- a/authentik/audit/api.py
+++ b/authentik/events/api.py
@@ -1,4 +1,4 @@
-"""Audit API Views"""
+"""Events API Views"""
from django.db.models.aggregates import Count
from django.db.models.fields.json import KeyTextTransform
from drf_yasg2.utils import swagger_auto_schema
@@ -9,7 +9,7 @@ from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ReadOnlyModelViewSet
-from authentik.audit.models import Event, EventAction
+from authentik.events.models import Event, EventAction
class EventSerializer(ModelSerializer):
diff --git a/authentik/events/apps.py b/authentik/events/apps.py
new file mode 100644
index 000000000..5899645df
--- /dev/null
+++ b/authentik/events/apps.py
@@ -0,0 +1,16 @@
+"""authentik events app"""
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class AuthentikEventsConfig(AppConfig):
+ """authentik events app"""
+
+ name = "authentik.events"
+ label = "authentik_events"
+ verbose_name = "authentik Events"
+ mountpoint = "events/"
+
+ def ready(self):
+ import_module("authentik.events.signals")
diff --git a/authentik/audit/middleware.py b/authentik/events/middleware.py
similarity index 94%
rename from authentik/audit/middleware.py
rename to authentik/events/middleware.py
index 7c192a568..4e7cd78b8 100644
--- a/authentik/audit/middleware.py
+++ b/authentik/events/middleware.py
@@ -1,4 +1,4 @@
-"""Audit middleware"""
+"""Events middleware"""
from functools import partial
from typing import Callable
@@ -7,9 +7,10 @@ from django.db.models import Model
from django.db.models.signals import post_save, pre_delete
from django.http import HttpRequest, HttpResponse
-from authentik.audit.models import Event, EventAction, model_to_dict
-from authentik.audit.signals import EventNewThread
from authentik.core.middleware import LOCAL
+from authentik.events.models import Event, EventAction
+from authentik.events.signals import EventNewThread
+from authentik.events.utils import model_to_dict
class AuditMiddleware:
diff --git a/authentik/audit/migrations/0001_initial.py b/authentik/events/migrations/0001_initial.py
similarity index 95%
rename from authentik/audit/migrations/0001_initial.py
rename to authentik/events/migrations/0001_initial.py
index 8d09ae314..93f8d652a 100644
--- a/authentik/audit/migrations/0001_initial.py
+++ b/authentik/events/migrations/0001_initial.py
@@ -63,8 +63,8 @@ class Migration(migrations.Migration):
),
],
options={
- "verbose_name": "Audit Event",
- "verbose_name_plural": "Audit Events",
+ "verbose_name": "Event",
+ "verbose_name_plural": "Events",
},
),
]
diff --git a/authentik/audit/migrations/0002_auto_20200918_2116.py b/authentik/events/migrations/0002_auto_20200918_2116.py
similarity index 95%
rename from authentik/audit/migrations/0002_auto_20200918_2116.py
rename to authentik/events/migrations/0002_auto_20200918_2116.py
index a6fcabf06..869376a90 100644
--- a/authentik/audit/migrations/0002_auto_20200918_2116.py
+++ b/authentik/events/migrations/0002_auto_20200918_2116.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ("authentik_audit", "0001_initial"),
+ ("authentik_events", "0001_initial"),
]
operations = [
diff --git a/authentik/audit/migrations/0003_auto_20200917_1155.py b/authentik/events/migrations/0003_auto_20200917_1155.py
similarity index 89%
rename from authentik/audit/migrations/0003_auto_20200917_1155.py
rename to authentik/events/migrations/0003_auto_20200917_1155.py
index 6163fe305..26d9b9782 100644
--- a/authentik/audit/migrations/0003_auto_20200917_1155.py
+++ b/authentik/events/migrations/0003_auto_20200917_1155.py
@@ -3,11 +3,11 @@ from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
-import authentik.audit.models
+import authentik.events.models
def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
- Event = apps.get_model("authentik_audit", "Event")
+ Event = apps.get_model("authentik_events", "Event")
db_alias = schema_editor.connection.alias
for event in Event.objects.all():
@@ -15,7 +15,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
# Because event objects cannot be updated, we have to re-create them
event.pk = None
event.user_json = (
- authentik.audit.models.get_user(event.user) if event.user else {}
+ authentik.events.models.get_user(event.user) if event.user else {}
)
event._state.adding = True
event.save()
@@ -24,7 +24,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
class Migration(migrations.Migration):
dependencies = [
- ("authentik_audit", "0002_auto_20200918_2116"),
+ ("authentik_events", "0002_auto_20200918_2116"),
]
operations = [
diff --git a/authentik/audit/migrations/0004_auto_20200921_1829.py b/authentik/events/migrations/0004_auto_20200921_1829.py
similarity index 95%
rename from authentik/audit/migrations/0004_auto_20200921_1829.py
rename to authentik/events/migrations/0004_auto_20200921_1829.py
index df4f64ab2..a1733604e 100644
--- a/authentik/audit/migrations/0004_auto_20200921_1829.py
+++ b/authentik/events/migrations/0004_auto_20200921_1829.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ("authentik_audit", "0003_auto_20200917_1155"),
+ ("authentik_events", "0003_auto_20200917_1155"),
]
operations = [
diff --git a/authentik/audit/migrations/0005_auto_20201005_2139.py b/authentik/events/migrations/0005_auto_20201005_2139.py
similarity index 95%
rename from authentik/audit/migrations/0005_auto_20201005_2139.py
rename to authentik/events/migrations/0005_auto_20201005_2139.py
index 3a2881172..fd3ea8fb9 100644
--- a/authentik/audit/migrations/0005_auto_20201005_2139.py
+++ b/authentik/events/migrations/0005_auto_20201005_2139.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ("authentik_audit", "0004_auto_20200921_1829"),
+ ("authentik_events", "0004_auto_20200921_1829"),
]
operations = [
diff --git a/authentik/audit/migrations/0006_auto_20201017_2024.py b/authentik/events/migrations/0006_auto_20201017_2024.py
similarity index 96%
rename from authentik/audit/migrations/0006_auto_20201017_2024.py
rename to authentik/events/migrations/0006_auto_20201017_2024.py
index ec242f6bd..172e9f242 100644
--- a/authentik/audit/migrations/0006_auto_20201017_2024.py
+++ b/authentik/events/migrations/0006_auto_20201017_2024.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ("authentik_audit", "0005_auto_20201005_2139"),
+ ("authentik_events", "0005_auto_20201005_2139"),
]
operations = [
diff --git a/authentik/events/migrations/0007_auto_20201215_0939.py b/authentik/events/migrations/0007_auto_20201215_0939.py
new file mode 100644
index 000000000..db2f030d6
--- /dev/null
+++ b/authentik/events/migrations/0007_auto_20201215_0939.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.1.4 on 2020-12-15 09:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_events", "0006_auto_20201017_2024"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="event",
+ name="action",
+ field=models.TextField(
+ choices=[
+ ("login", "Login"),
+ ("login_failed", "Login Failed"),
+ ("logout", "Logout"),
+ ("user_write", "User Write"),
+ ("suspicious_request", "Suspicious Request"),
+ ("password_set", "Password Set"),
+ ("token_view", "Token View"),
+ ("invitation_created", "Invite Created"),
+ ("invitation_used", "Invite Used"),
+ ("authorize_application", "Authorize Application"),
+ ("source_linked", "Source Linked"),
+ ("impersonation_started", "Impersonation Started"),
+ ("impersonation_ended", "Impersonation Ended"),
+ ("policy_execution", "Policy Execution"),
+ ("policy_exception", "Policy Exception"),
+ ("property_mapping_exception", "Property Mapping Exception"),
+ ("model_created", "Model Created"),
+ ("model_updated", "Model Updated"),
+ ("model_deleted", "Model Deleted"),
+ ("custom_", "Custom Prefix"),
+ ]
+ ),
+ ),
+ ]
diff --git a/authentik/events/migrations/0008_auto_20201220_1651.py b/authentik/events/migrations/0008_auto_20201220_1651.py
new file mode 100644
index 000000000..e8e0faf92
--- /dev/null
+++ b/authentik/events/migrations/0008_auto_20201220_1651.py
@@ -0,0 +1,42 @@
+# Generated by Django 3.1.4 on 2020-12-20 16:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_events", "0007_auto_20201215_0939"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="event",
+ name="action",
+ field=models.TextField(
+ choices=[
+ ("login", "Login"),
+ ("login_failed", "Login Failed"),
+ ("logout", "Logout"),
+ ("user_write", "User Write"),
+ ("suspicious_request", "Suspicious Request"),
+ ("password_set", "Password Set"),
+ ("token_view", "Token View"),
+ ("invitation_created", "Invite Created"),
+ ("invitation_used", "Invite Used"),
+ ("authorize_application", "Authorize Application"),
+ ("source_linked", "Source Linked"),
+ ("impersonation_started", "Impersonation Started"),
+ ("impersonation_ended", "Impersonation Ended"),
+ ("policy_execution", "Policy Execution"),
+ ("policy_exception", "Policy Exception"),
+ ("property_mapping_exception", "Property Mapping Exception"),
+ ("model_created", "Model Created"),
+ ("model_updated", "Model Updated"),
+ ("model_deleted", "Model Deleted"),
+ ("update_available", "Update Available"),
+ ("custom_", "Custom Prefix"),
+ ]
+ ),
+ ),
+ ]
diff --git a/authentik/audit/migrations/__init__.py b/authentik/events/migrations/__init__.py
similarity index 100%
rename from authentik/audit/migrations/__init__.py
rename to authentik/events/migrations/__init__.py
diff --git a/authentik/audit/models.py b/authentik/events/models.py
similarity index 61%
rename from authentik/audit/models.py
rename to authentik/events/models.py
index 7897ff066..5e35f2dbe 100644
--- a/authentik/audit/models.py
+++ b/authentik/events/models.py
@@ -1,17 +1,14 @@
-"""authentik audit models"""
+"""authentik events models"""
+
from inspect import getmodule, stack
-from typing import Any, Dict, Optional, Union
-from uuid import UUID, uuid4
+from typing import Optional, Union
+from uuid import uuid4
from django.conf import settings
-from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.db import models
-from django.db.models.base import Model
from django.http import HttpRequest
from django.utils.translation import gettext as _
-from django.views.debug import SafeExceptionReporterFilter
-from guardian.utils import get_anonymous_user
from structlog import get_logger
from authentik.core.middleware import (
@@ -19,78 +16,14 @@ from authentik.core.middleware import (
SESSION_IMPERSONATE_USER,
)
from authentik.core.models import User
+from authentik.events.utils import cleanse_dict, get_user, sanitize_dict
from authentik.lib.utils.http import get_client_ip
-LOGGER = get_logger("authentik.audit")
-
-
-def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
- """Cleanse a dictionary, recursively"""
- final_dict = {}
- for key, value in source.items():
- try:
- if SafeExceptionReporterFilter.hidden_settings.search(key):
- final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute
- else:
- final_dict[key] = value
- except TypeError:
- final_dict[key] = value
- if isinstance(value, dict):
- final_dict[key] = cleanse_dict(value)
- return final_dict
-
-
-def model_to_dict(model: Model) -> Dict[str, Any]:
- """Convert model to dict"""
- name = str(model)
- if hasattr(model, "name"):
- name = model.name
- return {
- "app": model._meta.app_label,
- "model_name": model._meta.model_name,
- "pk": model.pk,
- "name": name,
- }
-
-
-def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]:
- """Convert user object to dictionary, optionally including the original user"""
- if isinstance(user, AnonymousUser):
- user = get_anonymous_user()
- user_data = {
- "username": user.username,
- "pk": user.pk,
- "email": user.email,
- }
- if original_user:
- original_data = get_user(original_user)
- original_data["on_behalf_of"] = user_data
- return original_data
- return user_data
-
-
-def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
- """clean source of all Models that would interfere with the JSONField.
- Models are replaced with a dictionary of {
- app: str,
- name: str,
- pk: Any
- }"""
- final_dict = {}
- for key, value in source.items():
- if isinstance(value, dict):
- final_dict[key] = sanitize_dict(value)
- elif isinstance(value, models.Model):
- final_dict[key] = sanitize_dict(model_to_dict(value))
- elif isinstance(value, UUID):
- final_dict[key] = value.hex
- else:
- final_dict[key] = value
- return final_dict
+LOGGER = get_logger("authentik.events")
class EventAction(models.TextChoices):
- """All possible actions to save into the audit log"""
+ """All possible actions to save into the events log"""
LOGIN = "login"
LOGIN_FAILED = "login_failed"
@@ -111,15 +44,21 @@ class EventAction(models.TextChoices):
IMPERSONATION_STARTED = "impersonation_started"
IMPERSONATION_ENDED = "impersonation_ended"
+ POLICY_EXECUTION = "policy_execution"
+ POLICY_EXCEPTION = "policy_exception"
+ PROPERTY_MAPPING_EXCEPTION = "property_mapping_exception"
+
MODEL_CREATED = "model_created"
MODEL_UPDATED = "model_updated"
MODEL_DELETED = "model_deleted"
+ UPDATE_AVAILABLE = "update_available"
+
CUSTOM_PREFIX = "custom_"
class Event(models.Model):
- """An individual audit log event"""
+ """An individual Audit/Metrics/Notification/Error Event"""
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
user = models.JSONField(default=dict)
@@ -151,6 +90,12 @@ class Event(models.Model):
event = Event(action=action, app=app, context=cleaned_kwargs)
return event
+ def set_user(self, user: User) -> "Event":
+ """Set `.user` based on user, ensuring the correct attributes are copied.
+ This should only be used when self.from_http is *not* used."""
+ self.user = get_user(user)
+ return self
+
def from_http(
self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None
) -> "Event":
@@ -185,7 +130,7 @@ class Event(models.Model):
"you may not edit an existing %s" % self._meta.model_name
)
LOGGER.debug(
- "Created Audit event",
+ "Created Event",
action=self.action,
context=self.context,
client_ip=self.client_ip,
@@ -195,5 +140,5 @@ class Event(models.Model):
class Meta:
- verbose_name = _("Audit Event")
- verbose_name_plural = _("Audit Events")
+ verbose_name = _("Event")
+ verbose_name_plural = _("Events")
diff --git a/authentik/audit/signals.py b/authentik/events/signals.py
similarity index 97%
rename from authentik/audit/signals.py
rename to authentik/events/signals.py
index 88d769a8b..a7cb8b102 100644
--- a/authentik/audit/signals.py
+++ b/authentik/events/signals.py
@@ -1,4 +1,4 @@
-"""authentik audit signal listener"""
+"""authentik events signal listener"""
from threading import Thread
from typing import Any, Dict, Optional
@@ -10,9 +10,9 @@ from django.contrib.auth.signals import (
from django.dispatch import receiver
from django.http import HttpRequest
-from authentik.audit.models import Event, EventAction
from authentik.core.models import User
from authentik.core.signals import password_changed
+from authentik.events.models import Event, EventAction
from authentik.stages.invitation.models import Invitation
from authentik.stages.invitation.signals import invitation_created, invitation_used
from authentik.stages.user_write.signals import user_write
diff --git a/authentik/audit/templates/audit/list.html b/authentik/events/templates/events/list.html
similarity index 98%
rename from authentik/audit/templates/audit/list.html
rename to authentik/events/templates/events/list.html
index 470f9f0f3..0424a75d7 100644
--- a/authentik/audit/templates/audit/list.html
+++ b/authentik/events/templates/events/list.html
@@ -9,7 +9,7 @@
- {% trans 'Audit Log' %}
+ {% trans 'Event Log' %}
diff --git a/authentik/audit/tests/__init__.py b/authentik/events/tests/__init__.py
similarity index 100%
rename from authentik/audit/tests/__init__.py
rename to authentik/events/tests/__init__.py
diff --git a/authentik/audit/tests/test_event.py b/authentik/events/tests/test_event.py
similarity index 90%
rename from authentik/audit/tests/test_event.py
rename to authentik/events/tests/test_event.py
index bf7a6b594..e4d2d4887 100644
--- a/authentik/audit/tests/test_event.py
+++ b/authentik/events/tests/test_event.py
@@ -1,15 +1,15 @@
-"""audit event tests"""
+"""events event tests"""
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from guardian.shortcuts import get_anonymous_user
-from authentik.audit.models import Event
+from authentik.events.models import Event
from authentik.policies.dummy.models import DummyPolicy
-class TestAuditEvent(TestCase):
- """Test Audit Event"""
+class TestEvents(TestCase):
+ """Test Event"""
def test_new_with_model(self):
"""Create a new Event passing a model as kwarg"""
diff --git a/authentik/events/urls.py b/authentik/events/urls.py
new file mode 100644
index 000000000..ff208e2b8
--- /dev/null
+++ b/authentik/events/urls.py
@@ -0,0 +1,9 @@
+"""authentik events urls"""
+from django.urls import path
+
+from authentik.events.views import EventListView
+
+urlpatterns = [
+ # Event Log
+ path("log/", EventListView.as_view(), name="log"),
+]
diff --git a/authentik/events/utils.py b/authentik/events/utils.py
new file mode 100644
index 000000000..32dc69364
--- /dev/null
+++ b/authentik/events/utils.py
@@ -0,0 +1,86 @@
+"""event utilities"""
+import re
+from dataclasses import asdict, is_dataclass
+from typing import Any, Dict, Optional
+from uuid import UUID
+
+from django.contrib.auth.models import AnonymousUser
+from django.db import models
+from django.db.models.base import Model
+from django.views.debug import SafeExceptionReporterFilter
+from guardian.utils import get_anonymous_user
+
+from authentik.core.models import User
+
+# Special keys which are *not* cleaned, even when the default filter
+# is matched
+ALLOWED_SPECIAL_KEYS = re.compile("passing", flags=re.I)
+
+
+def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
+ """Cleanse a dictionary, recursively"""
+ final_dict = {}
+ for key, value in source.items():
+ try:
+ if SafeExceptionReporterFilter.hidden_settings.search(
+ key
+ ) and not ALLOWED_SPECIAL_KEYS.search(key):
+ final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute
+ else:
+ final_dict[key] = value
+ except TypeError:
+ final_dict[key] = value
+ if isinstance(value, dict):
+ final_dict[key] = cleanse_dict(value)
+ return final_dict
+
+
+def model_to_dict(model: Model) -> Dict[str, Any]:
+ """Convert model to dict"""
+ name = str(model)
+ if hasattr(model, "name"):
+ name = model.name
+ return {
+ "app": model._meta.app_label,
+ "model_name": model._meta.model_name,
+ "pk": model.pk,
+ "name": name,
+ }
+
+
+def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]:
+ """Convert user object to dictionary, optionally including the original user"""
+ if isinstance(user, AnonymousUser):
+ user = get_anonymous_user()
+ user_data = {
+ "username": user.username,
+ "pk": user.pk,
+ "email": user.email,
+ }
+ if original_user:
+ original_data = get_user(original_user)
+ original_data["on_behalf_of"] = user_data
+ return original_data
+ return user_data
+
+
+def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
+ """clean source of all Models that would interfere with the JSONField.
+ Models are replaced with a dictionary of {
+ app: str,
+ name: str,
+ pk: Any
+ }"""
+ final_dict = {}
+ for key, value in source.items():
+ if is_dataclass(value):
+ value = asdict(value)
+ if isinstance(value, dict):
+ final_dict[key] = sanitize_dict(value)
+ elif isinstance(value, models.Model):
+ final_dict[key] = sanitize_dict(model_to_dict(value))
+ elif isinstance(value, UUID):
+ final_dict[key] = value.hex
+ else:
+ final_dict[key] = value
+ return final_dict
diff --git a/authentik/audit/views.py b/authentik/events/views.py
similarity index 81%
rename from authentik/audit/views.py
rename to authentik/events/views.py
index c87d2fd67..0bbb83f2b 100644
--- a/authentik/audit/views.py
+++ b/authentik/events/views.py
@@ -4,7 +4,7 @@ from django.views.generic import ListView
from guardian.mixins import PermissionListMixin
from authentik.admin.views.utils import SearchListMixin, UserPaginateListMixin
-from authentik.audit.models import Event
+from authentik.events.models import Event
class EventListView(
@@ -17,8 +17,8 @@ class EventListView(
"""Show list of all invitations"""
model = Event
- template_name = "audit/list.html"
- permission_required = "authentik_audit.view_event"
+ template_name = "events/list.html"
+ permission_required = "authentik_events.view_event"
ordering = "-created"
search_fields = [
diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py
index 17246f80d..4f4dc54a2 100644
--- a/authentik/flows/planner.py
+++ b/authentik/flows/planner.py
@@ -8,8 +8,8 @@ from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span
from structlog import get_logger
-from authentik.audit.models import cleanse_dict
from authentik.core.models import User
+from authentik.events.models import cleanse_dict
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import Flow, FlowStageBinding, Stage
diff --git a/authentik/flows/views.py b/authentik/flows/views.py
index 66fa83ae2..62fd07127 100644
--- a/authentik/flows/views.py
+++ b/authentik/flows/views.py
@@ -17,8 +17,8 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import TemplateView, View
from structlog import get_logger
-from authentik.audit.models import cleanse_dict
from authentik.core.models import USER_ATTRIBUTE_DEBUG
+from authentik.events.models import cleanse_dict
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
from authentik.flows.planner import (
diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py
index 35b79b3a6..0a96af156 100644
--- a/authentik/lib/expression/evaluator.py
+++ b/authentik/lib/expression/evaluator.py
@@ -80,11 +80,15 @@ class BaseEvaluator:
span: Span
span.set_data("expression", expression_source)
param_keys = self._context.keys()
- ast_obj = compile(
- self.wrap_expression(expression_source, param_keys),
- self._filename,
- "exec",
- )
+ try:
+ ast_obj = compile(
+ self.wrap_expression(expression_source, param_keys),
+ self._filename,
+ "exec",
+ )
+ except (SyntaxError, ValueError) as exc:
+ self.handle_error(exc, expression_source)
+ raise exc
try:
_locals = self._context
# Yes this is an exec, yes it is potentially bad. Since we limit what variables are
@@ -94,10 +98,15 @@ class BaseEvaluator:
exec(ast_obj, self._globals, _locals) # nosec # noqa
result = _locals["result"]
except Exception as exc:
- LOGGER.warning("Expression error", exc=exc)
- raise
+ self.handle_error(exc, expression_source)
+ raise exc
return result
+ # pylint: disable=unused-argument
+ def handle_error(self, exc: Exception, expression_source: str): # pragma: no cover
+ """Exception Handler"""
+ LOGGER.warning("Expression error", exc=exc)
+
def validate(self, expression: str) -> bool:
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
param_keys = self._context.keys()
diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py
index 9dfba8c3c..11eb79100 100644
--- a/authentik/policies/expression/evaluator.py
+++ b/authentik/policies/expression/evaluator.py
@@ -1,16 +1,21 @@
"""authentik expression policy evaluator"""
from ipaddress import ip_address, ip_network
-from typing import List
+from traceback import format_tb
+from typing import TYPE_CHECKING, List, Optional
from django.http import HttpRequest
from structlog import get_logger
+from authentik.events.models import Event, EventAction
+from authentik.events.utils import model_to_dict, sanitize_dict
from authentik.flows.planner import PLAN_CONTEXT_SSO
from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.lib.utils.http import get_client_ip
from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
+if TYPE_CHECKING:
+ from authentik.policies.expression.models import ExpressionPolicy
class PolicyEvaluator(BaseEvaluator):
@@ -18,6 +23,8 @@ class PolicyEvaluator(BaseEvaluator):
_messages: List[str]
+ policy: Optional["ExpressionPolicy"] = None
+
def __init__(self, policy_name: str):
super().__init__()
self._messages = []
@@ -45,15 +52,30 @@ class PolicyEvaluator(BaseEvaluator):
self._context["ak_client_ip"] = ip_address(
get_client_ip(request) or "255.255.255.255"
)
- self._context["request"] = request
+ self._context["http_request"] = request
+
+ def handle_error(self, exc: Exception, expression_source: str):
+ """Exception Handler"""
+ error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
+ event = Event.new(
+ EventAction.POLICY_EXCEPTION,
+ expression=expression_source,
+ error=error_string,
+ request=self._context["request"],
+ )
+ if self.policy:
+ event.context["model"] = sanitize_dict(model_to_dict(self.policy))
+ if "http_request" in self._context:
+ event.from_http(self._context["http_request"])
+ else:
+ event.set_user(self._context["request"].user)
+ event.save()
def evaluate(self, expression_source: str) -> PolicyResult:
"""Parse and evaluate expression. Policy is expected to return a truthy object.
Messages can be added using 'do ak_message()'."""
try:
result = super().evaluate(expression_source)
- except (ValueError, SyntaxError) as exc:
- return PolicyResult(False, str(exc))
except Exception as exc: # pylint: disable=broad-except
LOGGER.warning("Expression error", exc=exc)
return PolicyResult(False, str(exc))
diff --git a/authentik/policies/expression/models.py b/authentik/policies/expression/models.py
index 66e92eb17..f4a114954 100644
--- a/authentik/policies/expression/models.py
+++ b/authentik/policies/expression/models.py
@@ -31,11 +31,14 @@ class ExpressionPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult:
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
evaluator = PolicyEvaluator(self.name)
+ evaluator.policy = self
evaluator.set_policy_request(request)
return evaluator.evaluate(self.expression)
def save(self, *args, **kwargs):
- PolicyEvaluator(self.name).validate(self.expression)
+ evaluator = PolicyEvaluator(self.name)
+ evaluator.policy = self
+ evaluator.validate(self.expression)
return super().save(*args, **kwargs)
class Meta:
diff --git a/authentik/policies/expression/tests.py b/authentik/policies/expression/tests.py
index 8cd51bccc..4d809b159 100644
--- a/authentik/policies/expression/tests.py
+++ b/authentik/policies/expression/tests.py
@@ -3,7 +3,9 @@ from django.core.exceptions import ValidationError
from django.test import TestCase
from guardian.shortcuts import get_anonymous_user
+from authentik.events.models import Event, EventAction
from authentik.policies.expression.evaluator import PolicyEvaluator
+from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.types import PolicyRequest
@@ -13,6 +15,14 @@ class TestEvaluator(TestCase):
def setUp(self):
self.request = PolicyRequest(user=get_anonymous_user())
+ def test_full(self):
+ """Test full with Policy instance"""
+ policy = ExpressionPolicy(name="test", expression="return 'test'")
+ policy.save()
+ request = PolicyRequest(get_anonymous_user())
+ result = policy.passes(request)
+ self.assertTrue(result.passing)
+
def test_valid(self):
"""test simple value expression"""
template = "return True"
@@ -37,6 +47,12 @@ class TestEvaluator(TestCase):
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("invalid syntax (test, line 3)",))
+ self.assertTrue(
+ Event.objects.filter(
+ action=EventAction.POLICY_EXCEPTION,
+ context__expression=template,
+ ).exists()
+ )
def test_undefined(self):
"""test undefined result"""
@@ -46,6 +62,12 @@ class TestEvaluator(TestCase):
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("name 'foo' is not defined",))
+ self.assertTrue(
+ Event.objects.filter(
+ action=EventAction.POLICY_EXCEPTION,
+ context__expression=template,
+ ).exists()
+ )
def test_validate(self):
"""test validate"""
diff --git a/authentik/policies/forms.py b/authentik/policies/forms.py
index d9e551cc5..b737ec817 100644
--- a/authentik/policies/forms.py
+++ b/authentik/policies/forms.py
@@ -5,7 +5,7 @@ from django import forms
from authentik.lib.widgets import GroupedModelChoiceField
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
-GENERAL_FIELDS = ["name"]
+GENERAL_FIELDS = ["name", "execution_logging"]
GENERAL_SERIALIZER_FIELDS = ["pk", "name"]
diff --git a/authentik/policies/migrations/0004_policy_execution_logging.py b/authentik/policies/migrations/0004_policy_execution_logging.py
new file mode 100644
index 000000000..6240a5e0d
--- /dev/null
+++ b/authentik/policies/migrations/0004_policy_execution_logging.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1.4 on 2020-12-15 09:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies", "0003_auto_20200908_1542"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="policy",
+ name="execution_logging",
+ field=models.BooleanField(
+ default=False,
+ help_text="When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.",
+ ),
+ ),
+ ]
diff --git a/authentik/policies/models.py b/authentik/policies/models.py
index 3cd8a2bb2..df9783d7d 100644
--- a/authentik/policies/models.py
+++ b/authentik/policies/models.py
@@ -81,6 +81,16 @@ class Policy(SerializerModel, CreatedUpdatedModel):
name = models.TextField(blank=True, null=True)
+ execution_logging = models.BooleanField(
+ default=False,
+ help_text=_(
+ (
+ "When this option is enabled, all executions of this policy will be logged. "
+ "By default, only execution errors are logged."
+ )
+ ),
+ )
+
objects = InheritanceAutoManager()
@property
diff --git a/authentik/policies/process.py b/authentik/policies/process.py
index 0737ef433..c26b63c5d 100644
--- a/authentik/policies/process.py
+++ b/authentik/policies/process.py
@@ -8,6 +8,7 @@ from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span
from structlog import get_logger
+from authentik.events.models import Event, EventAction
from authentik.policies.exceptions import PolicyException
from authentik.policies.models import PolicyBinding
from authentik.policies.types import PolicyRequest, PolicyResult
@@ -48,40 +49,48 @@ class PolicyProcess(Process):
def execute(self) -> PolicyResult:
"""Run actual policy, returns result"""
+ LOGGER.debug(
+ "P_ENG(proc): Running policy",
+ policy=self.binding.policy,
+ user=self.request.user,
+ process="PolicyProcess",
+ )
+ try:
+ policy_result = self.binding.policy.passes(self.request)
+ if self.binding.policy.execution_logging:
+ event = Event.new(
+ EventAction.POLICY_EXECUTION,
+ request=self.request,
+ result=policy_result,
+ )
+ event.set_user(self.request.user)
+ event.save()
+ except PolicyException as exc:
+ LOGGER.debug("P_ENG(proc): error", exc=exc)
+ policy_result = PolicyResult(False, str(exc))
+ policy_result.source_policy = self.binding.policy
+ # Invert result if policy.negate is set
+ if self.binding.negate:
+ policy_result.passing = not policy_result.passing
+ LOGGER.debug(
+ "P_ENG(proc): Finished",
+ policy=self.binding.policy,
+ result=policy_result,
+ process="PolicyProcess",
+ passing=policy_result.passing,
+ user=self.request.user,
+ )
+ key = cache_key(self.binding, self.request)
+ cache.set(key, policy_result)
+ LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
+ return policy_result
+
+ def run(self): # pragma: no cover
+ """Task wrapper to run policy checking"""
with Hub.current.start_span(
op="policy.process.execute",
) as span:
span: Span
span.set_data("policy", self.binding.policy)
span.set_data("request", self.request)
- LOGGER.debug(
- "P_ENG(proc): Running policy",
- policy=self.binding.policy,
- user=self.request.user,
- process="PolicyProcess",
- )
- try:
- policy_result = self.binding.policy.passes(self.request)
- except PolicyException as exc:
- LOGGER.debug("P_ENG(proc): error", exc=exc)
- policy_result = PolicyResult(False, str(exc))
- policy_result.source_policy = self.binding.policy
- # Invert result if policy.negate is set
- if self.binding.negate:
- policy_result.passing = not policy_result.passing
- LOGGER.debug(
- "P_ENG(proc): Finished",
- policy=self.binding.policy,
- result=policy_result,
- process="PolicyProcess",
- passing=policy_result.passing,
- user=self.request.user,
- )
- key = cache_key(self.binding, self.request)
- cache.set(key, policy_result)
- LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
- return policy_result
-
- def run(self):
- """Task wrapper to run policy checking"""
- self.connection.send(self.execute())
+ self.connection.send(self.execute())
diff --git a/authentik/policies/tests/test_engine.py b/authentik/policies/tests/test_engine.py
index 843577aac..d59825c4e 100644
--- a/authentik/policies/tests/test_engine.py
+++ b/authentik/policies/tests/test_engine.py
@@ -7,13 +7,14 @@ from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.engine import PolicyEngine
from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
+from authentik.policies.tests.test_process import clear_policy_cache
class TestPolicyEngine(TestCase):
"""PolicyEngine tests"""
def setUp(self):
- cache.clear()
+ clear_policy_cache()
self.user = User.objects.create_user(username="policyuser")
self.policy_false = DummyPolicy.objects.create(
result=False, wait_min=0, wait_max=1
@@ -84,10 +85,18 @@ class TestPolicyEngine(TestCase):
def test_engine_cache(self):
"""Ensure empty policy list passes"""
pbm = PolicyBindingModel.objects.create()
- PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0)
+ binding = PolicyBinding.objects.create(
+ target=pbm, policy=self.policy_false, order=0
+ )
engine = PolicyEngine(pbm, self.user)
- self.assertEqual(len(cache.keys("policy_*")), 0)
+ self.assertEqual(
+ len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 0
+ )
self.assertEqual(engine.build().passing, False)
- self.assertEqual(len(cache.keys("policy_*")), 1)
+ self.assertEqual(
+ len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1
+ )
self.assertEqual(engine.build().passing, False)
- self.assertEqual(len(cache.keys("policy_*")), 1)
+ self.assertEqual(
+ len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1
+ )
diff --git a/authentik/policies/tests/test_process.py b/authentik/policies/tests/test_process.py
new file mode 100644
index 000000000..1b06e6f06
--- /dev/null
+++ b/authentik/policies/tests/test_process.py
@@ -0,0 +1,105 @@
+"""policy process tests"""
+from django.core.cache import cache
+from django.test import TestCase
+
+from authentik.core.models import User
+from authentik.events.models import Event, EventAction
+from authentik.policies.dummy.models import DummyPolicy
+from authentik.policies.expression.models import ExpressionPolicy
+from authentik.policies.models import Policy, PolicyBinding
+from authentik.policies.process import PolicyProcess
+from authentik.policies.types import PolicyRequest
+
+
+def clear_policy_cache():
+ """Ensure no policy-related keys are stil cached"""
+ keys = cache.keys("policy_*")
+ cache.delete(keys)
+
+
+class TestPolicyProcess(TestCase):
+ """Policy Process tests"""
+
+ def setUp(self):
+ clear_policy_cache()
+ self.user = User.objects.create_user(username="policyuser")
+
+ def test_invalid(self):
+ """Test Process with invalid arguments"""
+ policy = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1)
+ binding = PolicyBinding(policy=policy)
+ with self.assertRaises(ValueError):
+ PolicyProcess(binding, None, None) # type: ignore
+
+ def test_true(self):
+ """Test policy execution"""
+ policy = DummyPolicy.objects.create(result=True, wait_min=0, wait_max=1)
+ binding = PolicyBinding(policy=policy)
+
+ request = PolicyRequest(self.user)
+ response = PolicyProcess(binding, request, None).execute()
+ self.assertEqual(response.passing, True)
+ self.assertEqual(response.messages, ("dummy",))
+
+ def test_false(self):
+ """Test policy execution"""
+ policy = DummyPolicy.objects.create(result=False, wait_min=0, wait_max=1)
+ binding = PolicyBinding(policy=policy)
+
+ request = PolicyRequest(self.user)
+ response = PolicyProcess(binding, request, None).execute()
+ self.assertEqual(response.passing, False)
+ self.assertEqual(response.messages, ("dummy",))
+
+ def test_negate(self):
+ """Test policy execution"""
+ policy = DummyPolicy.objects.create(result=False, wait_min=0, wait_max=1)
+ binding = PolicyBinding(policy=policy, negate=True)
+
+ request = PolicyRequest(self.user)
+ response = PolicyProcess(binding, request, None).execute()
+ self.assertEqual(response.passing, True)
+ self.assertEqual(response.messages, ("dummy",))
+
+ def test_exception(self):
+ """Test policy execution"""
+ policy = Policy.objects.create()
+ binding = PolicyBinding(policy=policy)
+
+ request = PolicyRequest(self.user)
+ response = PolicyProcess(binding, request, None).execute()
+ self.assertEqual(response.passing, False)
+
+ def test_execution_logging(self):
+ """Test policy execution creates event"""
+ policy = DummyPolicy.objects.create(
+ result=False, wait_min=0, wait_max=1, execution_logging=True
+ )
+ binding = PolicyBinding(policy=policy)
+
+ request = PolicyRequest(self.user)
+ response = PolicyProcess(binding, request, None).execute()
+ self.assertEqual(response.passing, False)
+ self.assertEqual(response.messages, ("dummy",))
+
+ events = Event.objects.filter(
+ action=EventAction.POLICY_EXECUTION,
+ )
+ self.assertTrue(events.exists())
+ self.assertEqual(len(events), 1)
+ event = events.first()
+ self.assertEqual(event.context["result"]["passing"], False)
+ self.assertEqual(event.context["result"]["messages"], ["dummy"])
+
+ def test_raises(self):
+ """Test policy that raises error"""
+ policy_raises = ExpressionPolicy.objects.create(
+ name="raises", expression="{{ 0/0 }}"
+ )
+ binding = PolicyBinding(policy=policy_raises)
+
+ request = PolicyRequest(self.user)
+ response = PolicyProcess(binding, request, None).execute()
+ self.assertEqual(response.passing, False)
+ self.assertEqual(response.messages, ("division by zero",))
+ # self.assert
diff --git a/authentik/policies/types.py b/authentik/policies/types.py
index 2abaf444a..c9ba8522c 100644
--- a/authentik/policies/types.py
+++ b/authentik/policies/types.py
@@ -1,6 +1,7 @@
"""policy structures"""
from __future__ import annotations
+from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from django.db.models import Model
@@ -11,6 +12,7 @@ if TYPE_CHECKING:
from authentik.policies.models import Policy
+@dataclass
class PolicyRequest:
"""Data-class to hold policy request data"""
@@ -20,6 +22,7 @@ class PolicyRequest:
context: Dict[str, str]
def __init__(self, user: User):
+ super().__init__()
self.user = user
self.http_request = None
self.obj = None
@@ -29,6 +32,7 @@ class PolicyRequest:
return f""
+@dataclass
class PolicyResult:
"""Small data-class to hold policy results"""
@@ -39,6 +43,7 @@ class PolicyResult:
source_results: Optional[List["PolicyResult"]]
def __init__(self, passing: bool, *messages: str):
+ super().__init__()
self.passing = passing
self.messages = messages
self.source_policy = None
@@ -49,5 +54,5 @@ class PolicyResult:
def __str__(self):
if self.messages:
- return f"PolicyResult passing={self.passing} messages={self.messages}"
- return f"PolicyResult passing={self.passing}"
+ return f""
+ return f""
diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py
index 56fe0c79b..b3f1e34f5 100644
--- a/authentik/providers/oauth2/views/authorize.py
+++ b/authentik/providers/oauth2/views/authorize.py
@@ -9,8 +9,8 @@ from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from structlog import get_logger
-from authentik.audit.models import Event, EventAction
from authentik.core.models import Application
+from authentik.events.models import Event, EventAction
from authentik.flows.models import in_memory_stage
from authentik.flows.planner import (
PLAN_CONTEXT_APPLICATION,
diff --git a/authentik/providers/saml/views.py b/authentik/providers/saml/views.py
index dd347d655..7e1dbb204 100644
--- a/authentik/providers/saml/views.py
+++ b/authentik/providers/saml/views.py
@@ -11,8 +11,8 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt
from structlog import get_logger
-from authentik.audit.models import Event, EventAction
from authentik.core.models import Application, Provider
+from authentik.events.models import Event, EventAction
from authentik.flows.models import in_memory_stage
from authentik.flows.planner import (
PLAN_CONTEXT_APPLICATION,
diff --git a/authentik/root/settings.py b/authentik/root/settings.py
index 9f90076f1..3975f7d8e 100644
--- a/authentik/root/settings.py
+++ b/authentik/root/settings.py
@@ -86,7 +86,7 @@ INSTALLED_APPS = [
"django.contrib.humanize",
"authentik.admin.apps.AuthentikAdminConfig",
"authentik.api.apps.AuthentikAPIConfig",
- "authentik.audit.apps.AuthentikAuditConfig",
+ "authentik.events.apps.AuthentikEventsConfig",
"authentik.crypto.apps.AuthentikCryptoConfig",
"authentik.flows.apps.AuthentikFlowsConfig",
"authentik.outposts.apps.AuthentikOutpostConfig",
@@ -180,7 +180,7 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"authentik.core.middleware.RequestIDMiddleware",
- "authentik.audit.middleware.AuditMiddleware",
+ "authentik.events.middleware.AuditMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py
index 5e75ea404..bbebab813 100644
--- a/authentik/sources/oauth/views/callback.py
+++ b/authentik/sources/oauth/views/callback.py
@@ -10,8 +10,8 @@ from django.utils.translation import gettext as _
from django.views.generic import View
from structlog import get_logger
-from authentik.audit.models import Event, EventAction
from authentik.core.models import User
+from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow, in_memory_stage
from authentik.flows.planner import (
PLAN_CONTEXT_PENDING_USER,
diff --git a/authentik/sources/oauth/views/flows.py b/authentik/sources/oauth/views/flows.py
index ac326f1f0..fbcba821e 100644
--- a/authentik/sources/oauth/views/flows.py
+++ b/authentik/sources/oauth/views/flows.py
@@ -1,8 +1,8 @@
"""OAuth Stages"""
from django.http import HttpRequest, HttpResponse
-from authentik.audit.models import Event, EventAction
from authentik.core.models import User
+from authentik.events.models import Event, EventAction
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.sources.oauth.models import UserOAuthSourceConnection
diff --git a/authentik/stages/otp_static/views.py b/authentik/stages/otp_static/views.py
index 2dd113fdc..ccc495240 100644
--- a/authentik/stages/otp_static/views.py
+++ b/authentik/stages/otp_static/views.py
@@ -7,7 +7,7 @@ from django.views import View
from django.views.generic import TemplateView
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
-from authentik.audit.models import Event
+from authentik.events.models import Event
from authentik.stages.otp_static.models import OTPStaticStage
diff --git a/authentik/stages/otp_time/views.py b/authentik/stages/otp_time/views.py
index ad09a3c8b..f1322cadf 100644
--- a/authentik/stages/otp_time/views.py
+++ b/authentik/stages/otp_time/views.py
@@ -7,7 +7,7 @@ from django.views import View
from django.views.generic import TemplateView
from django_otp.plugins.otp_totp.models import TOTPDevice
-from authentik.audit.models import Event
+from authentik.events.models import Event
from authentik.stages.otp_time.models import OTPTimeStage
diff --git a/lifecycle/system_migrations/to_0_13_events..py b/lifecycle/system_migrations/to_0_13_events..py
new file mode 100644
index 000000000..4745b8853
--- /dev/null
+++ b/lifecycle/system_migrations/to_0_13_events..py
@@ -0,0 +1,21 @@
+# flake8: noqa
+from lifecycle.migrate import BaseMigration
+
+SQL_STATEMENT = """BEGIN TRANSACTION;
+ALTER TABLE authentik_audit_event RENAME TO authentik_events_event;
+UPDATE django_migrations SET app = replace(app, 'authentik_audit', 'authentik_events');
+UPDATE django_content_type SET app_label = replace(app_label, 'authentik_audit', 'authentik_events');
+
+END TRANSACTION;"""
+
+
+class Migration(BaseMigration):
+ def needs_migration(self) -> bool:
+ self.cur.execute(
+ "select * from information_schema.tables where table_name = 'authentik_audit_event';"
+ )
+ return bool(self.cur.rowcount)
+
+ def run(self):
+ self.cur.execute(SQL_STATEMENT)
+ self.con.commit()
diff --git a/swagger.yaml b/swagger.yaml
index f19460c08..0d05fa15a 100755
--- a/swagger.yaml
+++ b/swagger.yaml
@@ -155,112 +155,6 @@ paths:
tags:
- admin
parameters: []
- /audit/events/:
- get:
- operationId: audit_events_list
- description: Event Read-Only Viewset
- parameters:
- - name: ordering
- in: query
- description: Which field to use when ordering the results.
- required: false
- type: string
- - name: search
- in: query
- description: A search term.
- required: false
- type: string
- - name: page
- in: query
- description: A page number within the paginated result set.
- required: false
- type: integer
- - name: page_size
- in: query
- description: Number of results to return per page.
- required: false
- type: integer
- responses:
- '200':
- description: ''
- schema:
- required:
- - count
- - results
- type: object
- properties:
- count:
- type: integer
- next:
- type: string
- format: uri
- x-nullable: true
- previous:
- type: string
- format: uri
- x-nullable: true
- results:
- type: array
- items:
- $ref: '#/definitions/Event'
- tags:
- - audit
- parameters: []
- /audit/events/top_per_user/:
- get:
- operationId: audit_events_top_per_user
- description: Get the top_n events grouped by user count
- parameters:
- - name: ordering
- in: query
- description: Which field to use when ordering the results.
- required: false
- type: string
- - name: search
- in: query
- description: A search term.
- required: false
- type: string
- - name: page
- in: query
- description: A page number within the paginated result set.
- required: false
- type: integer
- - name: page_size
- in: query
- description: Number of results to return per page.
- required: false
- type: integer
- responses:
- '200':
- description: Response object of Event's top_per_user
- schema:
- description: ''
- type: array
- items:
- $ref: '#/definitions/EventTopPerUserSerialier'
- tags:
- - audit
- parameters: []
- /audit/events/{event_uuid}/:
- get:
- operationId: audit_events_read
- description: Event Read-Only Viewset
- parameters: []
- responses:
- '200':
- description: ''
- schema:
- $ref: '#/definitions/Event'
- tags:
- - audit
- parameters:
- - name: event_uuid
- in: path
- description: A UUID string identifying this Audit Event.
- required: true
- type: string
- format: uuid
/core/applications/:
get:
operationId: core_applications_list
@@ -968,6 +862,112 @@ paths:
required: true
type: string
format: uuid
+ /events/events/:
+ get:
+ operationId: events_events_list
+ description: Event Read-Only Viewset
+ parameters:
+ - name: ordering
+ in: query
+ description: Which field to use when ordering the results.
+ required: false
+ type: string
+ - name: search
+ in: query
+ description: A search term.
+ required: false
+ type: string
+ - name: page
+ in: query
+ description: A page number within the paginated result set.
+ required: false
+ type: integer
+ - name: page_size
+ in: query
+ description: Number of results to return per page.
+ required: false
+ type: integer
+ responses:
+ '200':
+ description: ''
+ schema:
+ required:
+ - count
+ - results
+ type: object
+ properties:
+ count:
+ type: integer
+ next:
+ type: string
+ format: uri
+ x-nullable: true
+ previous:
+ type: string
+ format: uri
+ x-nullable: true
+ results:
+ type: array
+ items:
+ $ref: '#/definitions/Event'
+ tags:
+ - events
+ parameters: []
+ /events/events/top_per_user/:
+ get:
+ operationId: events_events_top_per_user
+ description: Get the top_n events grouped by user count
+ parameters:
+ - name: ordering
+ in: query
+ description: Which field to use when ordering the results.
+ required: false
+ type: string
+ - name: search
+ in: query
+ description: A search term.
+ required: false
+ type: string
+ - name: page
+ in: query
+ description: A page number within the paginated result set.
+ required: false
+ type: integer
+ - name: page_size
+ in: query
+ description: Number of results to return per page.
+ required: false
+ type: integer
+ responses:
+ '200':
+ description: Response object of Event's top_per_user
+ schema:
+ description: ''
+ type: array
+ items:
+ $ref: '#/definitions/EventTopPerUserSerialier'
+ tags:
+ - events
+ parameters: []
+ /events/events/{event_uuid}/:
+ get:
+ operationId: events_events_read
+ description: Event Read-Only Viewset
+ parameters: []
+ responses:
+ '200':
+ description: ''
+ schema:
+ $ref: '#/definitions/Event'
+ tags:
+ - events
+ parameters:
+ - name: event_uuid
+ in: path
+ description: A UUID string identifying this Event.
+ required: true
+ type: string
+ format: uuid
/flows/bindings/:
get:
operationId: flows_bindings_list
@@ -6729,78 +6729,6 @@ definitions:
title: Outdated
type: boolean
readOnly: true
- Event:
- description: Event Serializer
- required:
- - action
- - app
- type: object
- properties:
- pk:
- title: Event uuid
- type: string
- format: uuid
- readOnly: true
- user:
- title: User
- type: object
- action:
- title: Action
- type: string
- enum:
- - login
- - login_failed
- - logout
- - user_write
- - suspicious_request
- - password_set
- - token_view
- - invitation_created
- - invitation_used
- - authorize_application
- - source_linked
- - impersonation_started
- - impersonation_ended
- - model_created
- - model_updated
- - model_deleted
- - custom_
- app:
- title: App
- type: string
- minLength: 1
- context:
- title: Context
- type: object
- client_ip:
- title: Client ip
- type: string
- minLength: 1
- x-nullable: true
- created:
- title: Created
- type: string
- format: date-time
- readOnly: true
- EventTopPerUserSerialier:
- description: Response object of Event's top_per_user
- required:
- - application
- - counted_events
- - unique_users
- type: object
- properties:
- application:
- title: Application
- type: object
- additionalProperties:
- type: string
- counted_events:
- title: Counted events
- type: integer
- unique_users:
- title: Unique users
- type: integer
Application:
description: Application Serializer
required:
@@ -6986,6 +6914,82 @@ definitions:
description: Optional Private Key. If this is set, you can use this keypair
for encryption.
type: string
+ Event:
+ description: Event Serializer
+ required:
+ - action
+ - app
+ type: object
+ properties:
+ pk:
+ title: Event uuid
+ type: string
+ format: uuid
+ readOnly: true
+ user:
+ title: User
+ type: object
+ action:
+ title: Action
+ type: string
+ enum:
+ - login
+ - login_failed
+ - logout
+ - user_write
+ - suspicious_request
+ - password_set
+ - token_view
+ - invitation_created
+ - invitation_used
+ - authorize_application
+ - source_linked
+ - impersonation_started
+ - impersonation_ended
+ - policy_execution
+ - policy_exception
+ - property_mapping_exception
+ - model_created
+ - model_updated
+ - model_deleted
+ - update_available
+ - custom_
+ app:
+ title: App
+ type: string
+ minLength: 1
+ context:
+ title: Context
+ type: object
+ client_ip:
+ title: Client ip
+ type: string
+ minLength: 1
+ x-nullable: true
+ created:
+ title: Created
+ type: string
+ format: date-time
+ readOnly: true
+ EventTopPerUserSerialier:
+ description: Response object of Event's top_per_user
+ required:
+ - application
+ - counted_events
+ - unique_users
+ type: object
+ properties:
+ application:
+ title: Application
+ type: object
+ additionalProperties:
+ type: string
+ counted_events:
+ title: Counted events
+ type: integer
+ unique_users:
+ title: Unique users
+ type: integer
Stage:
title: Stage obj
description: Stage Serializer
@@ -7380,6 +7384,11 @@ definitions:
title: Name
type: string
x-nullable: true
+ execution_logging:
+ title: Execution logging
+ description: When this option is enabled, all executions of this policy will
+ be logged. By default, only execution errors are logged.
+ type: boolean
__type__:
title: 'type '
type: string
diff --git a/web/src/api/Events.ts b/web/src/api/Events.ts
index 9d2b2b961..408e38dec 100644
--- a/web/src/api/Events.ts
+++ b/web/src/api/Events.ts
@@ -1,9 +1,9 @@
import { DefaultClient } from "./Client";
-export class AuditEvent {
- //audit/events/top_per_user/?filter_action=authorize_application
+export class Event {
+ // events/events/top_per_user/?filter_action=authorize_application
static topForUser(action: string): Promise {
- return DefaultClient.fetch(["audit", "events", "top_per_user"], {
+ return DefaultClient.fetch(["events", "events", "top_per_user"], {
"filter_action": action,
});
}
diff --git a/web/src/interfaces/AdminInterface.ts b/web/src/interfaces/AdminInterface.ts
index 6730d985b..1b90936f5 100644
--- a/web/src/interfaces/AdminInterface.ts
+++ b/web/src/interfaces/AdminInterface.ts
@@ -9,7 +9,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
new SidebarItem("Monitor").children(
new SidebarItem("Overview", "/administration/overview/"),
new SidebarItem("System Tasks", "/administration/tasks/"),
- new SidebarItem("Events", "/audit/audit"),
+ new SidebarItem("Events", "/events/log/"),
).when((): Promise => {
return User.me().then(u => u.is_superuser);
}),
diff --git a/web/src/pages/admin-overview/TopApplicationsTable.ts b/web/src/pages/admin-overview/TopApplicationsTable.ts
index fa91fc27a..a47c80a04 100644
--- a/web/src/pages/admin-overview/TopApplicationsTable.ts
+++ b/web/src/pages/admin-overview/TopApplicationsTable.ts
@@ -1,6 +1,6 @@
import { gettext } from "django";
import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element";
-import { AuditEvent, TopNEvent } from "../../api/Events";
+import { Event, TopNEvent } from "../../api/Events";
import { COMMON_STYLES } from "../../common/styles";
import "../../elements/Spinner";
@@ -16,7 +16,7 @@ export class TopApplicationsTable extends LitElement {
}
firstUpdated(): void {
- AuditEvent.topForUser("authorize_application").then(events => this.topN = events);
+ Event.topForUser("authorize_application").then(events => this.topN = events);
}
renderRow(event: TopNEvent): TemplateResult {