Compare commits

...

3 Commits

Author SHA1 Message Date
435ba598bb add tests
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-09-25 15:07:00 +02:00
582511abcc add version
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-09-25 14:42:24 +02:00
80ea1dae81 analytics: init
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-09-25 14:37:27 +02:00
21 changed files with 685 additions and 9 deletions

View File

@ -0,0 +1,20 @@
"""authentik admin analytics"""
from typing import Any
from django.utils.translation import gettext_lazy as _
from authentik.root.celery import CELERY_APP
def get_analytics_description() -> dict[str, str]:
return {
"worker_count": _("Number of running workers"),
}
def get_analytics_data() -> dict[str, Any]:
worker_count = len(CELERY_APP.control.ping(timeout=0.5))
return {
"worker_count": worker_count,
}

View File

View File

@ -0,0 +1,54 @@
"""authentik analytics api"""
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.fields import CharField, DictField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.analytics.utils import get_analytics_data, get_analytics_description
from authentik.core.api.utils import PassiveSerializer
from authentik.rbac.permissions import HasPermission
class AnalyticsDescriptionSerializer(PassiveSerializer):
label = CharField()
desc = CharField()
class AnalyticsDescriptionViewSet(ViewSet):
"""Read-only view of analytics descriptions"""
permission_classes = [HasPermission("authentik_rbac.view_system_settings")]
@extend_schema(responses={200: AnalyticsDescriptionSerializer})
def list(self, request: Request) -> Response:
"""Read-only view of analytics descriptions"""
data = []
for label, desc in get_analytics_description().items():
data.append({"label": label, "desc": desc})
return Response(AnalyticsDescriptionSerializer(data, many=True).data)
class AnalyticsDataViewSet(ViewSet):
"""Read-only view of analytics descriptions"""
permission_classes = [HasPermission("authentik_rbac.edit_system_settings")]
@extend_schema(
responses={
200: inline_serializer(
name="AnalyticsData",
fields={
"data": DictField(),
},
)
}
)
def list(self, request: Request) -> Response:
"""Read-only view of analytics descriptions"""
return Response(
{
"data": get_analytics_data(force=True),
}
)

View File

@ -0,0 +1,12 @@
"""authentik analytics app config"""
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikAdminConfig(ManagedAppConfig):
"""authentik analytics app config"""
name = "authentik.analytics"
label = "authentik_analytics"
verbose_name = "authentik Analytics"
default = True

View File

@ -0,0 +1,19 @@
"""authentik analytics mixins"""
from typing import Any
from django.utils.translation import gettext_lazy as _
class AnalyticsMixin:
@classmethod
def get_analytics_description(cls) -> dict[str, str]:
object_name = _(cls._meta.verbose_name)
count_desc = _("Number of {object_name} objects".format_map({"object_name": object_name}))
return {
"count": count_desc,
}
@classmethod
def get_analytics_data(cls) -> dict[str, Any]:
return {"count": cls.objects.all().count()}

View File

