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 {
${JSON.stringify(this.event, null, 4)}+
${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)}+ |
+