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
This commit is contained in:
Jens L 2020-12-20 22:04:29 +01:00 committed by GitHub
parent 4d88dcff08
commit a4dc6d13b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 907 additions and 383 deletions

View File

@ -17,7 +17,7 @@ from rest_framework.response import Response
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from rest_framework.viewsets import ViewSet 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]]: def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]:

View File

@ -51,7 +51,7 @@ class VersionViewSet(ListModelMixin, GenericViewSet):
permission_classes = [IsAdminUser] permission_classes = [IsAdminUser]
def get_queryset(self): def get_queryset(self): # pragma: no cover
return None return None
@swagger_auto_schema(responses={200: VersionSerializer(many=True)}) @swagger_auto_schema(responses={200: VersionSerializer(many=True)})

View File

@ -15,7 +15,7 @@ class WorkerViewSet(ListModelMixin, GenericViewSet):
serializer_class = Serializer serializer_class = Serializer
permission_classes = [IsAdminUser] permission_classes = [IsAdminUser]
def get_queryset(self): def get_queryset(self): # pragma: no cover
return None return None
def list(self, request: Request) -> Response: def list(self, request: Request) -> Response:

View File

@ -1,8 +1,11 @@
"""authentik admin tasks""" """authentik admin tasks"""
from django.core.cache import cache from django.core.cache import cache
from packaging.version import parse
from requests import RequestException, get from requests import RequestException, get
from structlog import get_logger 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.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
@ -19,12 +22,24 @@ def update_latest_version(self: MonitoredTask):
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
tag_name = data.get("tag_name") 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( self.set_status(
TaskResult( TaskResult(
TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] 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: except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))

View File

@ -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()
)

View File

@ -11,7 +11,6 @@ from authentik.admin.api.version import VersionViewSet
from authentik.admin.api.workers import WorkerViewSet from authentik.admin.api.workers import WorkerViewSet
from authentik.api.v2.config import ConfigsViewSet from authentik.api.v2.config import ConfigsViewSet
from authentik.api.v2.messages import MessagesViewSet from authentik.api.v2.messages import MessagesViewSet
from authentik.audit.api import EventViewSet
from authentik.core.api.applications import ApplicationViewSet from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.groups import GroupViewSet from authentik.core.api.groups import GroupViewSet
from authentik.core.api.propertymappings import PropertyMappingViewSet 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.tokens import TokenViewSet
from authentik.core.api.users import UserViewSet from authentik.core.api.users import UserViewSet
from authentik.crypto.api import CertificateKeyPairViewSet from authentik.crypto.api import CertificateKeyPairViewSet
from authentik.events.api import EventViewSet
from authentik.flows.api import ( from authentik.flows.api import (
FlowCacheViewSet, FlowCacheViewSet,
FlowStageBindingViewSet, FlowStageBindingViewSet,
@ -96,7 +96,7 @@ router.register("flows/bindings", FlowStageBindingViewSet)
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet) router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
router.register("audit/events", EventViewSet) router.register("events/events", EventViewSet)
router.register("sources/all", SourceViewSet) router.register("sources/all", SourceViewSet)
router.register("sources/ldap", LDAPSourceViewSet) router.register("sources/ldap", LDAPSourceViewSet)

View File

@ -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")

View File

@ -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"),
]

View File

