Compare commits
2 Commits
main
...
enterprise
Author | SHA1 | Date | |
---|---|---|---|
74deedc9d4 | |||
3c8232b9a5 |
0
authentik/enterprise/reporting/__init__.py
Normal file
0
authentik/enterprise/reporting/__init__.py
Normal file
0
authentik/enterprise/reporting/api/__init__.py
Normal file
0
authentik/enterprise/reporting/api/__init__.py
Normal file
12
authentik/enterprise/reporting/apps.py
Normal file
12
authentik/enterprise/reporting/apps.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Reporting app config"""
|
||||
|
||||
from authentik.enterprise.apps import EnterpriseConfig
|
||||
|
||||
|
||||
class AuthentikEnterpriseReporting(EnterpriseConfig):
|
||||
"""authentik enterprise reporting app config"""
|
||||
|
||||
name = "authentik.enterprise.reporting"
|
||||
label = "authentik_reporting"
|
||||
verbose_name = "authentik Enterprise.Reporting"
|
||||
default = True
|
22
authentik/enterprise/reporting/executor.py
Normal file
22
authentik/enterprise/reporting/executor.py
Normal file
@ -0,0 +1,22 @@
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.enterprise.reporting.models import Report
|
||||
|
||||
|
||||
class ReportExecutor:
|
||||
"""Execute a report"""
|
||||
|
||||
def __init__(self, report: Report) -> None:
|
||||
self.report = report
|
||||
self.logger = get_logger().bind(report=self.report)
|
||||
|
||||
def execute(self):
|
||||
# 1. Run through policies bound to report itself
|
||||
# 2. Get all bound components by running through ReportComponentBinding,
|
||||
# while evaluating policies bound to each
|
||||
# 3. render the actual components
|
||||
# 4. Store the final data...somewhere??
|
||||
# 5. Optionally render PDF via chromedriver (special frontend that uses API)
|
||||
# (not required for MVP)
|
||||
# 6. Send out link to CSV/PDF or attach to email via delivery
|
||||
pass
|
131
authentik/enterprise/reporting/migrations/0001_initial.py
Normal file
131
authentik/enterprise/reporting/migrations/0001_initial.py
Normal file
@ -0,0 +1,131 @@
|
||||
# Generated by Django 5.0.4 on 2024-04-18 21:47
|
||||
|
||||
import authentik.lib.models
|
||||
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 = [
|
||||
("authentik_events", "0007_event_authentik_e_action_9a9dd9_idx_and_more"),
|
||||
("authentik_policies", "0011_policybinding_failure_result_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ReportComponent",
|
||||
fields=[
|
||||
(
|
||||
"widget_uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Report Component",
|
||||
"verbose_name_plural": "Report Components",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Report",
|
||||
fields=[
|
||||
(
|
||||
"policybindingmodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_policies.policybindingmodel",
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
("schedule", models.TextField()),
|
||||
("output_type", models.TextField(choices=[("csv", "Csv"), ("pdf", "Pdf")])),
|
||||
(
|
||||
"delivery",
|
||||
models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_events.notificationtransport",
|
||||
),
|
||||
),
|
||||
(
|
||||
"run_as",
|
||||
models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Report",
|
||||
"verbose_name_plural": "Reports",
|
||||
},
|
||||
bases=("authentik_policies.policybindingmodel", models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ReportComponentBinding",
|
||||
fields=[
|
||||
(
|
||||
"policybindingmodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
to="authentik_policies.policybindingmodel",
|
||||
),
|
||||
),
|
||||
(
|
||||
"binding_uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("layout_x", models.PositiveIntegerField(default=0)),
|
||||
("layout_y", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"target",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="authentik_reporting.report"
|
||||
),
|
||||
),
|
||||
(
|
||||
"widget",
|
||||
authentik.lib.models.InheritanceForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="+",
|
||||
to="authentik_reporting.reportcomponent",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Report Component Binding",
|
||||
"verbose_name_plural": "Report Component Bindings",
|
||||
"unique_together": {("target", "widget")},
|
||||
},
|
||||
bases=("authentik_policies.policybindingmodel", models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="report",
|
||||
name="components",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="bindings",
|
||||
through="authentik_reporting.ReportComponentBinding",
|
||||
to="authentik_reporting.reportcomponent",
|
||||
),
|
||||
),
|
||||
]
|
87
authentik/enterprise/reporting/models.py
Normal file
87
authentik/enterprise/reporting/models.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Reporting models"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from celery.schedules import crontab
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.events.models import NotificationTransport
|
||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
|
||||
|
||||
class OutputType(models.TextChoices):
|
||||
"""Different choices in which a report can be 'rendered'"""
|
||||
|
||||
csv = "csv"
|
||||
pdf = "pdf"
|
||||
|
||||
|
||||
class Report(SerializerModel, PolicyBindingModel):
|
||||
"""A report with a defined list of components, which can run on a schedule"""
|
||||
|
||||
name = models.TextField()
|
||||
|
||||
schedule = models.TextField()
|
||||
|
||||
# User under which permissions the queries are run,
|
||||
# when no user is selected the report is inactive
|
||||
run_as = models.ForeignKey(
|
||||
"authentik_core.user", on_delete=models.SET_DEFAULT, default=None, null=True
|
||||
)
|
||||
components = models.ManyToManyField(
|
||||
"ReportComponent", through="ReportComponentBinding", related_name="bindings", blank=True
|
||||
)
|
||||
output_type = models.TextField(choices=OutputType.choices)
|
||||
# Use notification transport to send report result (either link for webhook based?
|
||||
# maybe send full csv?) or fully rendered PDF via Email
|
||||
# when no transport is selected, reports are not sent anywhere but can be retrieved in authentik
|
||||
delivery = models.ForeignKey(
|
||||
NotificationTransport, on_delete=models.SET_DEFAULT, default=None, null=True
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def get_celery_schedule(self) -> crontab:
|
||||
return crontab(*self.schedule.split())
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Report")
|
||||
verbose_name_plural = _("Reports")
|
||||
|
||||
|
||||
class ReportComponentBinding(SerializerModel, PolicyBindingModel):
|
||||
"""Binding of a component to a report"""
|
||||
|
||||
binding_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
layout_x = models.PositiveIntegerField(default=0)
|
||||
layout_y = models.PositiveIntegerField(default=0)
|
||||
|
||||
target = models.ForeignKey("Report", on_delete=models.CASCADE)
|
||||
widget = InheritanceForeignKey("ReportComponent", on_delete=models.CASCADE, related_name="+")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Binding from {self.report.name} to {self.widget}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Report Component Binding")
|
||||
verbose_name_plural = _("Report Component Bindings")
|
||||
unique_together = ("target", "widget")
|
||||
|
||||
|
||||
class ReportComponent(SerializerModel):
|
||||
"""An individual component of a report, a query or graph, etc"""
|
||||
|
||||
widget_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return super().__str__()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Report Component")
|
||||
verbose_name_plural = _("Report Components")
|
38
authentik/enterprise/reporting/signals.py
Normal file
38
authentik/enterprise/reporting/signals.py
Normal file
@ -0,0 +1,38 @@
|
||||
from json import dumps
|
||||
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
||||
|
||||
from authentik.enterprise.reporting.models import Report
|
||||
|
||||
|
||||
@receiver(post_save, sender=Report)
|
||||
def report_post_save(sender, instance: Report, **_):
|
||||
if instance.schedule == "":
|
||||
return
|
||||
schedule = CrontabSchedule.from_schedule(instance.get_celery_schedule())
|
||||
schedule.save()
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=str(instance.pk),
|
||||
defaults={
|
||||
"crontab": schedule,
|
||||
"task": "authentik.enterprise.reporting.tasks.process_report",
|
||||
"queue": "authentik_reporting",
|
||||
"description": f"Report {instance.name}",
|
||||
"kwargs": dumps(
|
||||
{
|
||||
"report_uuid": str(instance.pk),
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Report)
|
||||
def report_pre_delete(sender, instance: Report, **_):
|
||||
if instance.schedule == "":
|
||||
return
|
||||
PeriodicTask.objects.filter(name=str(instance.pk)).delete()
|
||||
# Cleanup schedules without any tasks
|
||||
CrontabSchedule.objects.filter(periodictask__isnull=True).delete()
|
11
authentik/enterprise/reporting/tasks.py
Normal file
11
authentik/enterprise/reporting/tasks.py
Normal file
@ -0,0 +1,11 @@
|
||||
from authentik.enterprise.reporting.executor import ReportExecutor
|
||||
from authentik.enterprise.reporting.models import Report
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def process_report(report_uuid: str):
|
||||
report = Report.objects.filter(pk=report_uuid).first()
|
||||
if not report or not report.run_as:
|
||||
return
|
||||
ReportExecutor(report).execute()
|
@ -17,6 +17,7 @@ TENANT_APPS = [
|
||||
"authentik.enterprise.providers.google_workspace",
|
||||
"authentik.enterprise.providers.microsoft_entra",
|
||||
"authentik.enterprise.providers.ssf",
|
||||
"authentik.enterprise.reporting",
|
||||
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
|
||||
"authentik.enterprise.stages.source",
|
||||
]
|
||||
|
@ -125,6 +125,7 @@ TENANT_APPS = [
|
||||
"authentik.brands",
|
||||
"authentik.blueprints",
|
||||
"guardian",
|
||||
"django_celery_beat",
|
||||
]
|
||||
|
||||
TENANT_MODEL = "authentik_tenants.Tenant"
|
||||
|
@ -1,14 +1,18 @@
|
||||
"""Tenant-aware Celery beat scheduler"""
|
||||
|
||||
from tenant_schemas_celery.scheduler import (
|
||||
TenantAwarePersistentScheduler as BaseTenantAwarePersistentScheduler,
|
||||
)
|
||||
from tenant_schemas_celery.scheduler import TenantAwareScheduleEntry
|
||||
from django_celery_beat.schedulers import DatabaseScheduler, ModelEntry
|
||||
from tenant_schemas_celery.scheduler import TenantAwareScheduleEntry, TenantAwareSchedulerMixin
|
||||
|
||||
|
||||
class TenantAwarePersistentScheduler(BaseTenantAwarePersistentScheduler):
|
||||
class SchedulerEntry(ModelEntry, TenantAwareScheduleEntry):
|
||||
pass
|
||||
|
||||
|
||||
class TenantAwarePersistentScheduler(TenantAwareSchedulerMixin, DatabaseScheduler):
|
||||
"""Tenant-aware Celery beat scheduler"""
|
||||
|
||||
Entry = SchedulerEntry
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls):
|
||||
return super().get_queryset().filter(ready=True)
|
||||
|
322
poetry.lock
generated
322
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,7 @@ dacite = "*"
|
||||
deepmerge = "*"
|
||||
defusedxml = "*"
|
||||
django = "*"
|
||||
django-celery-beat = "*"
|
||||
django-countries = "*"
|
||||
django-cte = "*"
|
||||
django-filter = "*"
|
||||
|
Reference in New Issue
Block a user