From a4dc6d13b5fd432222f340304693eb7eda237d7b Mon Sep 17 00:00:00 2001 From: Jens L Date: Sun, 20 Dec 2020 22:04:29 +0100 Subject: [PATCH] events: rename audit to events and use for more metrics (#397) * events: rename audit to events * policies/expression: log expression exceptions as event * policies/expression: add ExpressionPolicy Model to event when possible * lib/expressions: ensure syntax errors are logged too * lib: fix lint error * policies: add execution_logging field * core: add property mapping tests * policies/expression: add full test * policies/expression: fix attribute name * policies: add execution_logging * web: fix imports * root: update swagger * policies: use dataclass instead of dict for types * events: add support for dataclass as event param * events: add special keys which are never cleaned * policies: add tests for process, don't clean full cache * admin: create event when new version is seen * events: move utils to separate file * admin: add tests for admin tasks * events: add .set_user method to ensure users have correct attributes set * core: add test for property_mapping errors with user and request --- authentik/admin/api/metrics.py | 2 +- authentik/admin/api/version.py | 2 +- authentik/admin/api/workers.py | 2 +- authentik/admin/tasks.py | 17 +- authentik/admin/tests/test_tasks.py | 76 ++++ authentik/api/v2/urls.py | 4 +- authentik/audit/apps.py | 16 - authentik/audit/urls.py | 9 - authentik/core/api/applications.py | 4 +- authentik/core/api/tokens.py | 2 +- authentik/core/expression.py | 17 + authentik/core/tests/test_property_mapping.py | 56 +++ authentik/core/views/impersonate.py | 2 +- authentik/{audit => events}/__init__.py | 0 authentik/{audit => events}/api.py | 4 +- authentik/events/apps.py | 16 + authentik/{audit => events}/middleware.py | 7 +- .../migrations/0001_initial.py | 4 +- .../migrations/0002_auto_20200918_2116.py | 2 +- .../migrations/0003_auto_20200917_1155.py | 8 +- .../migrations/0004_auto_20200921_1829.py | 2 +- .../migrations/0005_auto_20201005_2139.py | 2 +- .../migrations/0006_auto_20201017_2024.py | 2 +- .../migrations/0007_auto_20201215_0939.py | 41 ++ .../migrations/0008_auto_20201220_1651.py | 42 ++ .../{audit => events}/migrations/__init__.py | 0 authentik/{audit => events}/models.py | 101 ++--- authentik/{audit => events}/signals.py | 4 +- .../templates/events}/list.html | 2 +- authentik/{audit => events}/tests/__init__.py | 0 .../{audit => events}/tests/test_event.py | 8 +- authentik/events/urls.py | 9 + authentik/events/utils.py | 86 +++++ authentik/{audit => events}/views.py | 6 +- authentik/flows/planner.py | 2 +- authentik/flows/views.py | 2 +- authentik/lib/expression/evaluator.py | 23 +- authentik/policies/expression/evaluator.py | 30 +- authentik/policies/expression/models.py | 5 +- authentik/policies/expression/tests.py | 22 ++ authentik/policies/forms.py | 2 +- .../0004_policy_execution_logging.py | 21 + authentik/policies/models.py | 10 + authentik/policies/process.py | 71 ++-- authentik/policies/tests/test_engine.py | 19 +- authentik/policies/tests/test_process.py | 105 +++++ authentik/policies/types.py | 9 +- authentik/providers/oauth2/views/authorize.py | 2 +- authentik/providers/saml/views.py | 2 +- authentik/root/settings.py | 4 +- authentik/sources/oauth/views/callback.py | 2 +- authentik/sources/oauth/views/flows.py | 2 +- authentik/stages/otp_static/views.py | 2 +- authentik/stages/otp_time/views.py | 2 +- .../system_migrations/to_0_13_events..py | 21 + swagger.yaml | 365 +++++++++--------- web/src/api/Events.ts | 6 +- web/src/interfaces/AdminInterface.ts | 2 +- .../admin-overview/TopApplicationsTable.ts | 4 +- 59 files changed, 907 insertions(+), 383 deletions(-) create mode 100644 authentik/admin/tests/test_tasks.py delete mode 100644 authentik/audit/apps.py delete mode 100644 authentik/audit/urls.py create mode 100644 authentik/core/tests/test_property_mapping.py rename authentik/{audit => events}/__init__.py (100%) rename authentik/{audit => events}/api.py (96%) create mode 100644 authentik/events/apps.py rename authentik/{audit => events}/middleware.py (94%) rename authentik/{audit => events}/migrations/0001_initial.py (95%) rename authentik/{audit => events}/migrations/0002_auto_20200918_2116.py (95%) rename authentik/{audit => events}/migrations/0003_auto_20200917_1155.py (89%) rename authentik/{audit => events}/migrations/0004_auto_20200921_1829.py (95%) rename authentik/{audit => events}/migrations/0005_auto_20201005_2139.py (95%) rename authentik/{audit => events}/migrations/0006_auto_20201017_2024.py (96%) create mode 100644 authentik/events/migrations/0007_auto_20201215_0939.py create mode 100644 authentik/events/migrations/0008_auto_20201220_1651.py rename authentik/{audit => events}/migrations/__init__.py (100%) rename authentik/{audit => events}/models.py (61%) rename authentik/{audit => events}/signals.py (97%) rename authentik/{audit/templates/audit => events/templates/events}/list.html (98%) rename authentik/{audit => events}/tests/__init__.py (100%) rename authentik/{audit => events}/tests/test_event.py (90%) create mode 100644 authentik/events/urls.py create mode 100644 authentik/events/utils.py rename authentik/{audit => events}/views.py (81%) create mode 100644 authentik/policies/migrations/0004_policy_execution_logging.py create mode 100644 authentik/policies/tests/test_process.py create mode 100644 lifecycle/system_migrations/to_0_13_events..py 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 {