@ -12,8 +12,8 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.admin.api.metrics import get_events_per_1h from authentik.admin.api.metrics import get_events_per_1h
from authentik.audit.models import EventAction
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import EventAction
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
@ -78,7 +78,7 @@ class ApplicationViewSet(ModelViewSet):
get_objects_for_user(request.user, "authentik_core.view_application"), get_objects_for_user(request.user, "authentik_core.view_application"),
slug=slug, slug=slug,
) )
if not request.user.has_perm("authentik_audit.view_event"): if not request.user.has_perm("authentik_events.view_event"):
raise Http404 raise Http404
return Response( return Response(
get_events_per_1h( get_events_per_1h(

View File

@ -6,8 +6,8 @@ from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.audit.models import Event, EventAction
from authentik.core.models import Token from authentik.core.models import Token
from authentik.events.models import Event, EventAction
class TokenSerializer(ModelSerializer): class TokenSerializer(ModelSerializer):

View File

@ -1,9 +1,11 @@
"""Property Mapping Evaluator""" """Property Mapping Evaluator"""
from traceback import format_tb
from typing import Optional from typing import Optional
from django.http import HttpRequest from django.http import HttpRequest
from authentik.core.models import User from authentik.core.models import User
from authentik.events.models import Event, EventAction
from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.expression.evaluator import BaseEvaluator
@ -19,3 +21,18 @@ class PropertyMappingEvaluator(BaseEvaluator):
if request: if request:
self._context["request"] = request self._context["request"] = request
self._context.update(**kwargs) 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()

View File

@ -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")

View File

@ -5,12 +5,12 @@ from django.shortcuts import get_object_or_404, redirect
from django.views import View from django.views import View
from structlog import get_logger from structlog import get_logger
from authentik.audit.models import Event, EventAction
from authentik.core.middleware import ( from authentik.core.middleware import (
SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_ORIGINAL_USER,
SESSION_IMPERSONATE_USER, SESSION_IMPERSONATE_USER,
) )
from authentik.core.models import User from authentik.core.models import User
from authentik.events.models import Event, EventAction
LOGGER = get_logger() LOGGER = get_logger()

View File

@ -1,4 +1,4 @@
"""Audit API Views""" """Events API Views"""
from django.db.models.aggregates import Count from django.db.models.aggregates import Count
from django.db.models.fields.json import KeyTextTransform from django.db.models.fields.json import KeyTextTransform
from drf_yasg2.utils import swagger_auto_schema 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.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.audit.models import Event, EventAction from authentik.events.models import Event, EventAction
class EventSerializer(ModelSerializer): class EventSerializer(ModelSerializer):

16
authentik/events/apps.py Normal file
View File

@ -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")

View File

@ -1,4 +1,4 @@
"""Audit middleware""" """Events middleware"""
from functools import partial from functools import partial
from typing import Callable 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.db.models.signals import post_save, pre_delete
from django.http import HttpRequest, HttpResponse 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.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: class AuditMiddleware:

View File

@ -63,8 +63,8 @@ class Migration(migrations.Migration):
), ),
], ],
options={ options={
"verbose_name": "Audit Event", "verbose_name": "Event",
"verbose_name_plural": "Audit Events", "verbose_name_plural": "Events",
}, },
), ),
] ]

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_audit", "0001_initial"), ("authentik_events", "0001_initial"),
] ]
operations = [ operations = [

View File

@ -3,11 +3,11 @@ from django.apps.registry import Apps
from django.db import migrations, models from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor 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): 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 db_alias = schema_editor.connection.alias
for event in Event.objects.all(): 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 # Because event objects cannot be updated, we have to re-create them
event.pk = None event.pk = None
event.user_json = ( 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._state.adding = True
event.save() event.save()
@ -24,7 +24,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_audit", "0002_auto_20200918_2116"), ("authentik_events", "0002_auto_20200918_2116"),
] ]
operations = [ operations = [

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_audit", "0003_auto_20200917_1155"), ("authentik_events", "0003_auto_20200917_1155"),
] ]
operations = [ operations = [

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_audit", "0004_auto_20200921_1829"), ("authentik_events", "0004_auto_20200921_1829"),
] ]
operations = [ operations = [

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_audit", "0005_auto_20201005_2139"), ("authentik_events", "0005_auto_20201005_2139"),
] ]
operations = [ operations = [

View File

@ -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"),
]
),
),
]

View File

@ -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"),
]
),
),
]

View File

