enterprise/reports: add users and events export (#18088)

* 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 <marc.schmitt@risson.space>

* update for new file api

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* lint

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* Apply suggestions from code review

Signed-off-by: Dominic R <dominic@sdko.org>

* 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. <jens@goauthentik.io>
Signed-off-by: Connor Peshek <connor@connorpeshek.me>

* lint

* add tests

* fix proper id setting

* update id test

---------

Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: connor peshek <connorpeshek@unknown1641287c8f5d.attlocal.net>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: connor peshek <connorpeshek@connors-MacBook-Pro.local>

* core: custom avatar url improvements (#10525)

Co-authored-by: Dominic R <dominic@sdko.org>

* website/integrations: add salesforce (#18516)

Co-authored-by: connor peshek <connorpeshek@connors-MacBook-Pro.local>
Co-authored-by: dewi-tik <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>

* endpoints: implement endpoint stage (#18468)

* endpoints: implement endpoint stage

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix mismatched label

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix url in mdm config

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rephrase

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* and API & UI

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add deprecated support and deprecate gdtc

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add stage mode

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fixup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework stage slightly, add frontend

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* include jwks, add iat and exp

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* set kid

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* include device details in event list

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* implement device summary

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add remaining tables

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* revert sanitize

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix uuid format issues

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web/flows: update default background image (#18540)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* website/integrations: add hoop.dev (#17868)

Co-authored-by: iops <iops@syneforge.com>
Co-authored-by: Dominic R <dominic@sdko.org>

* website: Docusaurus 3.9.2 (#18506)

* endpoints/stage: v2, better error handling, more settings (#18545)

* add options, idle fallback

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* delete other device tokens during enroll

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better error handling

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* website: Glossary (#16007)

* website: Glossary

fix minor issues

wip

Apply suggestion from @dominic-r

Signed-off-by: Dominic R <dominic@sdko.org>

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 <tana@goauthentik.io>

* 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. <jens@beryju.org>
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 <dewi@goauthentik.io>

* 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 <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* enterprise/reports: remove assignment assertion in ExportButton.ts

* cleanup tests after perm update

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Dominic R <dominic@sdko.org>
Signed-off-by: Connor Peshek <connor@connorpeshek.me>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Connor Peshek <connor@connorpeshek.me>
Co-authored-by: connor peshek <connorpeshek@unknown1641287c8f5d.attlocal.net>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: connor peshek <connorpeshek@connors-MacBook-Pro.local>
Co-authored-by: Konrad Mösch <konrad@moesch.org>
Co-authored-by: dewi-tik <dewi@goauthentik.io>
Co-authored-by: shcherbak <ju.shcherbak@gmail.com>
Co-authored-by: iops <iops@syneforge.com>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
Co-authored-by: Jens L. <jens@beryju.org>
This commit is contained in:
Alexander Tereshkin
2025-12-09 16:35:41 +02:00
committed by GitHub
parent ea513f2ec0
commit 7e9e0a87f7
45 changed files with 1647 additions and 6 deletions
+6 -1
View File
@@ -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()
+129
View File
@@ -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
+8
View File
@@ -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
@@ -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",
},
),
]
+123
View File
@@ -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()
@@ -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",
]
+10
View File
@@ -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()
@@ -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,
)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -0,0 +1,6 @@
from unittest.mock import MagicMock, patch
patch_license = patch(
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
MagicMock(return_value=True),
)
+7
View File
@@ -0,0 +1,7 @@
"""API URLs"""
from authentik.enterprise.reports.api.reports import DataExportViewSet
api_urlpatterns = [
("reports/exports", DataExportViewSet),
]
+11
View File
@@ -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
+1
View File
@@ -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",
+4 -1
View File
@@ -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()
+2
View File
@@ -29,6 +29,8 @@ class NotificationSerializer(ModelSerializer):
"pk",
"severity",
"body",
"hyperlink",
"hyperlink_label",
"created",
"event",
"seen",
@@ -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"),
]
),
),
]
+17
View File
@@ -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)
+6 -1
View File
@@ -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:
+1 -1
View File
@@ -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)
@@ -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,
),
),
]
@@ -18,6 +18,13 @@
{{ body }}
</td>
</tr>
{% if link %}
<tr>
<td align="center" class="btn btn-primary">
<a id="confirm" href="{{ link.target }}" rel="noopener noreferrer" target="_blank">{{ link.label }}</a>
</td>
</tr>
{% endif %}
{% if key_value %}
<tr>
<td>
+105
View File
@@ -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",
+425
View File
@@ -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
+2 -1
View File
@@ -72,7 +72,8 @@ export const createAdminSidebarEntries = (): readonly SidebarEntry[] => [
[null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${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")],
+4
View File
@@ -153,6 +153,10 @@ export const ROUTES: Route[] = [
await import("#admin/events/RuleListPage");
return html`<ak-event-rule-list></ak-event-rule-list>`;
}),
new Route(new RegExp("^/events/exports"), async () => {
await import("./events/DataExportListPage");
return html`<ak-data-export-list></ak-data-export-list>`;
}),
new Route(new RegExp("^/outpost/outposts$"), async () => {
await import("#admin/outposts/OutpostListPage");
return html`<ak-outpost-list></ak-outpost-list>`;
+100
View File
@@ -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<DataExport> {
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<PaginatedResponse<DataExport>> {
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` <ak-forms-delete-bulk
objectLabel=${msg("Data export(s)")}
.objects=${this.selectedElements}
.delete=${(item: DataExport) => {
return new ReportsApi(DEFAULT_CONFIG).reportsExportsDestroy({
id: item.id,
});
}}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${msg("Delete")}
</button>
</ak-forms-delete-bulk>`;
}
row(item: DataExport): SlottedTemplateResult[] {
return [
html`${item.contentType.model}`,
html`<a href="#/identity/users/${item.requestedBy.pk}"
>${item.requestedBy.username}</a
>`,
Timestamp(item.requestedOn),
html`${item.completed ? msg("Yes") : msg("No")}`,
item.completed && item.fileUrl
? html`<div>
<a href="${item.fileUrl}">
<pf-tooltip position="top" content=${msg("Download")}>
<i class="fas fa-download" aria-hidden="true"></i>
</pf-tooltip>
</a>
</div>`
: html``,
];
}
renderExpanded(item: DataExport): TemplateResult {
return html` <dl class="pf-c-description-list pf-m-horizontal">
<div class="pf-c-card__title">${msg("Query parameters")}</div>
<div class="pf-c-card__body">
<code>${JSON.stringify(item.queryParams, null, 4)}</code>
</div>
</dl>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-data-export-list": DataExportListPage;
}
}
+14
View File
@@ -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<Event>) {
renderExpanded(item: Event): TemplateResult {
return html`<ak-event-info .event=${item as EventWithContext}></ak-event-info>`;
}
protected renderToolbar(): TemplateResult {
return html`${super.renderToolbar()}
<ak-reports-export-button
.createExport=${this.#createExport}
></ak-reports-export-button>`;
}
#createExport = async () => {
await new EventsApi(DEFAULT_CONFIG).eventsEventsExportCreate({
...(await this.defaultEndpointConfig()),
});
};
}
declare global {
+75
View File
@@ -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<void>) | null = null;
public createExport: (() => Promise<void>) | 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<this>) {
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`<button @click=${this.#clickHandler} class="pf-c-button pf-m-secondary">
${msg("Export")}
</button>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-reports-export-button": ExportButton;
}
}
+12
View File
@@ -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")}
</button>
</ak-forms-modal>
<ak-reports-export-button
.createExport=${this.#createExport}
></ak-reports-export-button>
`;
}
+1
View File
@@ -51,6 +51,7 @@ export const eventActionToLabel = new Map<EventActions | undefined, string>([
[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 =>
@@ -88,6 +88,14 @@ export class NotificationDrawer extends WithSession(AKElement) {
});
}
protected renderHyperlink(item: Notification) {
if (!item.hyperlink) {
return nothing;
}
return html`<small><a href=${item.hyperlink}>${item.hyperlinkLabel}</a></small>`;
}
#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!)}
</pf-tooltip></small
>
${this.renderHyperlink(item)}
</li>`;
};
+9
View File
@@ -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);
}
+23 -1
View File
@@ -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<SidebarItem>("ak-sidebar-item") || []);
children.forEach((child) => (child.parent = this));
@@ -199,7 +210,18 @@ export class SidebarItem extends AKElement {
</li>`;
}
renderEnterpriseRequired() {
return html`<a href="#/enterprise/licenses" class="pf-c-nav__link">
${this.label}
<span class="pf-c-nav__enterprise-notice">${msg("Enterprise only")}</span>
</a>`;
}
renderWithPath() {
if (this.enterprise && !this.hasEnterpriseLicense) {
if (!this.can(CapabilitiesEnum.IsEnterprise)) return nothing;
else return this.renderEnterpriseRequired();
}
return html`
<a
part="link ${this.current ? "current" : ""}"
+1
View File
@@ -668,6 +668,7 @@ const items = [
"sys-mgmt/certificates",
"sys-mgmt/settings",
"sys-mgmt/service-accounts",
"sys-mgmt/data-exports",
],
},
{
+17
View File
@@ -0,0 +1,17 @@
---
title: Data Exports
sidebar_label: Data Exports
authentik_enterprise: true
---
authentik enterprise allows you to export user and event data in CSV format for backup or analysis purposes.
The content included in a data export matches that returned by the API endpoints for the respective object types.
For detailed instructions on exporting users and events, see [Export users](../users-sources/user/user_basic_operations.md#export-users-) and [Export events](events/logging-events.md#export-events-) respectively.
You can access past data exports from the **System Management** > **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.
@@ -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.
@@ -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.
@@ -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.