Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enterprise: Reporting #9322

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Empty file.
12 changes: 12 additions & 0 deletions authentik/enterprise/reporting/apps.py
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions authentik/enterprise/reporting/executor.py
Original file line number Diff line number Diff line change
@@ -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)

Check warning on line 11 in authentik/enterprise/reporting/executor.py

View check run for this annotation

Codecov / codecov/patch

authentik/enterprise/reporting/executor.py#L10-L11

Added lines #L10 - L11 were not covered by tests

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

Check warning on line 22 in authentik/enterprise/reporting/executor.py

View check run for this annotation

Codecov / codecov/patch

authentik/enterprise/reporting/executor.py#L22

Added line #L22 was not covered by tests
131 changes: 131 additions & 0 deletions authentik/enterprise/reporting/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Empty file.
87 changes: 87 additions & 0 deletions authentik/enterprise/reporting/models.py
Original file line number Diff line number Diff line change
@@ -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())

Check warning on line 48 in authentik/enterprise/reporting/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/enterprise/reporting/models.py#L48

Added line #L48 was not covered by tests

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 changes: 38 additions & 0 deletions authentik/enterprise/reporting/signals.py
Original file line number Diff line number Diff line change
@@ -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(

Check warning on line 16 in authentik/enterprise/reporting/signals.py

View check run for this annotation

Codecov / codecov/patch

authentik/enterprise/reporting/signals.py#L12-L16

Added lines #L12 - L16 were not covered by tests
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()

Check warning on line 36 in authentik/enterprise/reporting/signals.py

View check run for this annotation

Codecov / codecov/patch

authentik/enterprise/reporting/signals.py#L34-L36

Added lines #L34 - L36 were not covered by tests
# Cleanup schedules without any tasks
CrontabSchedule.objects.filter(periodictask__isnull=True).delete()

Check warning on line 38 in authentik/enterprise/reporting/signals.py

View check run for this annotation

Codecov / codecov/patch

authentik/enterprise/reporting/signals.py#L38

Added line #L38 was not covered by tests
11 changes: 11 additions & 0 deletions authentik/enterprise/reporting/tasks.py
Original file line number Diff line number Diff line change
@@ -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()

Check warning on line 11 in authentik/enterprise/reporting/tasks.py

View check run for this annotation

Codecov / codecov/patch

authentik/enterprise/reporting/tasks.py#L8-L11

Added lines #L8 - L11 were not covered by tests
1 change: 1 addition & 0 deletions authentik/enterprise/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.rac",
"authentik.enterprise.providers.ssf",
"authentik.enterprise.reporting",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.source",
]
Expand Down
1 change: 1 addition & 0 deletions authentik/root/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"authentik.brands",
"authentik.blueprints",
"guardian",
"django_celery_beat",
]

TENANT_MODEL = "authentik_tenants.Tenant"
Expand Down
14 changes: 9 additions & 5 deletions authentik/tenants/scheduler.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 4 in authentik/tenants/scheduler.py

View check run for this annotation

Codecov / codecov/patch

authentik/tenants/scheduler.py#L3-L4

Added lines #L3 - L4 were not covered by tests


class TenantAwarePersistentScheduler(BaseTenantAwarePersistentScheduler):
class SchedulerEntry(ModelEntry, TenantAwareScheduleEntry):
pass

Check warning on line 8 in authentik/tenants/scheduler.py

View check run for this annotation

Codecov / codecov/patch

authentik/tenants/scheduler.py#L7-L8

Added lines #L7 - L8 were not covered by tests


class TenantAwarePersistentScheduler(TenantAwareSchedulerMixin, DatabaseScheduler):

Check warning on line 11 in authentik/tenants/scheduler.py

View check run for this annotation

Codecov / codecov/patch

authentik/tenants/scheduler.py#L11

Added line #L11 was not covered by tests
"""Tenant-aware Celery beat scheduler"""

Entry = SchedulerEntry

Check warning on line 14 in authentik/tenants/scheduler.py

View check run for this annotation

Codecov / codecov/patch

authentik/tenants/scheduler.py#L14

Added line #L14 was not covered by tests

@classmethod
def get_queryset(cls):
return super().get_queryset().filter(ready=True)
Expand Down
Loading
Loading