enterprise: add full audit log
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
eeb9716173
commit
054a2e8aec
|
@ -7,6 +7,8 @@ from dacite.config import Config
|
|||
from dacite.core import from_dict
|
||||
from dacite.exceptions import DaciteError
|
||||
from deepmerge import always_merger
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Model
|
||||
from django.db.models.query_utils import Q
|
||||
|
@ -57,8 +59,11 @@ def excluded_models() -> list[type[Model]]:
|
|||
from django.contrib.auth.models import User as DjangoUser
|
||||
|
||||
return (
|
||||
# Django only classes
|
||||
DjangoUser,
|
||||
DjangoGroup,
|
||||
ContentType,
|
||||
Permission,
|
||||
# Base classes
|
||||
Provider,
|
||||
Source,
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
"""Enterprise app config"""
|
||||
from django.conf import settings
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
|
@ -17,3 +19,9 @@ class AuthentikEnterpriseConfig(EnterpriseConfig):
|
|||
def reconcile_load_enterprise_signals(self):
|
||||
"""Load enterprise signals"""
|
||||
self.import_module("authentik.enterprise.signals")
|
||||
|
||||
def reconcile_install_middleware(self):
|
||||
"""Install enterprise audit middleware"""
|
||||
orig_import = "authentik.events.middleware.AuditMiddleware"
|
||||
new_import = "authentik.enterprise.middleware.EnterpriseAuditMiddleware"
|
||||
settings.MIDDLEWARE = [new_import if x == orig_import else x for x in settings.MIDDLEWARE]
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
"""Enterprise audit middleware"""
|
||||
from copy import deepcopy
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
from deepdiff import DeepDiff
|
||||
from django.core.files import File
|
||||
from django.db import connection
|
||||
from django.db.models import Model
|
||||
from django.db.models.expressions import BaseExpression, Combinable
|
||||
from django.db.models.signals import post_init
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.models import LicenseKey
|
||||
from authentik.events.middleware import AuditMiddleware, should_log_model
|
||||
|
||||
|
||||
class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
"""Enterprise audit middleware"""
|
||||
|
||||
_enabled = False
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
super().__init__(get_response)
|
||||
self._enabled = LicenseKey.get_total().is_valid()
|
||||
|
||||
def connect(self, request: HttpRequest):
|
||||
super().connect(request)
|
||||
if not self._enabled:
|
||||
return
|
||||
user = getattr(request, "user", self.anonymous_user)
|
||||
if not user.is_authenticated:
|
||||
user = self.anonymous_user
|
||||
if not hasattr(request, "request_id"):
|
||||
return
|
||||
post_init.connect(
|
||||
partial(self.post_init_handler, user=user, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
|
||||
def disconnect(self, request: HttpRequest):
|
||||
super().disconnect(request)
|
||||
if not self._enabled:
|
||||
return
|
||||
if not hasattr(request, "request_id"):
|
||||
return
|
||||
post_init.disconnect(dispatch_uid=request.request_id)
|
||||
|
||||
def serialize_simple(self, model: Model) -> dict:
|
||||
data = {}
|
||||
deferred_fields = model.get_deferred_fields()
|
||||
for field in model._meta.concrete_fields:
|
||||
value = None
|
||||
if field.remote_field:
|
||||
continue
|
||||
|
||||
if field.get_attname() in deferred_fields:
|
||||
continue
|
||||
|
||||
field_value = getattr(model, field.attname)
|
||||
if isinstance(value, File):
|
||||
field_value = value.name
|
||||
|
||||
# If current field value is an expression, we are not evaluating it
|
||||
if isinstance(field_value, (BaseExpression, Combinable)):
|
||||
continue
|
||||
field_value = field.to_python(field_value)
|
||||
data[field.name] = deepcopy(field_value)
|
||||
return data
|
||||
|
||||
def post_init_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||
if not should_log_model(instance):
|
||||
return
|
||||
if hasattr(instance, "_previous_state"):
|
||||
return
|
||||
before = len(connection.queries)
|
||||
setattr(instance, "_previous_state", self.serialize_simple(instance))
|
||||
after = len(connection.queries)
|
||||
if after > before:
|
||||
raise AssertionError("More queries generated by serialize_simple")
|
||||
|
||||
def post_save_handler(
|
||||
self, user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||
):
|
||||
thread_kwargs = {}
|
||||
if hasattr(instance, "_previous_state") or created:
|
||||
# Get current state
|
||||
prev_state = getattr(instance, "_previous_state", {})
|
||||
new_state = self.serialize_simple(instance)
|
||||
diff = DeepDiff(prev_state, new_state)
|
||||
thread_kwargs["diff"] = diff
|
||||
return super().post_save_handler(
|
||||
user, request, sender, instance, created, thread_kwargs, **_
|
||||
)
|
|
@ -10,45 +10,28 @@ from django.db.models import Model
|
|||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from guardian.models import UserObjectPermission
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
Group,
|
||||
PropertyMapping,
|
||||
Provider,
|
||||
Source,
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.blueprints.v1.importer import excluded_models
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken
|
||||
from authentik.events.models import Event, EventAction, Notification
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.flows.models import FlowToken, Stage
|
||||
from authentik.lib.sentry import before_send
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.outposts.models import OutpostServiceConnection
|
||||
from authentik.policies.models import Policy, PolicyBindingModel
|
||||
from authentik.policies.reputation.models import Reputation
|
||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
||||
from authentik.stages.authenticator_static.models import StaticToken
|
||||
|
||||
IGNORED_MODELS = (
|
||||
IGNORED_MODELS = tuple(
|
||||
excluded_models()
|
||||
+ (
|
||||
Event,
|
||||
Notification,
|
||||
UserObjectPermission,
|
||||
AuthenticatedSession,
|
||||
StaticToken,
|
||||
Session,
|
||||
FlowToken,
|
||||
Provider,
|
||||
Source,
|
||||
PropertyMapping,
|
||||
UserSourceConnection,
|
||||
Stage,
|
||||
OutpostServiceConnection,
|
||||
Policy,
|
||||
PolicyBindingModel,
|
||||
AuthorizationCode,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
|
@ -57,6 +40,7 @@ IGNORED_MODELS = (
|
|||
Reputation,
|
||||
ConnectionToken,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def should_log_model(model: Model) -> bool:
|
||||
|
@ -96,9 +80,11 @@ class AuditMiddleware:
|
|||
|
||||
get_response: Callable[[HttpRequest], HttpResponse]
|
||||
anonymous_user: User = None
|
||||
logger: BoundLogger
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
self.get_response = get_response
|
||||
self.logger = get_logger().bind()
|
||||
|
||||
def _ensure_fallback_user(self):
|
||||
"""Defer fetching anonymous user until we have to"""
|
||||
|
@ -116,21 +102,18 @@ class AuditMiddleware:
|
|||
user = self.anonymous_user
|
||||
if not hasattr(request, "request_id"):
|
||||
return
|
||||
post_save_handler = partial(self.post_save_handler, user=user, request=request)
|
||||
pre_delete_handler = partial(self.pre_delete_handler, user=user, request=request)
|
||||
m2m_changed_handler = partial(self.m2m_changed_handler, user=user, request=request)
|
||||
post_save.connect(
|
||||
post_save_handler,
|
||||
partial(self.post_save_handler, user=user, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
pre_delete.connect(
|
||||
pre_delete_handler,
|
||||
partial(self.pre_delete_handler, user=user, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
m2m_changed.connect(
|
||||
m2m_changed_handler,
|
||||
partial(self.m2m_changed_handler, user=user, request=request),
|
||||
dispatch_uid=request.request_id,
|
||||
weak=False,
|
||||
)
|
||||
|
@ -173,19 +156,26 @@ class AuditMiddleware:
|
|||
)
|
||||
thread.run()
|
||||
|
||||
@staticmethod
|
||||
def post_save_handler(
|
||||
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||
self,
|
||||
user: User,
|
||||
request: HttpRequest,
|
||||
sender,
|
||||
instance: Model,
|
||||
created: bool,
|
||||
thread_kwargs: Optional[dict] = None,
|
||||
**_,
|
||||
):
|
||||
"""Signal handler for all object's post_save"""
|
||||
if not should_log_model(instance):
|
||||
return
|
||||
|
||||
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
|
||||
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
||||
thread = EventNewThread(action, request, user=user, model=model_to_dict(instance))
|
||||
thread.kwargs.update(thread_kwargs or {})
|
||||
thread.run()
|
||||
|
||||
@staticmethod
|
||||
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||
def pre_delete_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||
"""Signal handler for all object's pre_delete"""
|
||||
if not should_log_model(instance): # pragma: no cover
|
||||
return
|
||||
|
@ -197,9 +187,8 @@ class AuditMiddleware:
|
|||
model=model_to_dict(instance),
|
||||
).run()
|
||||
|
||||
@staticmethod
|
||||
def m2m_changed_handler(
|
||||
user: User, request: HttpRequest, sender, instance: Model, action: str, **_
|
||||
self, user: User, request: HttpRequest, sender, instance: Model, action: str, **_
|
||||
):
|
||||
"""Signal handler for all object's m2m_changed"""
|
||||
if action not in ["pre_add", "pre_remove", "post_clear"]:
|
||||
|
|
|
@ -1057,6 +1057,24 @@ files = [
|
|||
{file = "debugpy-1.8.0.zip", hash = "sha256:12af2c55b419521e33d5fb21bd022df0b5eb267c3e178f1d374a63a2a6bdccd0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepdiff"
|
||||
version = "6.7.1"
|
||||
description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "deepdiff-6.7.1-py3-none-any.whl", hash = "sha256:58396bb7a863cbb4ed5193f548c56f18218060362311aa1dc36397b2f25108bd"},
|
||||
{file = "deepdiff-6.7.1.tar.gz", hash = "sha256:b367e6fa6caac1c9f500adc79ada1b5b1242c50d5f716a1a4362030197847d30"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
ordered-set = ">=4.0.2,<4.2.0"
|
||||
|
||||
[package.extras]
|
||||
cli = ["click (==8.1.3)", "pyyaml (==6.0.1)"]
|
||||
optimize = ["orjson"]
|
||||
|
||||
[[package]]
|
||||
name = "deepmerge"
|
||||
version = "1.1.1"
|
||||
|
@ -2473,6 +2491,20 @@ files = [
|
|||
{file = "opencontainers-0.0.14.tar.gz", hash = "sha256:fde3b8099b56b5c956415df8933e2227e1914e805a277b844f2f9e52341738f2"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-set"
|
||||
version = "4.1.0"
|
||||
description = "An OrderedSet is a custom MutableSet that remembers its order, so that every"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"},
|
||||
{file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["black", "mypy", "pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "outcome"
|
||||
version = "1.3.0.post0"
|
||||
|
@ -4488,4 +4520,4 @@ files = [
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "~3.12"
|
||||
content-hash = "6dcbc2c6d02643a72285e075528ec0841b9c8fda244632386ec19efb7350d4cd"
|
||||
content-hash = "b5127f147f007d9fd1fa661ae66f02f85d9143dda27e1ea5fe4568230c12b7b2"
|
||||
|
|
|
@ -125,6 +125,7 @@ channels-redis = "*"
|
|||
codespell = "*"
|
||||
colorama = "*"
|
||||
dacite = "*"
|
||||
deepdiff = "*"
|
||||
deepmerge = "*"
|
||||
defusedxml = "*"
|
||||
django = "*"
|
||||
|
@ -151,6 +152,7 @@ lxml = [
|
|||
# 4.9.x works with previous libxml2 versions, which is what we get on linux
|
||||
{ version = "4.9.4", platform = "linux" },
|
||||
]
|
||||
jsonpatch = "*"
|
||||
opencontainers = { extras = ["reggie"], version = "*" }
|
||||
packaging = "*"
|
||||
paramiko = "*"
|
||||
|
@ -176,7 +178,6 @@ webauthn = "*"
|
|||
wsproto = "*"
|
||||
xmlsec = "*"
|
||||
zxcvbn = "*"
|
||||
jsonpatch = "*"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
bandit = "*"
|
||||
|
|
|
@ -154,6 +154,12 @@ export class EventViewPage extends AKElement {
|
|||
<div class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-8-col-on-xl">
|
||||
<ak-event-info .event=${this.event}></ak-event-info>
|
||||
</div>
|
||||
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
|
||||
<div class="pf-c-card__title">${msg("Raw event info")}</div>
|
||||
<div class="pf-c-card__body">
|
||||
<pre>${JSON.stringify(this.event, null, 4)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
|
|
@ -248,6 +248,7 @@ export class EventInfo extends AKElement {
|
|||
<div class="pf-c-card__body">
|
||||
${this.getModelInfo(this.event.context?.model as EventModel)}
|
||||
</div>
|
||||
<ak-expand>${this.renderDefaultResponse()}</ak-expand>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
Reference in New Issue