From 7e9e0a87f7589cb32e765c8667915623145cc025 Mon Sep 17 00:00:00 2001 From: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:35:41 +0200 Subject: [PATCH] enterprise/reports: add users and events export (#18088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enterprise: add users and events export (reports app) * enterprise/reports: replace assert with AsertionError so that the assumption check is not lost when compiling to optimised byte code * enterprise/reports: use ConditionalInheritance with ExportMixin to make reduce coupling of enterprise with the rest of authentik * enterprise/reports: use custom iterative File to save data export instead of accessing default_storage directly, so all the FileField.save logic can run correctly (e.g. creating directories) * enterprise/reports: change app label to simply "authentik_reports" * wip Signed-off-by: Marc 'risson' Schmitt * update for new file api Signed-off-by: Marc 'risson' Schmitt * lint Signed-off-by: Marc 'risson' Schmitt * Apply suggestions from code review Signed-off-by: Dominic R * wip * sources/oauth: save returned oauth refresh tokens and add slack provider (#18501) * sources/oauth: save returned oauth refresh tokens * Update authentik/sources/oauth/models.py Co-authored-by: Jens L. Signed-off-by: Connor Peshek * lint * add tests * fix proper id setting * update id test --------- Signed-off-by: Connor Peshek Co-authored-by: connor peshek Co-authored-by: Jens L. Co-authored-by: connor peshek * core: custom avatar url improvements (#10525) Co-authored-by: Dominic R * website/integrations: add salesforce (#18516) Co-authored-by: connor peshek Co-authored-by: dewi-tik Co-authored-by: Dominic R * endpoints: implement endpoint stage (#18468) * endpoints: implement endpoint stage Signed-off-by: Jens Langhammer * format Signed-off-by: Jens Langhammer * fix mismatched label Signed-off-by: Jens Langhammer * fix url in mdm config Signed-off-by: Jens Langhammer * rephrase Signed-off-by: Jens Langhammer * and API & UI Signed-off-by: Jens Langhammer * add deprecated support and deprecate gdtc Signed-off-by: Jens Langhammer * add stage mode Signed-off-by: Jens Langhammer * fixup Signed-off-by: Jens Langhammer * rework stage slightly, add frontend Signed-off-by: Jens Langhammer * include jwks, add iat and exp Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * set kid Signed-off-by: Jens Langhammer * include device details in event list Signed-off-by: Jens Langhammer * format Signed-off-by: Jens Langhammer * implement device summary Signed-off-by: Jens Langhammer * add remaining tables Signed-off-by: Jens Langhammer * revert sanitize Signed-off-by: Jens Langhammer * fix uuid format issues Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer * web/flows: update default background image (#18540) Signed-off-by: Jens Langhammer * website/integrations: add hoop.dev (#17868) Co-authored-by: iops Co-authored-by: Dominic R * website: Docusaurus 3.9.2 (#18506) * endpoints/stage: v2, better error handling, more settings (#18545) * add options, idle fallback Signed-off-by: Jens Langhammer * delete other device tokens during enroll Signed-off-by: Jens Langhammer * better error handling Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer * website: Glossary (#16007) * website: Glossary fix minor issues wip Apply suggestion from @dominic-r Signed-off-by: Dominic R anchor to param wip wip at least the lockfile changes now sure a-z first as tana asked idk why i switched in the first place wip wip lock lockfiles are hard wip please work no have? Revert "no have?" This reverts commit 743dbc1bc2900eedcc2c93af248e6afdec3688a3. * changed to sentence-case capitalization --------- Co-authored-by: Tana M Berry * web/i18n: Locale Context Merge Branch (#18426) * web: Update fonts to Patternfly 5 variants. * Fix order of heading override. * web: Flesh out locale context. * Fix Han pattern. * Remove comment. * Add additional regional codes. * Clarify comment. * Fix typos. * web/i18n: Add locale-specific font overrides. * Fix stale session in locale lifecycle. * core, web: Fix Han language codes. * Fix warnings about invalid BCP language code. * Build translations. * Add locale relative labels. * Add locale translations for Finnish and Portuguese. * Fix XLIFF errors. * Clean up labels. * Tidy regions. * Match region comment. * Update extracted values. * Fix locale switch not triggering on source language. * Split labels. * Clean up labels. * providers/scim: cache ServiceProviderConfig (#18047) * Update authentik/enterprise/reports/api/reports.py Co-authored-by: Jens L. Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * enterprise/reports: got rid of unnecessary method-level import * enterprise/reports: celan up code duplication in data export generation (invoke viewset.filter_queryset directly instead of replicating it) * enterprise/reports: add check for app label when switching on content types * enterprise/reports: make hyperlink field on Notification larger so it can fit the security token in the export file URL * enterprise/reports: add is_superuser back in users export * enterprise/reports: split tests into multiple files * Apply suggestions from code review Signed-off-by: Dewi Roberts * Fixed prettier issue * Update web/src/admin/events/DataExportListPage.ts Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * Update web/src/admin/events/DataExportListPage.ts Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * Update web/src/admin/events/EventListPage.ts Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * Update web/src/admin/reports/ExportButton.ts Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * Update web/src/admin/reports/ExportButton.ts Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * Update web/src/admin/users/UserListPage.ts Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * Update web/src/elements/notifications/NotificationDrawer.ts Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * Update web/src/elements/sidebar/SidebarItem.css Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * enterprise/reports: resolve code review merge errors * enterprise/reports: remove the export button from the dom flow (by settings display:none) when there's no license * enterprise/reports: improve docs * include notification link in email Signed-off-by: Jens Langhammer * format Signed-off-by: Jens Langhammer * enterprise/reports: remove assignment assertion in ExportButton.ts * cleanup tests after perm update Signed-off-by: Jens Langhammer --------- Signed-off-by: Marc 'risson' Schmitt Signed-off-by: Dominic R Signed-off-by: Connor Peshek Signed-off-by: Jens Langhammer Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> Signed-off-by: Dewi Roberts Co-authored-by: Marc 'risson' Schmitt Co-authored-by: Dominic R Co-authored-by: Connor Peshek Co-authored-by: connor peshek Co-authored-by: Jens L. Co-authored-by: connor peshek Co-authored-by: Konrad Mösch Co-authored-by: dewi-tik Co-authored-by: shcherbak Co-authored-by: iops Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Co-authored-by: Tana M Berry Co-authored-by: Jens L. --- authentik/core/api/users.py | 7 +- authentik/enterprise/reports/__init__.py | 0 authentik/enterprise/reports/api/__init__.py | 0 authentik/enterprise/reports/api/reports.py | 129 ++++++ authentik/enterprise/reports/apps.py | 8 + .../reports/migrations/0001_initial.py | 48 ++ .../enterprise/reports/migrations/__init__.py | 0 authentik/enterprise/reports/models.py | 123 +++++ authentik/enterprise/reports/serializers.py | 32 ++ authentik/enterprise/reports/tasks.py | 10 + .../enterprise/reports/tests/__init__.py | 0 .../enterprise/reports/tests/test_api.py | 53 +++ .../reports/tests/test_event_export.py | 29 ++ .../reports/tests/test_permissions.py | 80 ++++ .../enterprise/reports/tests/test_schema.py | 48 ++ .../reports/tests/test_user_export.py | 75 ++++ authentik/enterprise/reports/tests/utils.py | 6 + authentik/enterprise/reports/urls.py | 7 + authentik/enterprise/reports/utils.py | 11 + authentik/enterprise/settings.py | 1 + authentik/events/api/events.py | 5 +- authentik/events/api/notifications.py | 2 + ...k_notification_hyperlink_label_and_more.py | 59 +++ authentik/events/models.py | 17 + authentik/events/tasks.py | 7 +- authentik/lib/utils/db.py | 2 +- .../0024_alter_eventmatcherpolicy_action.py | 52 +++ .../templates/email/event_notification.html | 7 + blueprints/schema.json | 105 +++++ schema.yml | 425 ++++++++++++++++++ web/src/admin/AdminInterface/AdminSidebar.ts | 3 +- web/src/admin/Routes.ts | 4 + web/src/admin/events/DataExportListPage.ts | 100 +++++ web/src/admin/events/EventListPage.ts | 14 + web/src/admin/reports/ExportButton.ts | 75 ++++ web/src/admin/users/UserListPage.ts | 12 + web/src/common/labels.ts | 1 + .../notifications/NotificationDrawer.ts | 9 + web/src/elements/sidebar/SidebarItem.css | 9 + web/src/elements/sidebar/SidebarItem.ts | 24 +- website/docs/sidebar.mjs | 1 + website/docs/sys-mgmt/data-exports.md | 17 + website/docs/sys-mgmt/events/event-actions.md | 4 + .../docs/sys-mgmt/events/logging-events.md | 19 + .../user/user_basic_operations.md | 13 + 45 files changed, 1647 insertions(+), 6 deletions(-) create mode 100644 authentik/enterprise/reports/__init__.py create mode 100644 authentik/enterprise/reports/api/__init__.py create mode 100644 authentik/enterprise/reports/api/reports.py create mode 100644 authentik/enterprise/reports/apps.py create mode 100644 authentik/enterprise/reports/migrations/0001_initial.py create mode 100644 authentik/enterprise/reports/migrations/__init__.py create mode 100644 authentik/enterprise/reports/models.py create mode 100644 authentik/enterprise/reports/serializers.py create mode 100644 authentik/enterprise/reports/tasks.py create mode 100644 authentik/enterprise/reports/tests/__init__.py create mode 100644 authentik/enterprise/reports/tests/test_api.py create mode 100644 authentik/enterprise/reports/tests/test_event_export.py create mode 100644 authentik/enterprise/reports/tests/test_permissions.py create mode 100644 authentik/enterprise/reports/tests/test_schema.py create mode 100644 authentik/enterprise/reports/tests/test_user_export.py create mode 100644 authentik/enterprise/reports/tests/utils.py create mode 100644 authentik/enterprise/reports/urls.py create mode 100644 authentik/enterprise/reports/utils.py create mode 100644 authentik/events/migrations/0014_notification_hyperlink_notification_hyperlink_label_and_more.py create mode 100644 authentik/policies/event_matcher/migrations/0024_alter_eventmatcherpolicy_action.py create mode 100644 web/src/admin/events/DataExportListPage.ts create mode 100644 web/src/admin/reports/ExportButton.ts create mode 100644 website/docs/sys-mgmt/data-exports.md diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index c94574e4af..6afcc1baa2 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -86,6 +86,7 @@ from authentik.flows.models import FlowToken from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from authentik.flows.views.executor import QS_KEY_TOKEN from authentik.lib.avatars import get_avatar +from authentik.lib.utils.reflection import ConditionalInheritance from authentik.rbac.api.roles import RoleSerializer from authentik.rbac.decorators import permission_required from authentik.rbac.models import Role, get_permission_choices @@ -484,7 +485,11 @@ class UsersFilter(FilterSet): ] -class UserViewSet(UsedByMixin, ModelViewSet): +class UserViewSet( + ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"), + UsedByMixin, + ModelViewSet, +): """User Viewset""" queryset = User.objects.none() diff --git a/authentik/enterprise/reports/__init__.py b/authentik/enterprise/reports/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/reports/api/__init__.py b/authentik/enterprise/reports/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/reports/api/reports.py b/authentik/enterprise/reports/api/reports.py new file mode 100644 index 0000000000..c16be1baab --- /dev/null +++ b/authentik/enterprise/reports/api/reports.py @@ -0,0 +1,129 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import QuerySet +from django.urls import reverse +from drf_spectacular.utils import extend_schema +from rest_framework import mixins +from rest_framework.decorators import action +from rest_framework.fields import CharField +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from authentik.core.api.utils import ModelSerializer +from authentik.core.models import User +from authentik.enterprise.api import EnterpriseRequiredMixin +from authentik.enterprise.reports.models import DataExport +from authentik.enterprise.reports.tasks import generate_export +from authentik.rbac.permissions import HasPermission + + +class RequestedBySerializer(ModelSerializer): + class Meta: + model = User + fields = ("pk", "username") + + +class ContentTypeSerializer(ModelSerializer): + app_label = CharField(read_only=True) + model = CharField(read_only=True) + + class Meta: + model = ContentType + fields = ("id", "app_label", "model") + + +class DataExportSerializer(EnterpriseRequiredMixin, ModelSerializer): + requested_by = RequestedBySerializer(read_only=True) + content_type = ContentTypeSerializer(read_only=True) + + class Meta: + model = DataExport + fields = ( + "id", + "requested_by", + "requested_on", + "content_type", + "query_params", + "file_url", + "completed", + ) + read_only_fields = ( + "id", + "requested_by", + "requested_on", + "content_type", + "file_url", + "completed", + ) + + +class DataExportViewSet( + mixins.RetrieveModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, GenericViewSet +): + queryset = DataExport.objects.all() + serializer_class = DataExportSerializer + owner_field = "requested_by" + ordering_fields = ["completed", "requested_by", "requested_on", "content_type__model"] + ordering = ["-requested_on"] + search_fields = ["requested_by__username", "content_type__model"] + + def get_queryset(self) -> QuerySet[DataExport]: + """Limit to exports of content types the user has view permission on""" + qs = super().get_queryset() + permitted_cts = [] + for ct in ContentType.objects.filter( + id__in=qs.values_list("content_type_id", flat=True).distinct() + ): + model = ct.model_class() + if model is None: + continue + perm = f"{ct.app_label}.view_{ct.model}" + if self.request.user.has_perm(perm): + permitted_cts.append(ct) + return qs.filter(content_type__in=permitted_cts) + + +class ExportMixin: + @extend_schema( + request=None, + parameters=[], + responses={201: DataExportSerializer}, + filters=True, + ) + @action( + detail=False, + methods=["POST"], + permission_classes=[HasPermission("authentik_reports.add_dataexport")], + ) + def export(self: GenericViewSet, request: Request) -> Response: + """ + Create a data export for this data type. Note that the export is generated asynchronously: + this method returns a `DataExport` object that will initially have `completed=false` as well + as the permanent URL to that object in the `Location` header. + You can poll that URL until `completed=true`, at which point the `file_url` property will + contain a URL to download + """ + + s = DataExportSerializer(data={"query_params": request.query_params.dict()}) + s.is_valid(raise_exception=True) + export = s.save( + requested_by=request.user, + content_type=ContentType.objects.get_for_model(self.queryset.model), + ) + generate_export.send(export.id) + + set = export.serializer(instance=export) + + return Response( + set.data, + status=201, + headers={"Location": reverse("authentik_api:dataexport-detail", args=[export.id])}, + ) + + def get_permissions(self: GenericViewSet) -> list[BasePermission]: + perms = super().get_permissions() + if self.action == "export": + model = self.get_queryset().model + perms.append(HasPermission(f"{model._meta.app_label}.view_{model._meta.model_name}")()) + return perms diff --git a/authentik/enterprise/reports/apps.py b/authentik/enterprise/reports/apps.py new file mode 100644 index 0000000000..053863090e --- /dev/null +++ b/authentik/enterprise/reports/apps.py @@ -0,0 +1,8 @@ +from authentik.enterprise.apps import EnterpriseConfig + + +class ReportsConfig(EnterpriseConfig): + name = "authentik.enterprise.reports" + label = "authentik_reports" + verbose_name = "authentik Enterprise.Reports" + default = True diff --git a/authentik/enterprise/reports/migrations/0001_initial.py b/authentik/enterprise/reports/migrations/0001_initial.py new file mode 100644 index 0000000000..4bd03cf343 --- /dev/null +++ b/authentik/enterprise/reports/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.8 on 2025-12-02 17:19 + +import authentik.admin.files.fields +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DataExport", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("requested_on", models.DateTimeField(auto_now_add=True)), + ("query_params", models.JSONField()), + ("file", authentik.admin.files.fields.FileField(blank=True)), + ("completed", models.BooleanField(default=False)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype" + ), + ), + ( + "requested_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Data Export", + "verbose_name_plural": "Data Exports", + }, + ), + ] diff --git a/authentik/enterprise/reports/migrations/__init__.py b/authentik/enterprise/reports/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/reports/models.py b/authentik/enterprise/reports/models.py new file mode 100644 index 0000000000..a133bf8a62 --- /dev/null +++ b/authentik/enterprise/reports/models.py @@ -0,0 +1,123 @@ +import csv +import io +from uuid import uuid4 + +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.translation import gettext as _ +from rest_framework.serializers import Serializer +from rest_framework.viewsets import ModelViewSet + +from authentik.admin.files.fields import FileField +from authentik.admin.files.manager import get_file_manager +from authentik.admin.files.usage import FileUsage +from authentik.core.models import User +from authentik.enterprise.reports.utils import MockRequest +from authentik.events.models import Event, EventAction, Notification, NotificationSeverity +from authentik.lib.models import SerializerModel +from authentik.lib.utils.db import chunked_queryset +from authentik.tenants.utils import get_current_tenant + + +class DataExport(SerializerModel): + id = models.UUIDField(primary_key=True, default=uuid4) + requested_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + requested_on = models.DateTimeField(auto_now_add=True) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + query_params = models.JSONField() + file = FileField(blank=True) + completed = models.BooleanField(default=False) + + class Meta: + verbose_name = _("Data Export") + verbose_name_plural = _("Data Exports") + + @property + def serializer(self) -> type[Serializer]: + """Get serializer for this model""" + from authentik.enterprise.reports.api.reports import DataExportSerializer + + return DataExportSerializer + + def generate(self) -> None: + if self.completed: + raise AssertionError("Data export must only be generated once") + + model_class = self.content_type.model_class() + model_verbose_name = model_class._meta.verbose_name + model_verbose_name_plural = model_class._meta.verbose_name_plural + + queryset = chunked_queryset(self.get_queryset()) + + serializer = self.get_serializer_class()( + context={"request": self._get_request()}, instance=queryset, many=True + ) + self.file = f"{model_verbose_name_plural.lower()}_{self.id}.csv" + + with get_file_manager(FileUsage.REPORTS).save_file_stream(self.file) as f: + with io.TextIOWrapper(f, encoding="utf-8", newline="") as text: + writer = csv.writer(text) + fields = [field.label for field in serializer.child.fields.values()] + writer.writerow(fields) + for record in queryset: + data = serializer.child.to_representation(record).values() + writer.writerow(data) + self.completed = True + self.save() + + message = _(f"{model_verbose_name} export generated successfully") + e = Event.new( + EventAction.EXPORT_READY, + message=message, + export=self, + ).set_user(self.requested_by) + e.save() + Notification.objects.create( + event=e, + severity=NotificationSeverity.NOTICE, + body=message, + hyperlink=self.file_url, + hyperlink_label=_("Download"), + user=self.requested_by, + ) + + @property + def file_url(self) -> str: + return get_file_manager(FileUsage.REPORTS).file_url(self.file) + + def _get_request(self) -> MockRequest: + return MockRequest( + user=self.requested_by, query_params=self.query_params, tenant=get_current_tenant() + ) + + def get_queryset(self) -> models.QuerySet: + request = self._get_request() + viewset = self.get_viewset() + viewset.request = request + queryset = viewset.get_queryset() + queryset = viewset.filter_queryset(queryset) + + return queryset + + def get_viewset(self) -> ModelViewSet: + from authentik.core.api.users import UserViewSet + from authentik.events.api.events import EventViewSet + + model = (self.content_type.app_label, self.content_type.model) + if model == ("authentik_core", "user"): + return UserViewSet() + elif model == ("authentik_events", "event"): + return EventViewSet() + raise NotImplementedError(f"Unsupported data export type {self.content_type.model}") + + def get_serializer_class(self) -> type[Serializer]: + from authentik.enterprise.reports.serializers import ( + ExportEventSerializer, + ExportUserSerializer, + ) + + if self.content_type.model == "user": + return ExportUserSerializer + elif self.content_type.model == "event": + return ExportEventSerializer + return self.get_viewset().get_serializer_class() diff --git a/authentik/enterprise/reports/serializers.py b/authentik/enterprise/reports/serializers.py new file mode 100644 index 0000000000..bf81c9ce4d --- /dev/null +++ b/authentik/enterprise/reports/serializers.py @@ -0,0 +1,32 @@ +from rest_framework.fields import CharField, IntegerField, SerializerMethodField + +from authentik.core.api.users import UserSerializer +from authentik.core.models import User +from authentik.events.api.events import EventSerializer + + +class ExportUserSerializer(UserSerializer): + """Serializer for exporting users""" + + groups = SerializerMethodField(source="get_groups") + + def get_groups(self, instance: User) -> str: + return ",".join([group.name for group in instance.ak_groups.all()]) + + class Meta(UserSerializer.Meta): + fields = [f for f in UserSerializer.Meta.fields if f != "groups_obj"] + ["groups"] + + +class ExportEventSerializer(EventSerializer): + """Serializer for exporting events""" + + user_pk = IntegerField(source="user.pk", read_only=True) + username = CharField(source="user.username", read_only=True) + email = CharField(source="user.email", read_only=True) + + class Meta(EventSerializer.Meta): + fields = [f for f in EventSerializer.Meta.fields if f != "user"] + [ + "user_pk", + "username", + "email", + ] diff --git a/authentik/enterprise/reports/tasks.py b/authentik/enterprise/reports/tasks.py new file mode 100644 index 0000000000..eba84ca694 --- /dev/null +++ b/authentik/enterprise/reports/tasks.py @@ -0,0 +1,10 @@ +from django.utils.translation import gettext_lazy as _ +from dramatiq import actor + +from authentik.enterprise.reports.models import DataExport + + +@actor(description=_("Generate data export.")) +def generate_export(export_id: int): + export = DataExport.objects.get(id=export_id) + export.generate() diff --git a/authentik/enterprise/reports/tests/__init__.py b/authentik/enterprise/reports/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/reports/tests/test_api.py b/authentik/enterprise/reports/tests/test_api.py new file mode 100644 index 0000000000..9c99515262 --- /dev/null +++ b/authentik/enterprise/reports/tests/test_api.py @@ -0,0 +1,53 @@ +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import User +from authentik.core.tests.utils import create_test_admin_user +from authentik.enterprise.reports.tests.utils import patch_license +from authentik.events.models import Event + + +@patch_license +class TestExportAPI(APITestCase): + def setUp(self) -> None: + self.user = create_test_admin_user() + self.client.force_login(self.user) + + def test_create_user_export(self): + """Test User export endpoint""" + response = self.client.post( + reverse("authentik_api:user-export"), + ) + self.assertEqual(response.status_code, 201) + self.assertEqual( + response.headers["Location"], + reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}), + ) + self.assertEqual(response.data["requested_by"]["pk"], self.user.pk) + self.assertEqual(response.data["completed"], False) + self.assertEqual(response.data["file_url"], "") + self.assertEqual(response.data["query_params"], {}) + self.assertEqual( + response.data["content_type"]["id"], + ContentType.objects.get_for_model(User).id, + ) + + def test_create_event_export(self): + """Test Event export endpoint""" + response = self.client.post( + reverse("authentik_api:event-export"), + ) + self.assertEqual(response.status_code, 201) + self.assertEqual( + response.headers["Location"], + reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}), + ) + self.assertEqual(response.data["requested_by"]["pk"], self.user.pk) + self.assertEqual(response.data["completed"], False) + self.assertEqual(response.data["file_url"], "") + self.assertEqual(response.data["query_params"], {}) + self.assertEqual( + response.data["content_type"]["id"], + ContentType.objects.get_for_model(Event).id, + ) diff --git a/authentik/enterprise/reports/tests/test_event_export.py b/authentik/enterprise/reports/tests/test_event_export.py new file mode 100644 index 0000000000..95506ef198 --- /dev/null +++ b/authentik/enterprise/reports/tests/test_event_export.py @@ -0,0 +1,29 @@ +from django.contrib.contenttypes.models import ContentType +from django.test.testcases import TestCase + +from authentik.core.tests.utils import create_test_user +from authentik.enterprise.reports.models import DataExport +from authentik.enterprise.reports.tests.utils import patch_license +from authentik.events.models import Event, EventAction + + +@patch_license +class TestEventExport(TestCase): + def setUp(self) -> None: + self.user = create_test_user() + self.user.assign_perms_to_managed_role("authentik_events.view_event") + + self.e1 = Event.new(EventAction.LOGIN, user=self.user) + self.e1.save() + self.e2 = Event.new(EventAction.LOGIN_FAILED, user=self.user) + self.e2.save() + + def test_type_filter(self): + export = DataExport.objects.create( + content_type=ContentType.objects.get_for_model(Event), + requested_by=self.user, + query_params={"actions": [EventAction.LOGIN]}, + ) + records = list(export.get_queryset()) + self.assertEqual(len(records), 1) + self.assertEqual(records[0], self.e1) diff --git a/authentik/enterprise/reports/tests/test_permissions.py b/authentik/enterprise/reports/tests/test_permissions.py new file mode 100644 index 0000000000..22bf93048d --- /dev/null +++ b/authentik/enterprise/reports/tests/test_permissions.py @@ -0,0 +1,80 @@ +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.tests.utils import create_test_user +from authentik.enterprise.reports.tests.utils import patch_license + + +@patch_license +class TestExportPermissions(APITestCase): + def setUp(self) -> None: + self.user = create_test_user() + self.client.force_login(self.user) + + def test_export_without_permission(self): + """Test User export endpoint without permission""" + response = self.client.post(reverse("authentik_api:user-export")) + self.assertEqual(response.status_code, 403) + + def test_export_only_user_permission(self): + """Test User export endpoint with only view_user permission""" + self.user.assign_perms_to_managed_role("authentik_core.view_user") + response = self.client.post(reverse("authentik_api:user-export")) + self.assertEqual(response.status_code, 403) + + def test_export_with_permission(self): + """Test User export endpoint with view_user and add_dataexport permission""" + self.user.assign_perms_to_managed_role("authentik_core.view_user") + self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport") + response = self.client.post(reverse("authentik_api:user-export")) + self.assertEqual(response.status_code, 201) + + def test_export_access(self): + """Test that data export access is restricted to the user who created it""" + self.user.assign_perms_to_managed_role("authentik_core.view_user") + self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport") + response = self.client.post(reverse("authentik_api:user-export")) + self.assertEqual(response.status_code, 201) + export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}) + response = self.client.get(export_url) + self.assertEqual(response.status_code, 200) + other_user = create_test_user() + other_user.assign_perms_to_managed_role("authentik_core.view_user") + other_user.assign_perms_to_managed_role("authentik_reports.add_dataexport") + self.client.logout() + self.client.force_login(other_user) + response = self.client.get(export_url) + self.assertEqual(response.status_code, 404) + + def test_export_access_no_datatype_permission(self): + """Test that data export access requires view permission on the data type""" + self.user.assign_perms_to_managed_role("authentik_core.view_user") + self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport") + self.user.assign_perms_to_managed_role("authentik_reports.view_dataexport") + response = self.client.post(reverse("authentik_api:user-export")) + self.assertEqual(response.status_code, 201) + export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}) + + response = self.client.get(export_url) + self.assertEqual(response.status_code, 200) + + self.user.remove_perms_from_managed_role("authentik_core.view_user") + response = self.client.get(export_url) + self.assertEqual(response.status_code, 404) + + response = self.client.get(reverse("authentik_api:dataexport-list")) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 0) + + def test_export_access_owner(self): + self.user.assign_perms_to_managed_role("authentik_core.view_user") + self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport") + response = self.client.post(reverse("authentik_api:user-export")) + self.assertEqual(response.status_code, 201) + export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}) + response = self.client.get(export_url) + self.assertEqual(response.status_code, 200) + + self.user.remove_perms_from_managed_role("authentik_core.view_user") + response = self.client.get(export_url) + self.assertEqual(response.status_code, 404) diff --git a/authentik/enterprise/reports/tests/test_schema.py b/authentik/enterprise/reports/tests/test_schema.py new file mode 100644 index 0000000000..03e7990b56 --- /dev/null +++ b/authentik/enterprise/reports/tests/test_schema.py @@ -0,0 +1,48 @@ +from django.test.testcases import TestCase +from drf_spectacular.generators import SchemaGenerator + +from authentik.enterprise.reports.tests.utils import patch_license + + +@patch_license +class TestSchemaMatch(TestCase): + def setUp(self) -> None: + generator = SchemaGenerator() + self.schema = generator.get_schema(request=None, public=True) + + def _index_params_by_name(self, parameters): + result = {} + for p in parameters or []: + if p.get("in") != "query": + continue + schema = p.get("schema", {}) + result[p["name"]] = { + "required": p.get("required", False), + "type": schema.get("type"), + "format": schema.get("format"), + "enum": tuple(schema.get("enum", [])), + } + return result + + def _find_operation_by_operation_id(self, operation_id): + for path_item in self.schema.get("paths", {}).values(): + for operation in path_item.values(): + if isinstance(operation, dict) and operation.get("operationId") == operation_id: + return operation + raise AssertionError(f"operationId '{operation_id}' not found in schema") + + def _get_op_params(self, operation_id): + operation = self._find_operation_by_operation_id(operation_id) + return self._index_params_by_name(operation.get("parameters", [])) + + def test_user_export_action_query_params_match_list(self): + list_params = self._get_op_params("core_users_list") + del list_params["include_groups"] # Not applicable for export + del list_params["include_roles"] # Not applicable for export + export_params = self._get_op_params("core_users_export_create") + self.assertDictEqual(list_params, export_params) + + def test_event_export_action_query_params_match_list(self): + list_params = self._get_op_params("events_events_list") + export_params = self._get_op_params("events_events_export_create") + self.assertDictEqual(list_params, export_params) diff --git a/authentik/enterprise/reports/tests/test_user_export.py b/authentik/enterprise/reports/tests/test_user_export.py new file mode 100644 index 0000000000..18b4cf665a --- /dev/null +++ b/authentik/enterprise/reports/tests/test_user_export.py @@ -0,0 +1,75 @@ +import csv + +from django.contrib.contenttypes.models import ContentType +from django.test.testcases import TestCase + +from authentik.admin.files.tests.utils import FileTestFileBackendMixin +from authentik.core.models import User +from authentik.core.tests.utils import create_test_user +from authentik.enterprise.reports.models import DataExport +from authentik.enterprise.reports.tests.utils import patch_license + + +@patch_license +class TestUserExport(FileTestFileBackendMixin, TestCase): + def setUp(self) -> None: + super().setUp() + + self.u1 = create_test_user(username="a") + self.u1.assign_perms_to_managed_role("authentik_core.view_user") + self.u2 = create_test_user(username="b", path="abcd") + self.u1.assign_perms_to_managed_role("authentik_core.view_user") + + def _read_export(self, filename): + with open(f"{self.reports_backend_path}/reports/public/{filename}") as f: + reader = csv.DictReader(f) + return list(reader) + + def test_generate_user_export(self): + export = DataExport.objects.create( + content_type=ContentType.objects.get_for_model(User), + requested_by=self.u1, + query_params={"email": str(self.u1.email)}, + ) + export.generate() + + self.assertEqual(export.completed, True) + data = self._read_export(export.file) + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["Username"], self.u1.username) + + def test_path_filter(self): + export = DataExport.objects.create( + content_type=ContentType.objects.get_for_model(User), + requested_by=self.u1, + query_params={"path": str(self.u2.path)}, + ) + records = list(export.get_queryset()) + self.assertEqual(len(records), 1) + self.assertEqual(records[0], self.u2) + + def test_search_filter(self): + export = DataExport.objects.create( + content_type=ContentType.objects.get_for_model(User), + requested_by=self.u1, + query_params={"search": f'username = "{self.u2.username}"'}, + ) + records = list(export.get_queryset()) + self.assertEqual(len(records), 1) + self.assertEqual(records[0], self.u2) + + def test_ordering(self): + export = DataExport.objects.create( + content_type=ContentType.objects.get_for_model(User), + requested_by=self.u1, + query_params={"ordering": "-username"}, + ) + records = list(export.get_queryset()) + self.assertGreaterEqual(records[0].username, records[-1].username) + export = DataExport.objects.create( + content_type=ContentType.objects.get_for_model(User), + requested_by=self.u1, + query_params={"ordering": "username"}, + ) + records = list(export.get_queryset()) + self.assertLess(records[0].username, records[-1].username) diff --git a/authentik/enterprise/reports/tests/utils.py b/authentik/enterprise/reports/tests/utils.py new file mode 100644 index 0000000000..5d5edc306c --- /dev/null +++ b/authentik/enterprise/reports/tests/utils.py @@ -0,0 +1,6 @@ +from unittest.mock import MagicMock, patch + +patch_license = patch( + "authentik.enterprise.models.LicenseUsageStatus.is_valid", + MagicMock(return_value=True), +) diff --git a/authentik/enterprise/reports/urls.py b/authentik/enterprise/reports/urls.py new file mode 100644 index 0000000000..ae78784419 --- /dev/null +++ b/authentik/enterprise/reports/urls.py @@ -0,0 +1,7 @@ +"""API URLs""" + +from authentik.enterprise.reports.api.reports import DataExportViewSet + +api_urlpatterns = [ + ("reports/exports", DataExportViewSet), +] diff --git a/authentik/enterprise/reports/utils.py b/authentik/enterprise/reports/utils.py new file mode 100644 index 0000000000..65287f6754 --- /dev/null +++ b/authentik/enterprise/reports/utils.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from authentik.core.models import User +from authentik.tenants.models import Tenant + + +@dataclass +class MockRequest: + user: User + query_params: dict[str, str] + tenant: Tenant diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 720d7dd000..5014743ab4 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -9,6 +9,7 @@ TENANT_APPS = [ "authentik.enterprise.providers.radius", "authentik.enterprise.providers.scim", "authentik.enterprise.providers.ssf", + "authentik.enterprise.reports", "authentik.enterprise.search", "authentik.enterprise.stages.authenticator_endpoint_gdtc", "authentik.enterprise.stages.mtls", diff --git a/authentik/events/api/events.py b/authentik/events/api/events.py index 5508afa142..7b91928955 100644 --- a/authentik/events/api/events.py +++ b/authentik/events/api/events.py @@ -21,6 +21,7 @@ from rest_framework.viewsets import ModelViewSet from authentik.core.api.object_types import TypeCreateSerializer from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.events.models import Event, EventAction +from authentik.lib.utils.reflection import ConditionalInheritance class EventVolumeSerializer(PassiveSerializer): @@ -116,7 +117,9 @@ class EventsFilter(django_filters.FilterSet): fields = ["action", "client_ip", "username"] -class EventViewSet(ModelViewSet): +class EventViewSet( + ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"), ModelViewSet +): """Event Read-Only Viewset""" queryset = Event.objects.all() diff --git a/authentik/events/api/notifications.py b/authentik/events/api/notifications.py index fd4d78485e..1d904345d7 100644 --- a/authentik/events/api/notifications.py +++ b/authentik/events/api/notifications.py @@ -29,6 +29,8 @@ class NotificationSerializer(ModelSerializer): "pk", "severity", "body", + "hyperlink", + "hyperlink_label", "created", "event", "seen", diff --git a/authentik/events/migrations/0014_notification_hyperlink_notification_hyperlink_label_and_more.py b/authentik/events/migrations/0014_notification_hyperlink_notification_hyperlink_label_and_more.py new file mode 100644 index 0000000000..309e482f29 --- /dev/null +++ b/authentik/events/migrations/0014_notification_hyperlink_notification_hyperlink_label_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.9 on 2025-12-05 10:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_events", "0013_delete_systemtask"), + ] + + operations = [ + migrations.AddField( + model_name="notification", + name="hyperlink", + field=models.TextField(blank=True, max_length=4096, null=True), + ), + migrations.AddField( + model_name="notification", + name="hyperlink_label", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="event", + name="action", + field=models.TextField( + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("secret_view", "Secret View"), + ("secret_rotate", "Secret Rotate"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("flow_execution", "Flow Execution"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ("property_mapping_exception", "Property Mapping Exception"), + ("system_task_execution", "System Task Execution"), + ("system_task_exception", "System Task Exception"), + ("system_exception", "System Exception"), + ("configuration_error", "Configuration Error"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("email_sent", "Email Sent"), + ("update_available", "Update Available"), + ("export_ready", "Export Ready"), + ("custom_", "Custom Prefix"), + ] + ), + ), + ] diff --git a/authentik/events/models.py b/authentik/events/models.py index f72101095a..a68f45a065 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -117,6 +117,8 @@ class EventAction(models.TextChoices): UPDATE_AVAILABLE = "update_available" + EXPORT_READY = "export_ready" + CUSTOM_PREFIX = "custom_" @@ -261,6 +263,14 @@ class Event(SerializerModel, ExpiringModel): return self.context["message"] return f"{self.action}: {self.context}" + @property + def hyperlink(self) -> str | None: + return self.context.get("hyperlink") + + @property + def hyperlink_label(self) -> str | None: + return self.context.get("hyperlink_label") + def __str__(self) -> str: return f"Event action={self.action} user={self.user} context={self.context}" @@ -479,6 +489,11 @@ class NotificationTransport(TasksModel, SerializerModel): context["key_value"]["event_user_username"] = notification.event.user.get( "username", None ) + if notification.hyperlink: + context["link"] = { + "target": notification.hyperlink, + "label": notification.hyperlink_label, + } if notification.event: context["title"] += notification.event.action for key, value in notification.event.context.items(): @@ -532,6 +547,8 @@ class Notification(SerializerModel): uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) severity = models.TextField(choices=NotificationSeverity.choices) body = models.TextField() + hyperlink = models.TextField(blank=True, null=True, max_length=4096) + hyperlink_label = models.TextField(blank=True, null=True) created = models.DateTimeField(auto_now_add=True) event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True) seen = models.BooleanField(default=False) diff --git a/authentik/events/tasks.py b/authentik/events/tasks.py index 8d7dcd707f..55716b29fe 100644 --- a/authentik/events/tasks.py +++ b/authentik/events/tasks.py @@ -110,7 +110,12 @@ def notification_transport(transport_pk: int, event_pk: str, user_pk: int, trigg if not trigger: return notification = Notification( - severity=trigger.severity, body=event.summary, event=event, user=user + severity=trigger.severity, + body=event.summary, + event=event, + user=user, + hyperlink=event.hyperlink, + hyperlink_label=event.hyperlink_label, ) transport: NotificationTransport = NotificationTransport.objects.filter(pk=transport_pk).first() if not transport: diff --git a/authentik/lib/utils/db.py b/authentik/lib/utils/db.py index 507b055bf2..16687687c9 100644 --- a/authentik/lib/utils/db.py +++ b/authentik/lib/utils/db.py @@ -26,4 +26,4 @@ def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000): for chunk in get_chunks(queryset): reset_queries() gc.collect() - yield from chunk.iterator() + yield from chunk.iterator(chunk_size=chunk_size) diff --git a/authentik/policies/event_matcher/migrations/0024_alter_eventmatcherpolicy_action.py b/authentik/policies/event_matcher/migrations/0024_alter_eventmatcherpolicy_action.py new file mode 100644 index 0000000000..517f467e99 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0024_alter_eventmatcherpolicy_action.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.12 on 2025-10-02 12:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0023_alter_eventmatcherpolicy_action_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="eventmatcherpolicy", + name="action", + field=models.TextField( + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("secret_view", "Secret View"), + ("secret_rotate", "Secret Rotate"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("flow_execution", "Flow Execution"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ("property_mapping_exception", "Property Mapping Exception"), + ("system_task_execution", "System Task Execution"), + ("system_task_exception", "System Task Exception"), + ("system_exception", "System Exception"), + ("configuration_error", "Configuration Error"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("email_sent", "Email Sent"), + ("update_available", "Update Available"), + ("export_ready", "Export Ready"), + ("custom_", "Custom Prefix"), + ], + default=None, + help_text="Match created events with this action type. When left empty, all action types will be matched.", + null=True, + ), + ), + ] diff --git a/authentik/stages/email/templates/email/event_notification.html b/authentik/stages/email/templates/email/event_notification.html index 7ca78fc64a..4c3b2df383 100644 --- a/authentik/stages/email/templates/email/event_notification.html +++ b/authentik/stages/email/templates/email/event_notification.html @@ -18,6 +18,13 @@ {{ body }} + {% if link %} + + + {{ link.label }} + + + {% endif %} {% if key_value %} diff --git a/blueprints/schema.json b/blueprints/schema.json index efd00c4059..611e89de63 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -896,6 +896,46 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_reports.dataexport" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "created", + "must_created", + "present" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_reports.dataexport_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_reports.dataexport" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_reports.dataexport" + } + } + }, { "type": "object", "required": [ @@ -5617,6 +5657,10 @@ "authentik_rbac.view_role", "authentik_rbac.view_system_info", "authentik_rbac.view_system_settings", + "authentik_reports.add_dataexport", + "authentik_reports.change_dataexport", + "authentik_reports.delete_dataexport", + "authentik_reports.view_dataexport", "authentik_sources_kerberos.add_groupkerberossourceconnection", "authentik_sources_kerberos.add_kerberossource", "authentik_sources_kerberos.add_kerberossourcepropertymapping", @@ -6914,6 +6958,43 @@ } } }, + "model_authentik_reports.dataexport": { + "type": "object", + "properties": { + "query_params": { + "type": "object", + "additionalProperties": true, + "title": "Query params" + } + }, + "required": [] + }, + "model_authentik_reports.dataexport_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_dataexport", + "change_dataexport", + "delete_dataexport", + "view_dataexport" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage": { "type": "object", "properties": { @@ -7363,6 +7444,7 @@ "model_deleted", "email_sent", "update_available", + "export_ready", "custom_" ], "title": "Action" @@ -7427,6 +7509,21 @@ "model_authentik_events.notification": { "type": "object", "properties": { + "hyperlink": { + "type": [ + "string", + "null" + ], + "maxLength": 4096, + "title": "Hyperlink" + }, + "hyperlink_label": { + "type": [ + "string", + "null" + ], + "title": "Hyperlink label" + }, "event": { "type": "object", "properties": { @@ -7464,6 +7561,7 @@ "model_deleted", "email_sent", "update_available", + "export_ready", "custom_" ], "title": "Action" @@ -8207,6 +8305,7 @@ "model_deleted", "email_sent", "update_available", + "export_ready", "custom_" ], "title": "Action", @@ -8299,6 +8398,7 @@ "authentik.enterprise.providers.radius", "authentik.enterprise.providers.scim", "authentik.enterprise.providers.ssf", + "authentik.enterprise.reports", "authentik.enterprise.search", "authentik.enterprise.stages.authenticator_endpoint_gdtc", "authentik.enterprise.stages.mtls", @@ -8424,6 +8524,7 @@ "authentik_providers_microsoft_entra.microsoftentraprovider", "authentik_providers_microsoft_entra.microsoftentraprovidermapping", "authentik_providers_ssf.ssfprovider", + "authentik_reports.dataexport", "authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage", "authentik_stages_mtls.mutualtlsstage", "authentik_stages_source.sourcestage" @@ -10945,6 +11046,10 @@ "authentik_rbac.view_role", "authentik_rbac.view_system_info", "authentik_rbac.view_system_settings", + "authentik_reports.add_dataexport", + "authentik_reports.change_dataexport", + "authentik_reports.delete_dataexport", + "authentik_reports.view_dataexport", "authentik_sources_kerberos.add_groupkerberossourceconnection", "authentik_sources_kerberos.add_kerberossource", "authentik_sources_kerberos.add_kerberossourcepropertymapping", diff --git a/schema.yml b/schema.yml index 254f3a3b65..f1bb2011fb 100644 --- a/schema.yml +++ b/schema.yml @@ -4499,6 +4499,145 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /core/users/export/: + post: + operationId: core_users_export_create + description: |- + Create a data export for this data type. Note that the export is generated asynchronously: + this method returns a `DataExport` object that will initially have `completed=false` as well + as the permanent URL to that object in the `Location` header. + You can poll that URL until `completed=true`, at which point the `file_url` property will + contain a URL to download + parameters: + - in: query + name: attributes + schema: + type: string + description: Attributes + - in: query + name: date_joined + schema: + type: string + format: date-time + - in: query + name: date_joined__gt + schema: + type: string + format: date-time + - in: query + name: date_joined__lt + schema: + type: string + format: date-time + - in: query + name: email + schema: + type: string + - in: query + name: groups_by_name + schema: + type: array + items: + type: string + explode: true + style: form + - in: query + name: groups_by_pk + schema: + type: array + items: + type: string + format: uuid + explode: true + style: form + - in: query + name: is_active + schema: + type: boolean + - in: query + name: is_superuser + schema: + type: boolean + - in: query + name: last_updated + schema: + type: string + format: date-time + - in: query + name: last_updated__gt + schema: + type: string + format: date-time + - in: query + name: last_updated__lt + schema: + type: string + format: date-time + - $ref: '#/components/parameters/QueryName' + - $ref: '#/components/parameters/QueryPaginationOrdering' + - in: query + name: path + schema: + type: string + - in: query + name: path_startswith + schema: + type: string + - in: query + name: roles_by_name + schema: + type: array + items: + type: string + explode: true + style: form + - in: query + name: roles_by_pk + schema: + type: array + items: + type: string + format: uuid + explode: true + style: form + - $ref: '#/components/parameters/QuerySearch' + - in: query + name: type + schema: + type: array + items: + type: string + enum: + - external + - internal + - internal_service_account + - service_account + explode: true + style: form + - in: query + name: username + schema: + type: string + - in: query + name: uuid + schema: + type: string + format: uuid + tags: + - core + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/DataExport' + description: '' + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' /core/users/impersonate_end/: get: operationId: core_users_impersonate_end_retrieve @@ -6510,6 +6649,7 @@ paths: - configuration_error - custom_ - email_sent + - export_ready - flow_execution - impersonation_ended - impersonation_started @@ -6745,6 +6885,108 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /events/events/export/: + post: + operationId: events_events_export_create + description: |- + Create a data export for this data type. Note that the export is generated asynchronously: + this method returns a `DataExport` object that will initially have `completed=false` as well + as the permanent URL to that object in the `Location` header. + You can poll that URL until `completed=true`, at which point the `file_url` property will + contain a URL to download + parameters: + - in: query + name: action + schema: + type: string + - in: query + name: actions + schema: + type: array + items: + type: string + enum: + - authorize_application + - configuration_error + - custom_ + - email_sent + - export_ready + - flow_execution + - impersonation_ended + - impersonation_started + - invitation_used + - login + - login_failed + - logout + - model_created + - model_deleted + - model_updated + - password_set + - policy_exception + - policy_execution + - property_mapping_exception + - secret_rotate + - secret_view + - source_linked + - suspicious_request + - system_exception + - system_task_exception + - system_task_execution + - update_available + - user_write + explode: true + style: form + - in: query + name: brand_name + schema: + type: string + description: Brand name + - in: query + name: client_ip + schema: + type: string + - in: query + name: context_authorized_app + schema: + type: string + description: Context Authorized application + - in: query + name: context_model_app + schema: + type: string + description: Context Model App + - in: query + name: context_model_name + schema: + type: string + description: Context Model Name + - in: query + name: context_model_pk + schema: + type: string + description: Context Model Primary Key + - $ref: '#/components/parameters/QueryPaginationOrdering' + - $ref: '#/components/parameters/QuerySearch' + - in: query + name: username + schema: + type: string + description: Username + tags: + - events + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/DataExport' + description: '' + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' /events/events/top_per_user/: get: operationId: events_events_top_per_user_list @@ -6795,6 +7037,7 @@ paths: - configuration_error - custom_ - email_sent + - export_ready - flow_execution - impersonation_ended - impersonation_started @@ -10454,6 +10697,7 @@ paths: - configuration_error - custom_ - email_sent + - export_ready - flow_execution - impersonation_ended - impersonation_started @@ -19617,6 +19861,7 @@ paths: - authentik_providers_ssf.ssfprovider - authentik_rbac.initialpermissions - authentik_rbac.role + - authentik_reports.dataexport - authentik_sources_kerberos.groupkerberossourceconnection - authentik_sources_kerberos.kerberossource - authentik_sources_kerberos.kerberossourcepropertymapping @@ -20069,6 +20314,76 @@ paths: $ref: '#/components/responses/ValidationErrorResponse' '403': $ref: '#/components/responses/GenericErrorResponse' + /reports/exports/: + get: + operationId: reports_exports_list + parameters: + - $ref: '#/components/parameters/QueryPaginationOrdering' + - $ref: '#/components/parameters/QueryPaginationPage' + - $ref: '#/components/parameters/QueryPaginationPageSize' + - $ref: '#/components/parameters/QuerySearch' + tags: + - reports + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedDataExportList' + description: '' + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' + /reports/exports/{id}/: + get: + operationId: reports_exports_retrieve + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this Data Export. + required: true + tags: + - reports + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DataExport' + description: '' + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' + delete: + operationId: reports_exports_destroy + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this Data Export. + required: true + tags: + - reports + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + $ref: '#/components/responses/ValidationErrorResponse' + '403': + $ref: '#/components/responses/GenericErrorResponse' /root/config/: get: operationId: root_config_retrieve @@ -32687,6 +33002,7 @@ components: - authentik.enterprise.providers.radius - authentik.enterprise.providers.scim - authentik.enterprise.providers.ssf + - authentik.enterprise.reports - authentik.enterprise.search - authentik.enterprise.stages.authenticator_endpoint_gdtc - authentik.enterprise.stages.mtls @@ -35037,6 +35353,22 @@ components: description: 'Offset after which consent expires. (Format: hours=1;minutes=2;seconds=3).' required: - name + ContentType: + type: object + properties: + id: + type: integer + readOnly: true + app_label: + type: string + readOnly: true + model: + type: string + readOnly: true + required: + - app_label + - id + - model ContextualFlowInfo: type: object description: Contextual flow information for a challenge @@ -35372,6 +35704,45 @@ components: - matched_domain - ui_footer_links - ui_theme + DataExport: + type: object + description: |- + Mixin to validate that a valid enterprise license + exists before allowing to save the object + properties: + id: + type: string + format: uuid + readOnly: true + requested_by: + allOf: + - $ref: '#/components/schemas/RequestedBy' + readOnly: true + requested_on: + type: string + format: date-time + readOnly: true + content_type: + allOf: + - $ref: '#/components/schemas/ContentType' + readOnly: true + query_params: + type: object + additionalProperties: {} + file_url: + type: string + readOnly: true + completed: + type: boolean + readOnly: true + required: + - completed + - content_type + - file_url + - id + - query_params + - requested_by + - requested_on DeliveryMethodEnum: enum: - https://schemas.openid.net/secevent/risc/delivery-method/push @@ -36970,6 +37341,7 @@ components: - model_deleted - email_sent - update_available + - export_ready - custom_ type: string EventMatcherPolicy: @@ -41054,6 +41426,7 @@ components: - authentik_providers_microsoft_entra.microsoftentraprovider - authentik_providers_microsoft_entra.microsoftentraprovidermapping - authentik_providers_ssf.ssfprovider + - authentik_reports.dataexport - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage - authentik_stages_mtls.mutualtlsstage - authentik_stages_source.sourcestage @@ -41284,6 +41657,13 @@ components: body: type: string readOnly: true + hyperlink: + type: string + nullable: true + maxLength: 4096 + hyperlink_label: + type: string + nullable: true created: type: string format: date-time @@ -41301,6 +41681,13 @@ components: type: object description: Notification Serializer properties: + hyperlink: + type: string + nullable: true + maxLength: 4096 + hyperlink_label: + type: string + nullable: true event: $ref: '#/components/schemas/EventRequest' seen: @@ -42727,6 +43114,21 @@ components: - pagination - results - autocomplete + PaginatedDataExportList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/DataExport' + autocomplete: + $ref: '#/components/schemas/Autocomplete' + required: + - pagination + - results + - autocomplete PaginatedDenyStageList: type: object properties: @@ -46937,6 +47339,13 @@ components: type: object description: Notification Serializer properties: + hyperlink: + type: string + nullable: true + maxLength: 4096 + hyperlink_label: + type: string + nullable: true event: $ref: '#/components/schemas/EventRequest' seen: @@ -50920,6 +51329,22 @@ components: minimum: -2147483648 required: - name + RequestedBy: + type: object + properties: + pk: + type: integer + readOnly: true + title: ID + username: + type: string + description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ + only. + pattern: ^[\w.@+-]+$ + maxLength: 150 + required: + - pk + - username ResidentKeyRequirementEnum: enum: - discouraged diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index 7cfcef68a9..4924703942 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -72,7 +72,8 @@ export const createAdminSidebarEntries = (): readonly SidebarEntry[] => [ [null, msg("Events"), null, [ ["/events/log", msg("Logs"), [`^/events/log/(?${UUID_REGEX})$`]], ["/events/rules", msg("Notification Rules")], - ["/events/transports", msg("Notification Transports")]] + ["/events/transports", msg("Notification Transports")], + ["/events/exports", msg("Data Exports"), {enterprise:true}]] ], [null, msg("Customization"), null, [ ["/policy/policies", msg("Policies")], diff --git a/web/src/admin/Routes.ts b/web/src/admin/Routes.ts index 6f323795a9..ded449f061 100644 --- a/web/src/admin/Routes.ts +++ b/web/src/admin/Routes.ts @@ -153,6 +153,10 @@ export const ROUTES: Route[] = [ await import("#admin/events/RuleListPage"); return html``; }), + new Route(new RegExp("^/events/exports"), async () => { + await import("./events/DataExportListPage"); + return html``; + }), new Route(new RegExp("^/outpost/outposts$"), async () => { await import("#admin/outposts/OutpostListPage"); return html``; diff --git a/web/src/admin/events/DataExportListPage.ts b/web/src/admin/events/DataExportListPage.ts new file mode 100644 index 0000000000..0662abb2fa --- /dev/null +++ b/web/src/admin/events/DataExportListPage.ts @@ -0,0 +1,100 @@ +import "#admin/rbac/ObjectPermissionModal"; +import "#elements/buttons/ActionButton/index"; +import "#elements/buttons/SpinnerButton/index"; +import "#elements/forms/DeleteBulkForm"; +import "#elements/forms/ModalForm"; +import "#elements/tasks/TaskList"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { DEFAULT_CONFIG } from "#common/api/config"; + +import { PaginatedResponse, TableColumn, Timestamp } from "#elements/table/Table"; +import { TablePage } from "#elements/table/TablePage"; +import { SlottedTemplateResult } from "#elements/types"; + +import { DataExport, ReportsApi } from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { html, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("ak-data-export-list") +export class DataExportListPage extends TablePage { + protected override searchEnabled = true; + public pageTitle = msg("Data Exports"); + public pageDescription = msg("Manage past data exports."); + public pageIcon = "pf-icon pf-icon-export"; + + public override checkbox = true; + public override clearOnRefresh = true; + public override expandable = true; + + @property({ type: String }) + public order = "-requested_on"; + + async apiEndpoint(): Promise> { + return new ReportsApi(DEFAULT_CONFIG).reportsExportsList( + await this.defaultEndpointConfig(), + ); + } + + protected columns: TableColumn[] = [ + [msg("Data type"), "content_type__model"], + [msg("Requested by"), "requested_by"], + [msg("Creation date"), "requested_on"], + [msg("Completed"), "completed"], + [msg("Actions"), null, msg("Row actions")], + ]; + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return new ReportsApi(DEFAULT_CONFIG).reportsExportsDestroy({ + id: item.id, + }); + }} + > + + `; + } + + row(item: DataExport): SlottedTemplateResult[] { + return [ + html`${item.contentType.model}`, + html`${item.requestedBy.username}`, + Timestamp(item.requestedOn), + html`${item.completed ? msg("Yes") : msg("No")}`, + item.completed && item.fileUrl + ? html`` + : html``, + ]; + } + + renderExpanded(item: DataExport): TemplateResult { + return html`
+
${msg("Query parameters")}
+
+ ${JSON.stringify(item.queryParams, null, 4)} +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-data-export-list": DataExportListPage; + } +} diff --git a/web/src/admin/events/EventListPage.ts b/web/src/admin/events/EventListPage.ts index fc71f84bc7..4e34222e9e 100644 --- a/web/src/admin/events/EventListPage.ts +++ b/web/src/admin/events/EventListPage.ts @@ -1,5 +1,6 @@ import "#admin/events/EventMap"; import "#admin/events/EventVolumeChart"; +import "#admin/reports/ExportButton"; import "#components/ak-event-info"; import "#elements/Tabs"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; @@ -118,6 +119,19 @@ export class EventListPage extends WithLicenseSummary(TablePage) { renderExpanded(item: Event): TemplateResult { return html``; } + + protected renderToolbar(): TemplateResult { + return html`${super.renderToolbar()} + `; + } + + #createExport = async () => { + await new EventsApi(DEFAULT_CONFIG).eventsEventsExportCreate({ + ...(await this.defaultEndpointConfig()), + }); + }; } declare global { diff --git a/web/src/admin/reports/ExportButton.ts b/web/src/admin/reports/ExportButton.ts new file mode 100644 index 0000000000..417d7e61bf --- /dev/null +++ b/web/src/admin/reports/ExportButton.ts @@ -0,0 +1,75 @@ +import { parseAPIResponseError } from "../../common/errors/network"; +import { MessageLevel } from "../../common/messages"; +import { AKElement } from "../../elements/Base"; +import { showAPIErrorMessage, showMessage } from "../../elements/messages/MessageContainer"; +import { SlottedTemplateResult } from "../../elements/types"; + +import { WithLicenseSummary } from "#elements/mixins/license"; + +import { msg } from "@lit/localize"; +import { CSSResult, html, nothing, PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +@customElement("ak-reports-export-button") +export class ExportButton extends WithLicenseSummary(AKElement) { + static styles: CSSResult[] = [PFBase, PFButton]; + + @property({ attribute: false }) + // public createExport: (() => Promise) | null = null; + public createExport: (() => Promise) | null = null; + + // safest display setting for a button + cachedDisplay = "inline-block"; + + // memoize what the button would be if it were visible: + connectedCallback() { + super.connectedCallback(); + const detectedDisplay = getComputedStyle(this).display; + if (detectedDisplay) { + this.cachedDisplay = detectedDisplay; + } + } + + // Take it out of the DOM flow if it's not enabled + willUpdate(changed: PropertyValues) { + super.willUpdate(changed); + this.style.display = this.hasEnterpriseLicense ? this.cachedDisplay : "none"; + } + + #clickHandler = () => { + if (typeof this.createExport !== "function") { + throw new TypeError("`createExport` property must be a function"); + } + + return this.createExport() + .then(() => { + showMessage({ + level: MessageLevel.success, + message: msg("Data export requested successfully"), + description: msg("You will receive a notification once the data is ready"), + }); + }) + .catch(async (error) => { + const apiError = await parseAPIResponseError(error); + showAPIErrorMessage(apiError); + }); + }; + + render(): SlottedTemplateResult { + if (!this.hasEnterpriseLicense) { + return nothing; + } + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-reports-export-button": ExportButton; + } +} diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index f617858b4f..77c9d48348 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -1,3 +1,4 @@ +import "#admin/reports/ExportButton"; import "#admin/users/ServiceAccountForm"; import "#admin/users/UserActiveForm"; import "#admin/users/UserForm"; @@ -155,6 +156,14 @@ export class UserListPage extends WithBrandConfig( [msg("Actions"), null, msg("Row Actions")], ]; + #createExport = async () => { + await new CoreApi(DEFAULT_CONFIG).coreUsersExportCreate({ + ...(await this.defaultEndpointConfig()), + pathStartswith: this.activePath, + isActive: this.hideDeactivated ? true : undefined, + }); + }; + renderToolbarSelected(): TemplateResult { const disabled = this.selectedElements.length < 1; const { currentUser, originalUser } = this; @@ -393,6 +402,9 @@ export class UserListPage extends WithBrandConfig( ${msg("New Service Account")} + `; } diff --git a/web/src/common/labels.ts b/web/src/common/labels.ts index c59d440504..b659e62242 100644 --- a/web/src/common/labels.ts +++ b/web/src/common/labels.ts @@ -51,6 +51,7 @@ export const eventActionToLabel = new Map([ [EventActions.ModelDeleted, msg("Model deleted")], [EventActions.EmailSent, msg("Email sent")], [EventActions.UpdateAvailable, msg("Update available")], + [EventActions.ExportReady, msg("Data export ready")], ]); export const actionToLabel = (action?: EventActions): string => diff --git a/web/src/elements/notifications/NotificationDrawer.ts b/web/src/elements/notifications/NotificationDrawer.ts index 00acec3f0a..4fbed8c04a 100644 --- a/web/src/elements/notifications/NotificationDrawer.ts +++ b/web/src/elements/notifications/NotificationDrawer.ts @@ -88,6 +88,14 @@ export class NotificationDrawer extends WithSession(AKElement) { }); } + protected renderHyperlink(item: Notification) { + if (!item.hyperlink) { + return nothing; + } + + return html`${item.hyperlinkLabel}`; + } + #renderItem = (item: Notification): TemplateResult => { const label = actionToLabel(item.event?.action); const level = severityToLevel(item.severity); @@ -144,6 +152,7 @@ export class NotificationDrawer extends WithSession(AKElement) { ${formatElapsedTime(item.created!)} + ${this.renderHyperlink(item)} `; }; diff --git a/web/src/elements/sidebar/SidebarItem.css b/web/src/elements/sidebar/SidebarItem.css index 9355e0dcf3..50c3f578ba 100644 --- a/web/src/elements/sidebar/SidebarItem.css +++ b/web/src/elements/sidebar/SidebarItem.css @@ -49,3 +49,12 @@ button.pf-c-nav__link { .pf-c-nav__item:not(.pf-m-expandable) { --pf-c-nav__link--after--BorderLeftWidth: 1px !important; } + +.pf-c-nav__enterprise-notice { + color: var(--pf-global--Color--dark-200); + background-color: var(--pf-global--Color--light-300); + font-size: var(--pf-global--FontSize--xs); + margin-inline-start: 0.5rem; + padding: 0.1rem 0.5rem; + border-radius: var(--pf-global--BorderRadius--sm); +} diff --git a/web/src/elements/sidebar/SidebarItem.ts b/web/src/elements/sidebar/SidebarItem.ts index fd793a3054..65ff7404a6 100644 --- a/web/src/elements/sidebar/SidebarItem.ts +++ b/web/src/elements/sidebar/SidebarItem.ts @@ -1,9 +1,16 @@ +import "#admin/common/ak-license-notice"; + +import { WithCapabilitiesConfig } from "../mixins/capabilities"; +import { WithLicenseSummary } from "../mixins/license"; + import { ROUTE_SEPARATOR } from "#common/constants"; import { AKElement } from "#elements/Base"; import Styles from "#elements/sidebar/SidebarItem.css"; import { ifPresent } from "#elements/utils/attributes"; +import { CapabilitiesEnum } from "@goauthentik/api"; + import { msg, str } from "@lit/localize"; import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -16,10 +23,11 @@ export interface SidebarItemProperties { path?: string | null; activeWhen?: string[]; expanded?: boolean | null; + enterprise?: boolean; } @customElement("ak-sidebar-item") -export class SidebarItem extends AKElement { +export class SidebarItem extends WithCapabilitiesConfig(WithLicenseSummary(AKElement)) { static styles: CSSResult[] = [ // --- PFPage, @@ -49,6 +57,9 @@ export class SidebarItem extends AKElement { public parent?: SidebarItem; + @property({ type: Boolean }) + public enterprise = false; + public get childItems(): SidebarItem[] { const children = Array.from(this.querySelectorAll("ak-sidebar-item") || []); children.forEach((child) => (child.parent = this)); @@ -199,7 +210,18 @@ export class SidebarItem extends AKElement { `; } + renderEnterpriseRequired() { + return html` + ${this.label} + ${msg("Enterprise only")} + `; + } + renderWithPath() { + if (this.enterprise && !this.hasEnterpriseLicense) { + if (!this.can(CapabilitiesEnum.IsEnterprise)) return nothing; + else return this.renderEnterpriseRequired(); + } return html` **Data Exports** page. On this page you can view the query used for a specific export, search exports by data type and user, download completed exports and delete exports that you no longer need. + +## Permissions + +Creating or viewing a data export requires [view permission](../users-sources/access-control/permissions.md) on the object type being exported in addition to the respective permission for data exports. diff --git a/website/docs/sys-mgmt/events/event-actions.md b/website/docs/sys-mgmt/events/event-actions.md index b204245884..4ba469b4d1 100644 --- a/website/docs/sys-mgmt/events/event-actions.md +++ b/website/docs/sys-mgmt/events/event-actions.md @@ -306,3 +306,7 @@ An email has been sent. Included is the email that was sent. ### `update_available` An update is available. + +### `export_ready` + +A data export has been generated. diff --git a/website/docs/sys-mgmt/events/logging-events.md b/website/docs/sys-mgmt/events/logging-events.md index 9bb04b88b0..37ea0a754e 100644 --- a/website/docs/sys-mgmt/events/logging-events.md +++ b/website/docs/sys-mgmt/events/logging-events.md @@ -59,3 +59,22 @@ For more examples, refer to the list of [Event actions](./event-actions.md) and 2. If the list of operators does not appear in a drop-down menu you will need to manually enter it. 3. For queries that include `user`, `brand`, or `context` you need to use a compound term such as `user.username` or `brand.name`. ::: + +## Export events :ak-enterprise + +You can export your authentik instance's events to a CSV file. To generate a data export, follow these steps: + +1. Log in to authentik as an administrator and navigate to **Events > Logs**. +2. Set a [search query](#tell-me-more) as well as the ordering, as needed. The data export will honor these settings. +3. Click **Export** above the event list. +4. Note that the export is processed in the background. Once the export is ready, you will receive a notification in the notification drawer. +5. In the notification, click **Download**. + +6. Log in to authentik as an administrator and open the authentik Admin interface. +7. Navigate to **Events** > **Logs**. +8. Set a [search query](#tell-me-more) as well as the ordering for the data export. +9. Click **Export** above the event list. +10. The export is processed in the background and after it's ready, you will receive a notification in the notification drawer. +11. In the notification, click **Download**. + +To review, download, or delete past data exports, navigate to **Events** > **Data Exports** in the Admin interface. diff --git a/website/docs/users-sources/user/user_basic_operations.md b/website/docs/users-sources/user/user_basic_operations.md index cb72624683..ff5483e6fc 100644 --- a/website/docs/users-sources/user/user_basic_operations.md +++ b/website/docs/users-sources/user/user_basic_operations.md @@ -187,3 +187,16 @@ An Admin can globally enable or disable impersonation in the [System Settings](. An Admin can also configure whether inputting a reason for impersonation is required in the [System Settings](../../sys-mgmt/settings.md#require-reason-for-impersonation). ::: + +## Export users :ak-enterprise + +You can export your authentik instance's user data to a CSV file. To generate a data export, follow these steps: + +1. Log in to authentik as an administrator and open the authentik Admin interface. +2. Navigate to **Directory** > **Users** and click **Export**. +3. Set a [search query](#tell-me-more) as well as the ordering for the data export. +4. Click **Export** above the event list. +5. The export is processed in the background and after it's ready, you will receive a notification in the Admin interface's notification area. +6. In the notification, click **Download**. + +To review, download, or delete past data exports, navigate to **Events** > **Data Exports** in the Admin interface.