@ -1,17 +1,14 @@
"""authentik audit models""" """authentik events models"""
from inspect import getmodule, stack from inspect import getmodule, stack
from typing import Any, Dict, Optional, Union from typing import Optional, Union
from uuid import UUID, uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models.base import Model
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.translation import gettext as _ 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 structlog import get_logger
from authentik.core.middleware import ( from authentik.core.middleware import (
@ -19,78 +16,14 @@ from authentik.core.middleware import (
SESSION_IMPERSONATE_USER, SESSION_IMPERSONATE_USER,
) )
from authentik.core.models import 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 from authentik.lib.utils.http import get_client_ip
LOGGER = get_logger("authentik.audit") LOGGER = get_logger("authentik.events")
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
class EventAction(models.TextChoices): 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 = "login"
LOGIN_FAILED = "login_failed" LOGIN_FAILED = "login_failed"
@ -111,15 +44,21 @@ class EventAction(models.TextChoices):
IMPERSONATION_STARTED = "impersonation_started" IMPERSONATION_STARTED = "impersonation_started"
IMPERSONATION_ENDED = "impersonation_ended" IMPERSONATION_ENDED = "impersonation_ended"
POLICY_EXECUTION = "policy_execution"
POLICY_EXCEPTION = "policy_exception"
PROPERTY_MAPPING_EXCEPTION = "property_mapping_exception"
MODEL_CREATED = "model_created" MODEL_CREATED = "model_created"
MODEL_UPDATED = "model_updated" MODEL_UPDATED = "model_updated"
MODEL_DELETED = "model_deleted" MODEL_DELETED = "model_deleted"
UPDATE_AVAILABLE = "update_available"
CUSTOM_PREFIX = "custom_" CUSTOM_PREFIX = "custom_"
class Event(models.Model): 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) event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
user = models.JSONField(default=dict) user = models.JSONField(default=dict)
@ -151,6 +90,12 @@ class Event(models.Model):
event = Event(action=action, app=app, context=cleaned_kwargs) event = Event(action=action, app=app, context=cleaned_kwargs)
return event 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( def from_http(
self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None
) -> "Event": ) -> "Event":
@ -185,7 +130,7 @@ class Event(models.Model):
"you may not edit an existing %s" % self._meta.model_name "you may not edit an existing %s" % self._meta.model_name
) )
LOGGER.debug( LOGGER.debug(
"Created Audit event", "Created Event",
action=self.action, action=self.action,
context=self.context, context=self.context,
client_ip=self.client_ip, client_ip=self.client_ip,
@ -195,5 +140,5 @@ class Event(models.Model):
class Meta: class Meta:
verbose_name = _("Audit Event") verbose_name = _("Event")
verbose_name_plural = _("Audit Events") verbose_name_plural = _("Events")

View File

@ -1,4 +1,4 @@
"""authentik audit signal listener""" """authentik events signal listener"""
from threading import Thread from threading import Thread
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@ -10,9 +10,9 @@ from django.contrib.auth.signals import (
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from authentik.audit.models import Event, EventAction
from authentik.core.models import User from authentik.core.models import User
from authentik.core.signals import password_changed 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.models import Invitation
from authentik.stages.invitation.signals import invitation_created, invitation_used from authentik.stages.invitation.signals import invitation_created, invitation_used
from authentik.stages.user_write.signals import user_write from authentik.stages.user_write.signals import user_write

View File

@ -9,7 +9,7 @@
<div class="pf-c-content"> <div class="pf-c-content">
<h1> <h1>
<i class="pf-icon pf-icon-catalog"></i> <i class="pf-icon pf-icon-catalog"></i>
{% trans 'Audit Log' %} {% trans 'Event Log' %}
</h1> </h1>
</div> </div>
</section> </section>

View File

@ -1,15 +1,15 @@
"""audit event tests""" """events event tests"""
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from guardian.shortcuts import get_anonymous_user 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 from authentik.policies.dummy.models import DummyPolicy
class TestAuditEvent(TestCase): class TestEvents(TestCase):
"""Test Audit Event""" """Test Event"""
def test_new_with_model(self): def test_new_with_model(self):
"""Create a new Event passing a model as kwarg""" """Create a new Event passing a model as kwarg"""

9
authentik/events/urls.py Normal file
View File

@ -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"),
]

86
authentik/events/utils.py Normal file
View File

@ -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

View File

@ -4,7 +4,7 @@ from django.views.generic import ListView
from guardian.mixins import PermissionListMixin from guardian.mixins import PermissionListMixin
from authentik.admin.views.utils import SearchListMixin, UserPaginateListMixin from authentik.admin.views.utils import SearchListMixin, UserPaginateListMixin
from authentik.audit.models import Event from authentik.events.models import Event
class EventListView( class EventListView(
@ -17,8 +17,8 @@ class EventListView(
"""Show list of all invitations""" """Show list of all invitations"""
model = Event model = Event
template_name = "audit/list.html" template_name = "events/list.html"
permission_required = "authentik_audit.view_event" permission_required = "authentik_events.view_event"
ordering = "-created" ordering = "-created"
search_fields = [ search_fields = [

View File

@ -8,8 +8,8 @@ from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
from structlog import get_logger from structlog import get_logger
from authentik.audit.models import cleanse_dict
from authentik.core.models import User from authentik.core.models import User
from authentik.events.models import cleanse_dict
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.markers import ReevaluateMarker, StageMarker from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import Flow, FlowStageBinding, Stage from authentik.flows.models import Flow, FlowStageBinding, Stage

View File

@ -17,8 +17,8 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from structlog import get_logger from structlog import get_logger
from authentik.audit.models import cleanse_dict
from authentik.core.models import USER_ATTRIBUTE_DEBUG 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.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
from authentik.flows.planner import ( from authentik.flows.planner import (

View File

@ -80,11 +80,15 @@ class BaseEvaluator:
span: Span span: Span
span.set_data("expression", expression_source) span.set_data("expression", expression_source)
param_keys = self._context.keys() param_keys = self._context.keys()
ast_obj = compile( try:
self.wrap_expression(expression_source, param_keys), ast_obj = compile(
self._filename, self.wrap_expression(expression_source, param_keys),
"exec", self._filename,
) "exec",
)
except (SyntaxError, ValueError) as exc:
self.handle_error(exc, expression_source)
raise exc
try: try:
_locals = self._context _locals = self._context
# Yes this is an exec, yes it is potentially bad. Since we limit what variables are # 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 exec(ast_obj, self._globals, _locals) # nosec # noqa
result = _locals["result"] result = _locals["result"]
except Exception as exc: except Exception as exc:
LOGGER.warning("Expression error", exc=exc) self.handle_error(exc, expression_source)
raise raise exc
return result 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: def validate(self, expression: str) -> bool:
"""Validate expression's syntax, raise ValidationError if Syntax is invalid""" """Validate expression's syntax, raise ValidationError if Syntax is invalid"""
param_keys = self._context.keys() param_keys = self._context.keys()

View File

@ -1,16 +1,21 @@
"""authentik expression policy evaluator""" """authentik expression policy evaluator"""
from ipaddress import ip_address, ip_network 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 django.http import HttpRequest
from structlog import get_logger 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.flows.planner import PLAN_CONTEXT_SSO
from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.lib.utils.http import get_client_ip from authentik.lib.utils.http import get_client_ip
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
if TYPE_CHECKING:
from authentik.policies.expression.models import ExpressionPolicy
class PolicyEvaluator(BaseEvaluator): class PolicyEvaluator(BaseEvaluator):
@ -18,6 +23,8 @@ class PolicyEvaluator(BaseEvaluator):
_messages: List[str] _messages: List[str]
policy: Optional["ExpressionPolicy"] = None
def __init__(self, policy_name: str): def __init__(self, policy_name: str):
super().__init__() super().__init__()
self._messages = [] self._messages = []
@ -45,15 +52,30 @@ class PolicyEvaluator(BaseEvaluator):
self._context["ak_client_ip"] = ip_address( self._context["ak_client_ip"] = ip_address(
get_client_ip(request) or "255.255.255.255" 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: def evaluate(self, expression_source: str) -> PolicyResult:
"""Parse and evaluate expression. Policy is expected to return a truthy object. """Parse and evaluate expression. Policy is expected to return a truthy object.
Messages can be added using 'do ak_message()'.""" Messages can be added using 'do ak_message()'."""
try: try:
result = super().evaluate(expression_source) result = super().evaluate(expression_source)
except (ValueError, SyntaxError) as exc:
return PolicyResult(False, str(exc))
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
LOGGER.warning("Expression error", exc=exc) LOGGER.warning("Expression error", exc=exc)
return PolicyResult(False, str(exc)) return PolicyResult(False, str(exc))

View File

@ -31,11 +31,14 @@ class ExpressionPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult: def passes(self, request: PolicyRequest) -> PolicyResult:
"""Evaluate and render expression. Returns PolicyResult(false) on error.""" """Evaluate and render expression. Returns PolicyResult(false) on error."""
evaluator = PolicyEvaluator(self.name) evaluator = PolicyEvaluator(self.name)
evaluator.policy = self
evaluator.set_policy_request(request) evaluator.set_policy_request(request)
return evaluator.evaluate(self.expression) return evaluator.evaluate(self.expression)
def save(self, *args, **kwargs): 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) return super().save(*args, **kwargs)
class Meta: class Meta:

View File

@ -3,7 +3,9 @@ from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase
from guardian.shortcuts import get_anonymous_user 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.evaluator import PolicyEvaluator
from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
@ -13,6 +15,14 @@ class TestEvaluator(TestCase):
def setUp(self): def setUp(self):
self.request = PolicyRequest(user=get_anonymous_user()) 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): def test_valid(self):
"""test simple value expression""" """test simple value expression"""
template = "return True" template = "return True"
@ -37,6 +47,12 @@ class TestEvaluator(TestCase):
result = evaluator.evaluate(template) result = evaluator.evaluate(template)
self.assertEqual(result.passing, False) self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("invalid syntax (test, line 3)",)) 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): def test_undefined(self):
"""test undefined result""" """test undefined result"""
@ -46,6 +62,12 @@ class TestEvaluator(TestCase):
result = evaluator.evaluate(template) result = evaluator.evaluate(template)
self.assertEqual(result.passing, False) self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("name 'foo' is not defined",)) 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): def test_validate(self):
"""test validate""" """test validate"""

View File

@ -5,7 +5,7 @@ from django import forms
from authentik.lib.widgets import GroupedModelChoiceField from authentik.lib.widgets import GroupedModelChoiceField
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
GENERAL_FIELDS = ["name"] GENERAL_FIELDS = ["name", "execution_logging"]
GENERAL_SERIALIZER_FIELDS = ["pk", "name"] GENERAL_SERIALIZER_FIELDS = ["pk", "name"]

View File

@ -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.",
),
),
]

View File

@ -81,6 +81,16 @@ class Policy(SerializerModel, CreatedUpdatedModel):
name = models.TextField(blank=True, null=True) 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() objects = InheritanceAutoManager()
@property @property

View File

@ -8,6 +8,7 @@ from sentry_sdk.hub import Hub
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
from structlog import get_logger from structlog import get_logger
from authentik.events.models import Event, EventAction
from authentik.policies.exceptions import PolicyException from authentik.policies.exceptions import PolicyException
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
@ -48,40 +49,48 @@ class PolicyProcess(Process):
def execute(self) -> PolicyResult: def execute(self) -> PolicyResult:
"""Run actual policy, returns result""" """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( with Hub.current.start_span(
op="policy.process.execute", op="policy.process.execute",
) as span: ) as span:
span: Span span: Span
span.set_data("policy", self.binding.policy) span.set_data("policy", self.binding.policy)
span.set_data("request", self.request) span.set_data("request", self.request)
LOGGER.debug( self.connection.send(self.execute())
"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())

View File

@ -7,13 +7,14 @@ from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
from authentik.policies.tests.test_process import clear_policy_cache
class TestPolicyEngine(TestCase): class TestPolicyEngine(TestCase):
"""PolicyEngine tests""" """PolicyEngine tests"""
def setUp(self): def setUp(self):
cache.clear() clear_policy_cache()
self.user = User.objects.create_user(username="policyuser") self.user = User.objects.create_user(username="policyuser")
self.policy_false = DummyPolicy.objects.create( self.policy_false = DummyPolicy.objects.create(
result=False, wait_min=0, wait_max=1 result=False, wait_min=0, wait_max=1
@ -84,10 +85,18 @@ class TestPolicyEngine(TestCase):
def test_engine_cache(self): def test_engine_cache(self):
"""Ensure empty policy list passes""" """Ensure empty policy list passes"""
pbm = PolicyBindingModel.objects.create() 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) 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(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(engine.build().passing, False)
self.assertEqual(len(cache.keys("policy_*")), 1) self.assertEqual(
len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1
)

View File

@ -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

View File

@ -1,6 +1,7 @@
"""policy structures""" """policy structures"""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from django.db.models import Model from django.db.models import Model
@ -11,6 +12,7 @@ if TYPE_CHECKING:
from authentik.policies.models import Policy from authentik.policies.models import Policy
@dataclass
class PolicyRequest: class PolicyRequest:
"""Data-class to hold policy request data""" """Data-class to hold policy request data"""
@ -20,6 +22,7 @@ class PolicyRequest:
context: Dict[str, str] context: Dict[str, str]
def __init__(self, user: User): def __init__(self, user: User):
super().__init__()
self.user = user self.user = user
self.http_request = None self.http_request = None
self.obj = None self.obj = None
@ -29,6 +32,7 @@ class PolicyRequest:
return f"<PolicyRequest user={self.user}>" return f"<PolicyRequest user={self.user}>"
@dataclass
class PolicyResult: class PolicyResult:
"""Small data-class to hold policy results""" """Small data-class to hold policy results"""
@ -39,6 +43,7 @@ class PolicyResult:
source_results: Optional[List["PolicyResult"]] source_results: Optional[List["PolicyResult"]]
def __init__(self, passing: bool, *messages: str): def __init__(self, passing: bool, *messages: str):
super().__init__()
self.passing = passing self.passing = passing
self.messages = messages self.messages = messages
self.source_policy = None self.source_policy = None
@ -49,5 +54,5 @@ class PolicyResult:
def __str__(self): def __str__(self):
if self.messages: if self.messages:
return f"PolicyResult passing={self.passing} messages={self.messages}" return f"<PolicyResult passing={self.passing} messages={self.messages}>"
return f"PolicyResult passing={self.passing}" return f"<PolicyResult passing={self.passing}>"

View File

@ -9,8 +9,8 @@ from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone from django.utils import timezone
from structlog import get_logger from structlog import get_logger
from authentik.audit.models import Event, EventAction
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import Event, EventAction
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import ( from authentik.flows.planner import (
PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_APPLICATION,

View File

@ -11,8 +11,8 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from structlog import get_logger from structlog import get_logger
from authentik.audit.models import Event, EventAction
from authentik.core.models import Application, Provider 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.models import in_memory_stage
from authentik.flows.planner import ( from authentik.flows.planner import (
PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_APPLICATION,

View File

@ -86,7 +86,7 @@ INSTALLED_APPS = [
"django.contrib.humanize", "django.contrib.humanize",
"authentik.admin.apps.AuthentikAdminConfig", "authentik.admin.apps.AuthentikAdminConfig",
"authentik.api.apps.AuthentikAPIConfig", "authentik.api.apps.AuthentikAPIConfig",
"authentik.audit.apps.AuthentikAuditConfig", "authentik.events.apps.AuthentikEventsConfig",
"authentik.crypto.apps.AuthentikCryptoConfig", "authentik.crypto.apps.AuthentikCryptoConfig",
"authentik.flows.apps.AuthentikFlowsConfig", "authentik.flows.apps.AuthentikFlowsConfig",
"authentik.outposts.apps.AuthentikOutpostConfig", "authentik.outposts.apps.AuthentikOutpostConfig",
@ -180,7 +180,7 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"authentik.core.middleware.RequestIDMiddleware", "authentik.core.middleware.RequestIDMiddleware",
"authentik.audit.middleware.AuditMiddleware", "authentik.events.middleware.AuditMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",

View File

@ -10,8 +10,8 @@ from django.utils.translation import gettext as _
from django.views.generic import View from django.views.generic import View
from structlog import get_logger from structlog import get_logger
from authentik.audit.models import Event, EventAction
from authentik.core.models import User 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.models import Flow, in_memory_stage
from authentik.flows.planner import ( from authentik.flows.planner import (
PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_PENDING_USER,

View File

@ -1,8 +1,8 @@
"""OAuth Stages""" """OAuth Stages"""
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from authentik.audit.models import Event, EventAction
from authentik.core.models import User 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.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.sources.oauth.models import UserOAuthSourceConnection from authentik.sources.oauth.models import UserOAuthSourceConnection

View File

@ -7,7 +7,7 @@ from django.views import View
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken 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 from authentik.stages.otp_static.models import OTPStaticStage

View File

@ -7,7 +7,7 @@ from django.views import View
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django_otp.plugins.otp_totp.models import TOTPDevice 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 from authentik.stages.otp_time.models import OTPTimeStage

View File

@ -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()

View File

@ -155,112 +155,6 @@ paths:
tags: tags:
- admin - admin
parameters: [] 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/: /core/applications/:
get: get:
operationId: core_applications_list operationId: core_applications_list
@ -968,6 +862,112 @@ paths:
required: true required: true
type: string type: string
format: uuid 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/: /flows/bindings/:
get: get:
operationId: flows_bindings_list operationId: flows_bindings_list
@ -6729,78 +6729,6 @@ definitions:
title: Outdated title: Outdated
type: boolean type: boolean
readOnly: true 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: Application:
description: Application Serializer description: Application Serializer
required: required:
@ -6986,6 +6914,82 @@ definitions:
description: Optional Private Key. If this is set, you can use this keypair description: Optional Private Key. If this is set, you can use this keypair
for encryption. for encryption.
type: string 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: Stage:
title: Stage obj title: Stage obj
description: Stage Serializer description: Stage Serializer
@ -7380,6 +7384,11 @@ definitions:
title: Name title: Name
type: string type: string
x-nullable: true 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__: __type__:
title: 'type ' title: 'type '
type: string type: string

View File

@ -1,9 +1,9 @@
import { DefaultClient } from "./Client"; import { DefaultClient } from "./Client";
export class AuditEvent { export class Event {
//audit/events/top_per_user/?filter_action=authorize_application // events/events/top_per_user/?filter_action=authorize_application
static topForUser(action: string): Promise<TopNEvent[]> { static topForUser(action: string): Promise<TopNEvent[]> {
return DefaultClient.fetch<TopNEvent[]>(["audit", "events", "top_per_user"], { return DefaultClient.fetch<TopNEvent[]>(["events", "events", "top_per_user"], {
"filter_action": action, "filter_action": action,
}); });
} }

View File

@ -9,7 +9,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
new SidebarItem("Monitor").children( new SidebarItem("Monitor").children(
new SidebarItem("Overview", "/administration/overview/"), new SidebarItem("Overview", "/administration/overview/"),
new SidebarItem("System Tasks", "/administration/tasks/"), new SidebarItem("System Tasks", "/administration/tasks/"),
new SidebarItem("Events", "/audit/audit"), new SidebarItem("Events", "/events/log/"),
).when((): Promise<boolean> => { ).when((): Promise<boolean> => {
return User.me().then(u => u.is_superuser); return User.me().then(u => u.is_superuser);
}), }),

View File

@ -1,6 +1,6 @@
import { gettext } from "django"; import { gettext } from "django";
import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element"; 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 { COMMON_STYLES } from "../../common/styles";
import "../../elements/Spinner"; import "../../elements/Spinner";
@ -16,7 +16,7 @@ export class TopApplicationsTable extends LitElement {
} }
firstUpdated(): void { firstUpdated(): void {
AuditEvent.topForUser("authorize_application").then(events => this.topN = events); Event.topForUser("authorize_application").then(events => this.topN = events);
} }
renderRow(event: TopNEvent): TemplateResult { renderRow(event: TopNEvent): TemplateResult {