From 4184f8a770200b61e4a451a4e2c17e468d11c172 Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 24 Jan 2024 11:36:06 +0100 Subject: [PATCH] enterprise: add full audit log [AUTH-458] (#8177) * enterprise: add full audit log Signed-off-by: Jens Langhammer * delegate enabled check to apps Signed-off-by: Jens Langhammer * move audit middleware to separate app Signed-off-by: Jens Langhammer * cleanse before diff Signed-off-by: Jens Langhammer * make cleanse include a hash of the values Signed-off-by: Jens Langhammer * fix sentry error during lint Signed-off-by: Jens Langhammer * format Signed-off-by: Jens Langhammer * fix tests? Signed-off-by: Jens Langhammer * only use start of hash Signed-off-by: Jens Langhammer * don't use deepdiff Signed-off-by: Jens Langhammer * add diff ui Signed-off-by: Jens Langhammer * fix info for dict Signed-off-by: Jens Langhammer * update release notes Signed-off-by: Jens Langhammer * enable audit logging for tests Signed-off-by: Jens Langhammer * fix startup with tests Signed-off-by: Jens Langhammer * lint Signed-off-by: Jens Langhammer * include first 4 chars of raw value? Signed-off-by: Jens Langhammer * only log asterisks Signed-off-by: Jens Langhammer * fixup Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/blueprints/v1/importer.py | 7 +- authentik/core/models.py | 17 +++- authentik/enterprise/apps.py | 3 - authentik/enterprise/audit/__init__.py | 0 authentik/enterprise/audit/apps.py | 19 ++++ authentik/enterprise/audit/middleware.py | 121 +++++++++++++++++++++++ authentik/enterprise/settings.py | 1 + authentik/events/middleware.py | 86 +++++++--------- authentik/events/tests/test_event.py | 6 +- authentik/events/utils.py | 12 +-- authentik/lib/sentry.py | 2 - authentik/policies/tests/test_process.py | 3 +- authentik/root/settings.py | 2 +- blueprints/schema.json | 1 + poetry.lock | 1 - pyproject.toml | 2 +- schema.yml | 5 + web/src/admin/events/EventViewPage.ts | 6 ++ web/src/components/ak-event-info.ts | 49 ++++++++- website/docs/events/index.md | 4 + website/docs/releases/2024/v2024.1.md | 4 + 21 files changed, 281 insertions(+), 70 deletions(-) create mode 100644 authentik/enterprise/audit/__init__.py create mode 100644 authentik/enterprise/audit/apps.py create mode 100644 authentik/enterprise/audit/middleware.py diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 058b05b29e..b190933e1d 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -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 @@ -58,9 +60,11 @@ def excluded_models() -> list[type[Model]]: from django.contrib.auth.models import User as DjangoUser return ( - Tenant, + # Django only classes DjangoUser, DjangoGroup, + ContentType, + Permission, # Base classes Provider, Source, @@ -77,6 +81,7 @@ def excluded_models() -> list[type[Model]]: LicenseUsage, SCIMGroup, SCIMUser, + Tenant, ) diff --git a/authentik/core/models.py b/authentik/core/models.py index 2352055cc8..6c2d5e34fb 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -47,7 +47,14 @@ USER_PATH_SYSTEM_PREFIX = "goauthentik.io" USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts" -options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",) +options.DEFAULT_NAMES = options.DEFAULT_NAMES + ( + # used_by API that allows models to specify if they shadow an object + # for example the proxy provider which is built on top of an oauth provider + "authentik_used_by_shadows", + # List fields for which changes are not logged (due to them having dedicated objects) + # for example user's password and last_login + "authentik_signals_ignored_fields", +) def default_token_duration(): @@ -278,6 +285,14 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): ("assign_user_permissions", _("Can assign permissions to users")), ("unassign_user_permissions", _("Can unassign permissions from users")), ] + authentik_signals_ignored_fields = [ + # Logged by the events `password_set` + # the `password_set` action/signal doesn't currently convey which user + # initiated the password change, so for now we'll log two actions + # ("password", "password_change_date"), + # Logged by `login` + ("last_login",), + ] class Provider(SerializerModel): diff --git a/authentik/enterprise/apps.py b/authentik/enterprise/apps.py index 0f98a6461c..166ab55c88 100644 --- a/authentik/enterprise/apps.py +++ b/authentik/enterprise/apps.py @@ -1,6 +1,4 @@ """Enterprise app config""" -from functools import lru_cache - from django.conf import settings from authentik.blueprints.apps import ManagedAppConfig @@ -26,7 +24,6 @@ class AuthentikEnterpriseConfig(EnterpriseConfig): """Return true if enterprise is enabled and valid""" return self.check_enabled() or settings.TEST - @lru_cache() def check_enabled(self): """Actual enterprise check, cached""" from authentik.enterprise.models import LicenseKey diff --git a/authentik/enterprise/audit/__init__.py b/authentik/enterprise/audit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/audit/apps.py b/authentik/enterprise/audit/apps.py new file mode 100644 index 0000000000..04c9f3686d --- /dev/null +++ b/authentik/enterprise/audit/apps.py @@ -0,0 +1,19 @@ +"""Enterprise app config""" +from django.conf import settings + +from authentik.enterprise.apps import EnterpriseConfig + + +class AuthentikEnterpriseAuditConfig(EnterpriseConfig): + """Enterprise app config""" + + name = "authentik.enterprise.audit" + label = "authentik_enterprise_audit" + verbose_name = "authentik Enterprise.Audit" + default = True + + def reconcile_global_install_middleware(self): + """Install enterprise audit middleware""" + orig_import = "authentik.events.middleware.AuditMiddleware" + new_import = "authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware" + settings.MIDDLEWARE = [new_import if x == orig_import else x for x in settings.MIDDLEWARE] diff --git a/authentik/enterprise/audit/middleware.py b/authentik/enterprise/audit/middleware.py new file mode 100644 index 0000000000..889ce25e58 --- /dev/null +++ b/authentik/enterprise/audit/middleware.py @@ -0,0 +1,121 @@ +"""Enterprise audit middleware""" +from copy import deepcopy +from functools import partial + +from django.apps.registry import apps +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 + +from authentik.core.models import User +from authentik.events.middleware import AuditMiddleware, should_log_model +from authentik.events.utils import cleanse_dict, sanitize_item + + +class EnterpriseAuditMiddleware(AuditMiddleware): + """Enterprise audit middleware""" + + _enabled = None + + @property + def enabled(self): + """Lazy check if audit logging is enabled""" + if self._enabled is None: + self._enabled = apps.get_app_config("authentik_enterprise").enabled() + return self._enabled + + 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: + """Serialize a model in a very simple way. No ForeginKeys or other relationships are + resolved""" + data = {} + deferred_fields = model.get_deferred_fields() + for field in model._meta.concrete_fields: + value = None + 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 cleanse_dict(data) + + def diff(self, before: dict, after: dict) -> dict: + """Generate diff between dicts""" + diff = {} + for key, value in before.items(): + if after.get(key) != value: + diff[key] = {"previous_value": value, "new_value": after.get(key)} + return sanitize_item(diff) + + def post_init_handler(self, user: User, request: HttpRequest, sender, instance: Model, **_): + """post_init django model handler""" + 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") + + # pylint: disable=too-many-arguments + def post_save_handler( + self, + user: User, + request: HttpRequest, + sender, + instance: Model, + created: bool, + thread_kwargs: dict | None = None, + **_, + ): + if not should_log_model(instance): + return None + thread_kwargs = {} + if hasattr(instance, "_previous_state") or created: + prev_state = getattr(instance, "_previous_state", {}) + # Get current state + new_state = self.serialize_simple(instance) + diff = self.diff(prev_state, new_state) + thread_kwargs["diff"] = diff + if not created: + ignored_field_sets = getattr(instance._meta, "authentik_signals_ignored_fields", []) + for field_set in ignored_field_sets: + if set(diff.keys()) == set(field_set): + return None + return super().post_save_handler( + user, request, sender, instance, created, thread_kwargs, **_ + ) diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 8e515f053c..cc3ed2ae8d 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -12,5 +12,6 @@ CELERY_BEAT_SCHEDULE = { } TENANT_APPS = [ + "authentik.enterprise.audit", "authentik.enterprise.providers.rac", ] diff --git a/authentik/events/middleware.py b/authentik/events/middleware.py index 9843402ab8..ade3e8b32b 100644 --- a/authentik/events/middleware.py +++ b/authentik/events/middleware.py @@ -10,52 +10,36 @@ 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 = ( - Event, - Notification, - UserObjectPermission, - AuthenticatedSession, - StaticToken, - Session, - FlowToken, - Provider, - Source, - PropertyMapping, - UserSourceConnection, - Stage, - OutpostServiceConnection, - Policy, - PolicyBindingModel, - AuthorizationCode, - AccessToken, - RefreshToken, - SCIMUser, - SCIMGroup, - Reputation, - ConnectionToken, +IGNORED_MODELS = tuple( + excluded_models() + + ( + Event, + Notification, + UserObjectPermission, + StaticToken, + Session, + AuthorizationCode, + AccessToken, + RefreshToken, + SCIMUser, + SCIMGroup, + Reputation, + ConnectionToken, + ) ) @@ -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,27 @@ class AuditMiddleware: ) thread.run() - @staticmethod + # pylint: disable=too-many-arguments 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 +188,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"]: diff --git a/authentik/events/tests/test_event.py b/authentik/events/tests/test_event.py index 2ca49ab328..f9392014df 100644 --- a/authentik/events/tests/test_event.py +++ b/authentik/events/tests/test_event.py @@ -66,7 +66,8 @@ class TestEvents(TestCase): def test_from_http_clean_querystring(self): """Test cleansing query string""" - request = self.factory.get(f"/?token={generate_id()}") + token = generate_id() + request = self.factory.get(f"/?token={token}") event = Event.new("unittest").from_http(request) self.assertEqual( event.context, @@ -82,7 +83,8 @@ class TestEvents(TestCase): def test_from_http_clean_querystring_flow(self): """Test cleansing query string (nested query string like flow executor)""" - nested_qs = {"token": generate_id()} + token = generate_id() + nested_qs = {"token": token} request = self.factory.get(f"/?{QS_QUERY}={urlencode(nested_qs)}") event = Event.new("unittest").from_http(request) self.assertEqual( diff --git a/authentik/events/utils.py b/authentik/events/utils.py index 4183758575..96e46c5e3f 100644 --- a/authentik/events/utils.py +++ b/authentik/events/utils.py @@ -28,7 +28,7 @@ from authentik.policies.types import PolicyRequest # Special keys which are *not* cleaned, even when the default filter # is matched -ALLOWED_SPECIAL_KEYS = re.compile("passing", flags=re.I) +ALLOWED_SPECIAL_KEYS = re.compile("passing|password_change_date", flags=re.I) def cleanse_item(key: str, value: Any) -> Any: @@ -40,13 +40,13 @@ def cleanse_item(key: str, value: Any) -> Any: value[idx] = cleanse_item(key, item) return value try: - if SafeExceptionReporterFilter.hidden_settings.search( - key - ) and not ALLOWED_SPECIAL_KEYS.search(key): - return SafeExceptionReporterFilter.cleansed_substitute + if not SafeExceptionReporterFilter.hidden_settings.search(key): + return value + if ALLOWED_SPECIAL_KEYS.search(key): + return value + return SafeExceptionReporterFilter.cleansed_substitute except TypeError: # pragma: no cover return value - return value def cleanse_dict(source: dict[Any, Any]) -> dict[Any, Any]: diff --git a/authentik/lib/sentry.py b/authentik/lib/sentry.py index e760173ce7..6ea34625e2 100644 --- a/authentik/lib/sentry.py +++ b/authentik/lib/sentry.py @@ -60,8 +60,6 @@ def sentry_init(**sentry_init_kwargs): }, } kwargs.update(**sentry_init_kwargs) - if settings.DEBUG: - kwargs["spotlight"] = True # pylint: disable=abstract-class-instantiated sentry_sdk_init( dsn=CONFIG.get("error_reporting.sentry_dsn"), diff --git a/authentik/policies/tests/test_process.py b/authentik/policies/tests/test_process.py index dd2eafe04e..6c7c941c7c 100644 --- a/authentik/policies/tests/test_process.py +++ b/authentik/policies/tests/test_process.py @@ -136,6 +136,7 @@ class TestPolicyProcess(TestCase): http_request.user = self.user http_request.resolver_match = resolve(reverse("authentik_api:user-impersonate-end")) + password = generate_id() request = PolicyRequest(self.user) request.set_http_request(http_request) request.context = { @@ -144,7 +145,7 @@ class TestPolicyProcess(TestCase): "list": ["foo", "bar"], "tuple": ("foo", "bar"), "set": {"foo", "bar"}, - "password": generate_id(), + "password": password, } } response = PolicyProcess(binding, request, None).execute() diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 2a5d1f617d..27df24dc41 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -381,7 +381,7 @@ env = get_env() _ERROR_REPORTING = CONFIG.get_bool("error_reporting.enabled", False) if _ERROR_REPORTING: sentry_env = CONFIG.get("error_reporting.environment", "customer") - sentry_init() + sentry_init(spotlight=DEBUG) set_tag("authentik.uuid", sha512(str(SECRET_KEY).encode("ascii")).hexdigest()[:16]) diff --git a/blueprints/schema.json b/blueprints/schema.json index e36e674c6a..397b5f95d6 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -3647,6 +3647,7 @@ "authentik.blueprints", "authentik.core", "authentik.enterprise", + "authentik.enterprise.audit", "authentik.enterprise.providers.rac" ], "title": "App", diff --git a/poetry.lock b/poetry.lock index afd1721878..30229c522e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3362,7 +3362,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, diff --git a/pyproject.toml b/pyproject.toml index 7e71a90b9a..d7bc55313d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,6 +154,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 = "*" @@ -181,7 +182,6 @@ webauthn = "*" wsproto = "*" xmlsec = "*" zxcvbn = "*" -jsonpatch = "*" [tool.poetry.dev-dependencies] bandit = "*" diff --git a/schema.yml b/schema.yml index 2394b86e7c..0af653c16c 100644 --- a/schema.yml +++ b/schema.yml @@ -29362,6 +29362,7 @@ components: - authentik.blueprints - authentik.core - authentik.enterprise + - authentik.enterprise.audit - authentik.enterprise.providers.rac type: string description: |- @@ -29415,6 +29416,7 @@ components: * `authentik.blueprints` - authentik Blueprints * `authentik.core` - authentik Core * `authentik.enterprise` - authentik Enterprise + * `authentik.enterprise.audit` - authentik Enterprise.Audit * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC AppleChallengeResponseRequest: type: object @@ -32415,6 +32417,7 @@ components: * `authentik.blueprints` - authentik Blueprints * `authentik.core` - authentik Core * `authentik.enterprise` - authentik Enterprise + * `authentik.enterprise.audit` - authentik Enterprise.Audit * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC model: allOf: @@ -32617,6 +32620,7 @@ components: * `authentik.blueprints` - authentik Blueprints * `authentik.core` - authentik Core * `authentik.enterprise` - authentik Enterprise + * `authentik.enterprise.audit` - authentik Enterprise.Audit * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC model: allOf: @@ -38243,6 +38247,7 @@ components: * `authentik.blueprints` - authentik Blueprints * `authentik.core` - authentik Core * `authentik.enterprise` - authentik Enterprise + * `authentik.enterprise.audit` - authentik Enterprise.Audit * `authentik.enterprise.providers.rac` - authentik Enterprise.Providers.RAC model: allOf: diff --git a/web/src/admin/events/EventViewPage.ts b/web/src/admin/events/EventViewPage.ts index b41b2adf86..7a7ac52ad6 100644 --- a/web/src/admin/events/EventViewPage.ts +++ b/web/src/admin/events/EventViewPage.ts @@ -154,6 +154,12 @@ export class EventViewPage extends AKElement {
+
+
${msg("Raw event info")}
+
+
${JSON.stringify(this.event, null, 4)}
+
+
`; } diff --git a/web/src/components/ak-event-info.ts b/web/src/components/ak-event-info.ts index e728958c2b..7283f1fbbc 100644 --- a/web/src/components/ak-event-info.ts +++ b/web/src/components/ak-event-info.ts @@ -16,6 +16,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFTable from "@patternfly/patternfly/components/Table/table.css"; import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; @@ -78,6 +79,7 @@ export class EventInfo extends AKElement { PFButton, PFFlex, PFCard, + PFTable, PFList, PFDescriptionList, css` @@ -243,11 +245,52 @@ export class EventInfo extends AKElement { } renderModelChanged() { + const diff = this.event.context.diff as unknown as { + [key: string]: { new_value: unknown; previous_value: unknown }; + }; + let diffBody = html``; + if (diff) { + diffBody = html`
+
${msg("Changes made:")}
+ + + + + + + + + + ${Object.keys(diff).map((key) => { + return html` + + + + `; + })} + +
${msg("Key")}${msg("Previous value")}${msg("New value")}
${key}
+
+${JSON.stringify(diff[key].previous_value, null, 4)}
+
+
${JSON.stringify(diff[key].new_value, null, 4)}
+
+
+ `; + } return html` -
${msg("Affected model:")}
-
- ${this.getModelInfo(this.event.context?.model as EventModel)} +
+
+
${msg("Affected model:")}
+
+ ${this.getModelInfo(this.event.context?.model as EventModel)} +
+
+ ${diffBody}
+
+ ${this.renderDefaultResponse()} `; } diff --git a/website/docs/events/index.md b/website/docs/events/index.md index 856c3d9071..d9f3af567f 100644 --- a/website/docs/events/index.md +++ b/website/docs/events/index.md @@ -305,6 +305,10 @@ A configuration error occurs, for example during the authorization of an applica Logged when any model is created/updated/deleted, including the user that sent the request. +:::info +Starting with authentik Enterprise 2024.1, `model_*` events also include which fields have been changed and their previous and new values. +::: + ### `email_sent` An email has been sent. Included is the email that was sent. diff --git a/website/docs/releases/2024/v2024.1.md b/website/docs/releases/2024/v2024.1.md index 7cd4c95aec..4552ee0cfb 100644 --- a/website/docs/releases/2024/v2024.1.md +++ b/website/docs/releases/2024/v2024.1.md @@ -70,6 +70,10 @@ slug: /releases/2024.1 It allows for authentik operators to manage several authentik installations without having to deploy additional instances. +- Audit log Enterprise + + authentik instances which have a valid enterprise license installed will log changes made to models including which fields were changed with previous and new values of the fields. The values are censored if they are sensitive (for example a password hash), however a hash of the changed value will still be logged. + - "Pretend user exists" option for Identification stage Previously the identification stage would only continue if a user matching the user identifier exists. While this was the intended functionality, this release adds an option to continue to the next stage even if no matching user was found. "Pretend" users cannot authenticate nor receive emails, and don't exist in the database. **This feature is enabled by default.**