@ -0,0 +1,17 @@
"""authentik admin settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"analytics_send": {
"task": "authentik.analytics.tasks.send_analytics",
"schedule": crontab(
minute=fqdn_rand("analytics_send"),
hour=fqdn_rand("analytics_send", stop=24),
day_of_week=fqdn_rand("analytics_send", 7),
),
"options": {"queue": "authentik_scheduled"},
}
}

View File

@ -0,0 +1,45 @@
"""authentik admin tasks"""
import orjson
from django.utils.translation import gettext_lazy as _
from requests import RequestException
from structlog.stdlib import get_logger
from authentik.analytics.utils import get_analytics_data
from authentik.events.models import Event, EventAction
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.utils.http import get_http_session
from authentik.root.celery import CELERY_APP
from authentik.tenants.models import Tenant
LOGGER = get_logger()
@CELERY_APP.task(bind=True, base=SystemTask)
@prefill_task
def send_analytics(self: SystemTask):
"""Send analytics"""
for tenant in Tenant.objects.filter(ready=True):
data = get_analytics_data(current_tenant=tenant)
if not tenant.analytics_enabled or not data:
self.set_status(TaskStatus.WARNING, "Analytics disabled. Nothing was sent.")
return
try:
response = get_http_session().post(
"https://customers.goauthentik.io/api/analytics/post/", json=data
)
response.raise_for_status()
self.set_status(
TaskStatus.SUCCESSFUL,
"Successfully sent analytics",
orjson.dumps(
data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS | orjson.OPT_UTC_Z
).decode(),
)
Event.new(
EventAction.ANALYTICS_SENT,
message=_("Analytics sent"),
analytics_data=data,
).save()
except (RequestException, IndexError) as exc:
self.set_error(exc)

View File

@ -0,0 +1,76 @@
"""authentik analytics tests"""
from json import loads
from requests_mock import Mocker
from django.test import TestCase
from django.urls import reverse
from authentik import __version__
from authentik.analytics.tasks import send_analytics
from authentik.analytics.utils import get_analytics_apps_data, get_analytics_apps_description, get_analytics_data, get_analytics_description, get_analytics_models_data, get_analytics_models_description
from authentik.core.models import Group, User
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.tenants.utils import get_current_tenant
class TestAnalytics(TestCase):
"""test analytics api"""
def setUp(self) -> None:
super().setUp()
self.user = User.objects.create(username=generate_id())
self.group = Group.objects.create(name=generate_id(), is_superuser=True)
self.group.users.add(self.user)
self.client.force_login(self.user)
self.tenant = get_current_tenant()
def test_description_api(self):
"""Test Version API"""
response = self.client.get(reverse("authentik_api:analytics-description-list"))
self.assertEqual(response.status_code, 200)
loads(response.content)
def test_data_api(self):
"""Test Version API"""
response = self.client.get(reverse("authentik_api:analytics-data-list"))
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(body["data"]["version"], __version__)
def test_sending_enabled(self):
"""Test analytics sending"""
self.tenant.analytics_enabled = True
self.tenant.save()
with Mocker() as mocker:
mocker.post("https://customers.goauthentik.io/api/analytics/post/", status_code=200)
send_analytics.delay().get()
self.assertTrue(
Event.objects.filter(
action=EventAction.ANALYTICS_SENT
).exists()
)
def test_sending_disabled(self):
"""Test analytics sending"""
self.tenant.analytics_enabled = False
self.tenant.save()
send_analytics.delay().get()
self.assertFalse(
Event.objects.filter(
action=EventAction.ANALYTICS_SENT
).exists()
)
def test_description_data_match_apps(self):
"""Test description and data keys match"""
description = get_analytics_apps_description()
data = get_analytics_apps_data()
self.assertEqual(data.keys(), description.keys())
def test_description_data_match_models(self):
"""Test description and data keys match"""
description = get_analytics_models_description()
data = get_analytics_models_data()
self.assertEqual(data.keys(), description.keys())

View File

@ -0,0 +1,8 @@
"""API URLs"""
from authentik.analytics.api import AnalyticsDataViewSet, AnalyticsDescriptionViewSet
api_urlpatterns = [
("analytics/description", AnalyticsDescriptionViewSet, "analytics-description"),
("analytics/data", AnalyticsDataViewSet, "analytics-data"),
]

View File

@ -0,0 +1,112 @@
"""authentik analytics utils"""
from hashlib import sha256
from importlib import import_module
from typing import Any
from structlog import get_logger
from authentik import get_full_version
from authentik.analytics.models import AnalyticsMixin
from authentik.lib.utils.reflection import get_apps
from authentik.root.install_id import get_install_id
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger()
def get_analytics_apps() -> dict:
modules = {}
for _authentik_app in get_apps():
try:
module = import_module(f"{_authentik_app.name}.analytics")
except ModuleNotFoundError:
continue
except ImportError as exc:
LOGGER.warning(
"Could not import app's analytics", app_name=_authentik_app.name, exc=exc
)
continue
if not hasattr(module, "get_analytics_description") or not hasattr(
module, "get_analytics_data"
):
LOGGER.debug(
"App does not define API URLs",
app_name=_authentik_app.name,
)
continue
modules[_authentik_app.label] = module
return modules
def get_analytics_apps_description() -> dict[str, str]:
result = {}
for app_label, module in get_analytics_apps().items():
for k, v in module.get_analytics_description().items():
result[f"{app_label}/app/{k}"] = v
return result
def get_analytics_apps_data() -> dict[str, Any]:
result = {}
for app_label, module in get_analytics_apps().items():
for k, v in module.get_analytics_data().items():
result[f"{app_label}/app/{k}"] = v
return result
def get_analytics_models() -> list[AnalyticsMixin]:
def get_subclasses(cls):
for subclass in cls.__subclasses__():
if subclass.__subclasses__():
yield from get_subclasses(subclass)
elif not subclass._meta.abstract:
yield subclass
return list(get_subclasses(AnalyticsMixin))
def get_analytics_models_description() -> dict[str, str]:
result = {}
for model in get_analytics_models():
for k, v in model.get_analytics_description().items():
result[f"{model._meta.app_label}/models/{model._meta.object_name}/{k}"] = v
return result
def get_analytics_models_data() -> dict[str, Any]:
result = {}
for model in get_analytics_models():
for k, v in model.get_analytics_data().items():
result[f"{model._meta.app_label}/models/{model._meta.object_name}/{k}"] = v
return result
def get_analytics_description() -> dict[str, str]:
return {
**get_analytics_apps_description(),
**get_analytics_models_description(),
}
def get_analytics_data(current_tenant: Tenant | None = None, force: bool = False) -> dict[str, Any]:
current_tenant = current_tenant or get_current_tenant()
if not current_tenant.analytics_enabled and not force:
return {}
data = {
**get_analytics_apps_data(),
**get_analytics_models_data(),
}
to_remove = []
for key in data.keys():
if key not in current_tenant.analytics_sources:
to_remove.append(key)
for key in to_remove:
del data[key]
return {
**data,
"install_id_hash": sha256(get_install_id().encode()).hexdigest(),
"tenant_hash": sha256(current_tenant.tenant_uuid.bytes).hexdigest(),
"version": get_full_version(),
}

View File

@ -23,6 +23,7 @@ from model_utils.managers import InheritanceManager
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.analytics.models import AnalyticsMixin
from authentik.blueprints.models import ManagedModel from authentik.blueprints.models import ManagedModel
from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.core.types import UILoginButton, UserSettingSerializer
@ -168,7 +169,7 @@ class GroupQuerySet(CTEQuerySet):
return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte) return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte)
class Group(SerializerModel, AttributesMixin): class Group(SerializerModel, AttributesMixin, AnalyticsMixin):
"""Group model which supports a basic hierarchy and has attributes""" """Group model which supports a basic hierarchy and has attributes"""
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -258,7 +259,7 @@ class UserManager(DjangoUserManager):
return self.get_queryset().exclude_anonymous() return self.get_queryset().exclude_anonymous()
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser): class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser, AnalyticsMixin):
"""authentik User model, based on django's contrib auth user model.""" """authentik User model, based on django's contrib auth user model."""
uuid = models.UUIDField(default=uuid4, editable=False, unique=True) uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
@ -376,7 +377,7 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
return get_avatar(self) return get_avatar(self)
class Provider(SerializerModel): class Provider(SerializerModel, AnalyticsMixin):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
name = models.TextField(unique=True) name = models.TextField(unique=True)
@ -470,7 +471,7 @@ class ApplicationQuerySet(QuerySet):
return qs return qs
class Application(SerializerModel, PolicyBindingModel): class Application(SerializerModel, PolicyBindingModel, AnalyticsMixin):
"""Every Application which uses authentik for authentication/identification/authorization """Every Application which uses authentik for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to needs an Application record. Other authentication types can subclass this Model to
add custom fields and other properties""" add custom fields and other properties"""
@ -603,7 +604,7 @@ class SourceGroupMatchingModes(models.TextChoices):
) )
class Source(ManagedModel, SerializerModel, PolicyBindingModel): class Source(ManagedModel, SerializerModel, PolicyBindingModel, AnalyticsMixin):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
name = models.TextField(help_text=_("Source's display Name.")) name = models.TextField(help_text=_("Source's display Name."))
@ -735,7 +736,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
] ]
class UserSourceConnection(SerializerModel, CreatedUpdatedModel): class UserSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
"""Connection between User and Source.""" """Connection between User and Source."""
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
@ -755,7 +756,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
unique_together = (("user", "source"),) unique_together = (("user", "source"),)
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel): class GroupSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
"""Connection between Group and Source.""" """Connection between Group and Source."""
group = models.ForeignKey(Group, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE)
@ -879,7 +880,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
).save() ).save()
class PropertyMapping(SerializerModel, ManagedModel): class PropertyMapping(SerializerModel, ManagedModel, AnalyticsMixin):
"""User-defined key -> x mapping which can be used by providers to expose extra data.""" """User-defined key -> x mapping which can be used by providers to expose extra data."""
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)

View File

@ -0,0 +1,49 @@
# Generated by Django 5.0.9 on 2024-09-25 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0007_event_authentik_e_action_9a9dd9_idx_and_more"),
]
operations = [
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"),
("analytics_sent", "Analytics Sent"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
]
),
),
]

View File

@ -119,6 +119,7 @@ class EventAction(models.TextChoices):
MODEL_DELETED = "model_deleted" MODEL_DELETED = "model_deleted"
EMAIL_SENT = "email_sent" EMAIL_SENT = "email_sent"
ANALYTICS_SENT = "analytics_sent"
UPDATE_AVAILABLE = "update_available" UPDATE_AVAILABLE = "update_available"
CUSTOM_PREFIX = "custom_" CUSTOM_PREFIX = "custom_"

View File

@ -0,0 +1,52 @@
# Generated by Django 5.0.9 on 2024-09-25 11:06
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"),
("analytics_sent", "Analytics Sent"),
("update_available", "Update Available"),
("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,
),
),
]

View File

@ -70,6 +70,7 @@ TENANT_APPS = [
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"authentik.admin", "authentik.admin",
"authentik.analytics",
"authentik.api", "authentik.api",
"authentik.crypto", "authentik.crypto",
"authentik.flows", "authentik.flows",

View File

@ -1,9 +1,12 @@
"""Serializer for tenants models""" """Serializer for tenants models"""
from django_tenants.utils import get_public_schema_name from django_tenants.utils import get_public_schema_name
from rest_framework.fields import SerializerMethodField
from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import SAFE_METHODS from rest_framework.permissions import SAFE_METHODS
from authentik.analytics.api import AnalyticsDescriptionSerializer
from authentik.analytics.utils import get_analytics_description
from authentik.core.api.utils import ModelSerializer from authentik.core.api.utils import ModelSerializer
from authentik.rbac.permissions import HasPermission from authentik.rbac.permissions import HasPermission
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
@ -12,6 +15,8 @@ from authentik.tenants.models import Tenant
class SettingsSerializer(ModelSerializer): class SettingsSerializer(ModelSerializer):
"""Settings Serializer""" """Settings Serializer"""
analytics_sources_obj = SerializerMethodField()
class Meta: class Meta:
model = Tenant model = Tenant
fields = [ fields = [
@ -25,8 +30,19 @@ class SettingsSerializer(ModelSerializer):
"impersonation", "impersonation",
"default_token_duration", "default_token_duration",
"default_token_length", "default_token_length",
"default_token_length",
"analytics_enabled",
"analytics_sources",
"analytics_sources_obj",
] ]
def get_analytics_sources_obj(self, obj: Tenant) -> list[AnalyticsDescriptionSerializer]:
result = []
for label, desc in get_analytics_description().items():
if label in obj.analytics_sources:
result.append((label, desc))
return result
class SettingsView(RetrieveUpdateAPIView): class SettingsView(RetrieveUpdateAPIView):
"""Settings view""" """Settings view"""

View File

@ -0,0 +1,26 @@
# Generated by Django 5.0.9 on 2024-09-24 15:36
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0003_alter_tenant_default_token_duration"),
]
operations = [
migrations.AddField(
model_name="tenant",
name="analytics_enabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="tenant",
name="analytics_sources",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, size=None
),
),
]

View File

@ -4,6 +4,7 @@ import re
from uuid import uuid4 from uuid import uuid4
from django.apps import apps from django.apps import apps
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
@ -96,6 +97,9 @@ class Tenant(TenantMixin, SerializerModel):
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
) )
analytics_enabled = models.BooleanField(default=False)
analytics_sources = ArrayField(models.TextField(), blank=True, default=list)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.schema_name == "template": if self.schema_name == "template":
raise IntegrityError("Cannot create schema named template") raise IntegrityError("Cannot create schema named template")

View File

@ -4227,6 +4227,7 @@
"model_updated", "model_updated",
"model_deleted", "model_deleted",
"email_sent", "email_sent",
"analytics_sent",
"update_available", "update_available",
"custom_" "custom_"
], ],
@ -4251,6 +4252,7 @@
null, null,
"authentik.tenants", "authentik.tenants",
"authentik.admin", "authentik.admin",
"authentik.analytics",
"authentik.api", "authentik.api",
"authentik.crypto", "authentik.crypto",
"authentik.flows", "authentik.flows",
@ -13116,6 +13118,7 @@
"model_updated", "model_updated",
"model_deleted", "model_deleted",
"email_sent", "email_sent",
"analytics_sent",
"update_available", "update_available",
"custom_" "custom_"
], ],
@ -13277,6 +13280,7 @@
"model_updated", "model_updated",
"model_deleted", "model_deleted",
"email_sent", "email_sent",
"analytics_sent",
"update_available", "update_available",
"custom_" "custom_"
], ],

View File

@ -290,6 +290,64 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/analytics/data/:
get:
operationId: analytics_data_list
description: Read-only view of analytics descriptions
tags:
- analytics
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/AnalyticsData'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/analytics/description/:
get:
operationId: analytics_description_list
description: Read-only view of analytics descriptions
tags:
- analytics
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/AnalyticsDescription'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/authenticators/admin/all/: /authenticators/admin/all/:
get: get:
operationId: authenticators_admin_all_list operationId: authenticators_admin_all_list
@ -11502,6 +11560,7 @@ paths:
type: string type: string
nullable: true nullable: true
enum: enum:
- analytics_sent
- authorize_application - authorize_application
- configuration_error - configuration_error
- custom_ - custom_
@ -35758,6 +35817,25 @@ components:
- rsa - rsa
- ecdsa - ecdsa
type: string type: string
AnalyticsData:
type: object
properties:
data:
type: object
additionalProperties: {}
required:
- data
AnalyticsDescription:
type: object
description: Base serializer class which doesn't implement create/update methods
properties:
label:
type: string
desc:
type: string
required:
- desc
- label
App: App:
type: object type: object
description: Serialize Application info description: Serialize Application info
@ -35773,6 +35851,7 @@ components:
enum: enum:
- authentik.tenants - authentik.tenants
- authentik.admin - authentik.admin
- authentik.analytics
- authentik.api - authentik.api
- authentik.crypto - authentik.crypto
- authentik.flows - authentik.flows
@ -38960,6 +39039,7 @@ components:
- model_updated - model_updated
- model_deleted - model_deleted
- email_sent - email_sent
- analytics_sent
- update_available - update_available
- custom_ - custom_
type: string type: string
@ -47361,6 +47441,13 @@ components:
maximum: 2147483647 maximum: 2147483647
minimum: 1 minimum: 1
description: Default token length description: Default token length
analytics_enabled:
type: boolean
analytics_sources:
type: array
items:
type: string
minLength: 1
PatchedSourceStageRequest: PatchedSourceStageRequest:
type: object type: object
description: SourceStage Serializer description: SourceStage Serializer
@ -51001,6 +51088,23 @@ components:
maximum: 2147483647 maximum: 2147483647
minimum: 1 minimum: 1
description: Default token length description: Default token length
analytics_enabled:
type: boolean
analytics_sources:
type: array
items:
type: string
analytics_sources_obj:
type: array
items:
type: array
items:
type: string
minLength: 2
maxLength: 2
readOnly: true
required:
- analytics_sources_obj
SettingsRequest: SettingsRequest:
type: object type: object
description: Settings Serializer description: Settings Serializer
@ -51041,6 +51145,13 @@ components:
maximum: 2147483647 maximum: 2147483647
minimum: 1 minimum: 1
description: Default token length description: Default token length
analytics_enabled:
type: boolean
analytics_sources:
type: array
items:
type: string
minLength: 1
SeverityEnum: SeverityEnum:
enum: enum:
- notice - notice

View File

@ -5,6 +5,8 @@ import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-text-input";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import { DataProvision, DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
import { Form } from "@goauthentik/elements/forms/Form"; import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
@ -19,7 +21,17 @@ import { ifDefined } from "lit/directives/if-defined.js";
import PFList from "@patternfly/patternfly/components/List/list.css"; import PFList from "@patternfly/patternfly/components/List/list.css";
import { AdminApi, Settings, SettingsRequest } from "@goauthentik/api"; import {
AdminApi,
AnalyticsApi,
AnalyticsDescription,
Settings,
SettingsRequest,
} from "@goauthentik/api";
function analyticsSourceToPair(analyticsSource: AnalyticsDescription): DualSelectPair {
return [analyticsSource.label, analyticsSource.desc];
}
@customElement("ak-admin-settings-form") @customElement("ak-admin-settings-form")
export class AdminSettingsForm extends Form<SettingsRequest> { export class AdminSettingsForm extends Form<SettingsRequest> {
@ -211,6 +223,42 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
value="${first(this._settings?.defaultTokenLength, 60)}" value="${first(this._settings?.defaultTokenLength, 60)}"
help=${msg("Default length of generated tokens")} help=${msg("Default length of generated tokens")}
></ak-number-input> ></ak-number-input>
<ak-form-group ?expanded=${false}>
<span slot="header"> ${msg("Analytics")} </span>
<div slot="body" class="pf-c-form">
<ak-switch-input
name="analyticsEnabled"
label=${msg("Enable analytics")}
?checked="${this._settings?.analyticsEnabled}"
help=${msg("Enable sending analytics to the authentik team")}
>
</ak-switch-input>
<ak-form-element-horizontal
label=${msg("Analytics sources")}
name="analyticsSources"
>
<ak-dual-select-provider
.provider=${async (
_page: number,
_search?: string,
): Promise<DataProvision> => {
return new AnalyticsApi(DEFAULT_CONFIG)
.analyticsDescriptionList()
.then((results) => {
return {
options: results.map(analyticsSourceToPair),
};
});
}}
.selected=${this._settings?.analyticsSourcesObj ?? []}
available-label="${msg("Available sources")}"
selected-label="${msg("Selected sources")}"
>
</ak-dual-select-provider>
<p class="pf-c-form__helper-text">${msg("Choose what data to send")}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
`; `;
} }
} }