enterprise: add full audit log

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens Langhammer 2024-01-15 17:37:05 +01:00
parent eeb9716173
commit 054a2e8aec
No known key found for this signature in database
8 changed files with 188 additions and 50 deletions

View File

@ -7,6 +7,8 @@ from dacite.config import Config
from dacite.core import from_dict from dacite.core import from_dict
from dacite.exceptions import DaciteError from dacite.exceptions import DaciteError
from deepmerge import always_merger 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.core.exceptions import FieldError
from django.db.models import Model from django.db.models import Model
from django.db.models.query_utils import Q 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 from django.contrib.auth.models import User as DjangoUser
return ( return (
# Django only classes
DjangoUser, DjangoUser,
DjangoGroup, DjangoGroup,
ContentType,
Permission,
# Base classes # Base classes
Provider, Provider,
Source, Source,

View File

@ -1,4 +1,6 @@
"""Enterprise app config""" """Enterprise app config"""
from django.conf import settings
from authentik.blueprints.apps import ManagedAppConfig from authentik.blueprints.apps import ManagedAppConfig
@ -17,3 +19,9 @@ class AuthentikEnterpriseConfig(EnterpriseConfig):
def reconcile_load_enterprise_signals(self): def reconcile_load_enterprise_signals(self):
"""Load enterprise signals""" """Load enterprise signals"""
self.import_module("authentik.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]

View File

@ -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, **_
)

View File

@ -10,45 +10,28 @@ from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import ( from authentik.blueprints.v1.importer import excluded_models
AuthenticatedSession, from authentik.core.models import Group, User
Group,
PropertyMapping,
Provider,
Source,
User,
UserSourceConnection,
)
from authentik.enterprise.providers.rac.models import ConnectionToken from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.events.models import Event, EventAction, Notification from authentik.events.models import Event, EventAction, Notification
from authentik.events.utils import model_to_dict 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.sentry import before_send
from authentik.lib.utils.errors import exception_to_string 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.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMGroup, SCIMUser from authentik.providers.scim.models import SCIMGroup, SCIMUser
from authentik.stages.authenticator_static.models import StaticToken from authentik.stages.authenticator_static.models import StaticToken
IGNORED_MODELS = ( IGNORED_MODELS = tuple(
excluded_models()
+ (
Event, Event,
Notification, Notification,
UserObjectPermission, UserObjectPermission,
AuthenticatedSession,
StaticToken, StaticToken,
Session, Session,
FlowToken,
Provider,
Source,
PropertyMapping,
UserSourceConnection,
Stage,
OutpostServiceConnection,
Policy,
PolicyBindingModel,
AuthorizationCode, AuthorizationCode,
AccessToken, AccessToken,
RefreshToken, RefreshToken,
@ -57,6 +40,7 @@ IGNORED_MODELS = (
Reputation, Reputation,
ConnectionToken, ConnectionToken,
) )
)
def should_log_model(model: Model) -> bool: def should_log_model(model: Model) -> bool:
@ -96,9 +80,11 @@ class AuditMiddleware:
get_response: Callable[[HttpRequest], HttpResponse] get_response: Callable[[HttpRequest], HttpResponse]
anonymous_user: User = None anonymous_user: User = None
logger: BoundLogger
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response self.get_response = get_response
self.logger = get_logger().bind()
def _ensure_fallback_user(self): def _ensure_fallback_user(self):
"""Defer fetching anonymous user until we have to""" """Defer fetching anonymous user until we have to"""
@ -116,21 +102,18 @@ class AuditMiddleware:
user = self.anonymous_user user = self.anonymous_user
if not hasattr(request, "request_id"): if not hasattr(request, "request_id"):
return 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.connect(
post_save_handler, partial(self.post_save_handler, user=user, request=request),
dispatch_uid=request.request_id, dispatch_uid=request.request_id,
weak=False, weak=False,
) )
pre_delete.connect( pre_delete.connect(
pre_delete_handler, partial(self.pre_delete_handler, user=user, request=request),
dispatch_uid=request.request_id, dispatch_uid=request.request_id,
weak=False, weak=False,
) )
m2m_changed.connect( m2m_changed.connect(
m2m_changed_handler, partial(self.m2m_changed_handler, user=user, request=request),
dispatch_uid=request.request_id, dispatch_uid=request.request_id,
weak=False, weak=False,
) )
@ -173,19 +156,26 @@ class AuditMiddleware:
) )
thread.run() thread.run()
@staticmethod
def post_save_handler( 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""" """Signal handler for all object's post_save"""
if not should_log_model(instance): if not should_log_model(instance):
return return
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED 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(self, user: User, request: HttpRequest, sender, instance: Model, **_):
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
"""Signal handler for all object's pre_delete""" """Signal handler for all object's pre_delete"""
if not should_log_model(instance): # pragma: no cover if not should_log_model(instance): # pragma: no cover
return return
@ -197,9 +187,8 @@ class AuditMiddleware:
model=model_to_dict(instance), model=model_to_dict(instance),
).run() ).run()
@staticmethod
def m2m_changed_handler( 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""" """Signal handler for all object's m2m_changed"""
if action not in ["pre_add", "pre_remove", "post_clear"]: if action not in ["pre_add", "pre_remove", "post_clear"]:

34
poetry.lock generated
View File

@ -1057,6 +1057,24 @@ files = [
{file = "debugpy-1.8.0.zip", hash = "sha256:12af2c55b419521e33d5fb21bd022df0b5eb267c3e178f1d374a63a2a6bdccd0"}, {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]] [[package]]
name = "deepmerge" name = "deepmerge"
version = "1.1.1" version = "1.1.1"
@ -2473,6 +2491,20 @@ files = [
{file = "opencontainers-0.0.14.tar.gz", hash = "sha256:fde3b8099b56b5c956415df8933e2227e1914e805a277b844f2f9e52341738f2"}, {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]] [[package]]
name = "outcome" name = "outcome"
version = "1.3.0.post0" version = "1.3.0.post0"
@ -4488,4 +4520,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "~3.12" python-versions = "~3.12"
content-hash = "6dcbc2c6d02643a72285e075528ec0841b9c8fda244632386ec19efb7350d4cd" content-hash = "b5127f147f007d9fd1fa661ae66f02f85d9143dda27e1ea5fe4568230c12b7b2"

View File

@ -125,6 +125,7 @@ channels-redis = "*"
codespell = "*" codespell = "*"
colorama = "*" colorama = "*"
dacite = "*" dacite = "*"
deepdiff = "*"
deepmerge = "*" deepmerge = "*"
defusedxml = "*" defusedxml = "*"
django = "*" django = "*"
@ -151,6 +152,7 @@ lxml = [
# 4.9.x works with previous libxml2 versions, which is what we get on linux # 4.9.x works with previous libxml2 versions, which is what we get on linux
{ version = "4.9.4", platform = "linux" }, { version = "4.9.4", platform = "linux" },
] ]
jsonpatch = "*"
opencontainers = { extras = ["reggie"], version = "*" } opencontainers = { extras = ["reggie"], version = "*" }
packaging = "*" packaging = "*"
paramiko = "*" paramiko = "*"
@ -176,7 +178,6 @@ webauthn = "*"
wsproto = "*" wsproto = "*"
xmlsec = "*" xmlsec = "*"
zxcvbn = "*" zxcvbn = "*"
jsonpatch = "*"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
bandit = "*" bandit = "*"

View File

@ -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"> <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> <ak-event-info .event=${this.event}></ak-event-info>
</div> </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> </div>
</section>`; </section>`;
} }

View File

@ -248,6 +248,7 @@ export class EventInfo extends AKElement {
<div class="pf-c-card__body"> <div class="pf-c-card__body">
${this.getModelInfo(this.event.context?.model as EventModel)} ${this.getModelInfo(this.event.context?.model as EventModel)}
</div> </div>
<ak-expand>${this.renderDefaultResponse()}</ak-expand>
`; `;
} }