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.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,
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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.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"]:
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 = "*"
|
||||||
|
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Reference in New Issue