diff --git a/authentik/admin/analytics.py b/authentik/admin/analytics.py new file mode 100644 index 0000000000..1d4b7f6556 --- /dev/null +++ b/authentik/admin/analytics.py @@ -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, + } diff --git a/authentik/analytics/__init__.py b/authentik/analytics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/analytics/api.py b/authentik/analytics/api.py new file mode 100644 index 0000000000..91a9a1c14b --- /dev/null +++ b/authentik/analytics/api.py @@ -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), + } + ) diff --git a/authentik/analytics/apps.py b/authentik/analytics/apps.py new file mode 100644 index 0000000000..036fe12b83 --- /dev/null +++ b/authentik/analytics/apps.py @@ -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 diff --git a/authentik/analytics/models.py b/authentik/analytics/models.py new file mode 100644 index 0000000000..c1f20ade1b --- /dev/null +++ b/authentik/analytics/models.py @@ -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()} diff --git a/authentik/analytics/settings.py b/authentik/analytics/settings.py new file mode 100644 index 0000000000..69ad46f344 --- /dev/null +++ b/authentik/analytics/settings.py @@ -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"}, + } +} diff --git a/authentik/analytics/tasks.py b/authentik/analytics/tasks.py new file mode 100644 index 0000000000..add52db7c9 --- /dev/null +++ b/authentik/analytics/tasks.py @@ -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) diff --git a/authentik/analytics/urls.py b/authentik/analytics/urls.py new file mode 100644 index 0000000000..ea2f894eab --- /dev/null +++ b/authentik/analytics/urls.py @@ -0,0 +1,8 @@ +"""API URLs""" + +from authentik.analytics.api import AnalyticsDataViewSet, AnalyticsDescriptionViewSet + +api_urlpatterns = [ + ("analytics/description", AnalyticsDescriptionViewSet, "description"), + ("analytics/data", AnalyticsDataViewSet, "data"), +] diff --git a/authentik/analytics/utils.py b/authentik/analytics/utils.py new file mode 100644 index 0000000000..dab8e99f77 --- /dev/null +++ b/authentik/analytics/utils.py @@ -0,0 +1,107 @@ +"""authentik analytics utils""" + +from hashlib import sha256 +from importlib import import_module +from typing import Any + +from structlog import get_logger + +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(), + } + for key in data.keys(): + if key not in current_tenant.analytics_sources: + data.pop(key) + return { + **data, + "install_id_hash": sha256(get_install_id().encode()).hexdigest(), + "tenant_hash": sha256(current_tenant.tenant_uuid.bytes).hexdigest(), + } diff --git a/authentik/core/models.py b/authentik/core/models.py index 8b6fbcbf56..78d7b9ebe6 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -23,6 +23,7 @@ from model_utils.managers import InheritanceManager from rest_framework.serializers import Serializer from structlog.stdlib import get_logger +from authentik.analytics.models import AnalyticsMixin from authentik.blueprints.models import ManagedModel from authentik.core.expression.exceptions import PropertyMappingExpressionException 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) -class Group(SerializerModel, AttributesMixin): +class Group(SerializerModel, AttributesMixin, AnalyticsMixin): """Group model which supports a basic hierarchy and has attributes""" group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) @@ -258,7 +259,7 @@ class UserManager(DjangoUserManager): 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.""" uuid = models.UUIDField(default=uuid4, editable=False, unique=True) @@ -376,7 +377,7 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser): return get_avatar(self) -class Provider(SerializerModel): +class Provider(SerializerModel, AnalyticsMixin): """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" name = models.TextField(unique=True) @@ -470,7 +471,7 @@ class ApplicationQuerySet(QuerySet): return qs -class Application(SerializerModel, PolicyBindingModel): +class Application(SerializerModel, PolicyBindingModel, AnalyticsMixin): """Every Application which uses authentik for authentication/identification/authorization needs an Application record. Other authentication types can subclass this Model to 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""" 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.""" user = models.ForeignKey(User, on_delete=models.CASCADE) @@ -755,7 +756,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel): unique_together = (("user", "source"),) -class GroupSourceConnection(SerializerModel, CreatedUpdatedModel): +class GroupSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin): """Connection between Group and Source.""" group = models.ForeignKey(Group, on_delete=models.CASCADE) @@ -879,7 +880,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel): ).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.""" pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) diff --git a/authentik/events/migrations/0008_alter_event_action.py b/authentik/events/migrations/0008_alter_event_action.py new file mode 100644 index 0000000000..fa0b51b1b6 --- /dev/null +++ b/authentik/events/migrations/0008_alter_event_action.py @@ -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"), + ] + ), + ), + ] diff --git a/authentik/events/models.py b/authentik/events/models.py index 2f6a5b9d7e..ee06352c34 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -119,6 +119,7 @@ class EventAction(models.TextChoices): MODEL_DELETED = "model_deleted" EMAIL_SENT = "email_sent" + ANALYTICS_SENT = "analytics_sent" UPDATE_AVAILABLE = "update_available" CUSTOM_PREFIX = "custom_" diff --git a/authentik/policies/event_matcher/migrations/0024_alter_eventmatcherpolicy_action.py b/authentik/policies/event_matcher/migrations/0024_alter_eventmatcherpolicy_action.py new file mode 100644 index 0000000000..4c28c358c3 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0024_alter_eventmatcherpolicy_action.py @@ -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, + ), + ), + ] diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 8b3c7666b3..0d46c54b8b 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -70,6 +70,7 @@ TENANT_APPS = [ "django.contrib.contenttypes", "django.contrib.sessions", "authentik.admin", + "authentik.analytics", "authentik.api", "authentik.crypto", "authentik.flows", diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py index ad98165c05..3521e16c02 100644 --- a/authentik/tenants/api/settings.py +++ b/authentik/tenants/api/settings.py @@ -1,9 +1,12 @@ """Serializer for tenants models""" from django_tenants.utils import get_public_schema_name +from rest_framework.fields import SerializerMethodField from rest_framework.generics import RetrieveUpdateAPIView 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.rbac.permissions import HasPermission from authentik.tenants.models import Tenant @@ -12,6 +15,8 @@ from authentik.tenants.models import Tenant class SettingsSerializer(ModelSerializer): """Settings Serializer""" + analytics_sources_obj = SerializerMethodField() + class Meta: model = Tenant fields = [ @@ -25,8 +30,19 @@ class SettingsSerializer(ModelSerializer): "impersonation", "default_token_duration", "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): """Settings view""" diff --git a/authentik/tenants/migrations/0004_tenant_analytics_enabled_tenant_analytics_sources.py b/authentik/tenants/migrations/0004_tenant_analytics_enabled_tenant_analytics_sources.py new file mode 100644 index 0000000000..c201fd6033 --- /dev/null +++ b/authentik/tenants/migrations/0004_tenant_analytics_enabled_tenant_analytics_sources.py @@ -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 + ), + ), + ] diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py index df44159c37..e4cbb8125a 100644 --- a/authentik/tenants/models.py +++ b/authentik/tenants/models.py @@ -4,6 +4,7 @@ import re from uuid import uuid4 from django.apps import apps +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models @@ -96,6 +97,9 @@ class Tenant(TenantMixin, SerializerModel): validators=[MinValueValidator(1)], ) + analytics_enabled = models.BooleanField(default=False) + analytics_sources = ArrayField(models.TextField(), blank=True, default=list) + def save(self, *args, **kwargs): if self.schema_name == "template": raise IntegrityError("Cannot create schema named template") diff --git a/blueprints/schema.json b/blueprints/schema.json index c93c0d6a2a..6249b4b683 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -4227,6 +4227,7 @@ "model_updated", "model_deleted", "email_sent", + "analytics_sent", "update_available", "custom_" ], @@ -4251,6 +4252,7 @@ null, "authentik.tenants", "authentik.admin", + "authentik.analytics", "authentik.api", "authentik.crypto", "authentik.flows", @@ -13116,6 +13118,7 @@ "model_updated", "model_deleted", "email_sent", + "analytics_sent", "update_available", "custom_" ], @@ -13277,6 +13280,7 @@ "model_updated", "model_deleted", "email_sent", + "analytics_sent", "update_available", "custom_" ], diff --git a/schema.yml b/schema.yml index 48f5984395..171543f7d2 100644 --- a/schema.yml +++ b/schema.yml @@ -290,6 +290,64 @@ paths: schema: $ref: '#/components/schemas/GenericError' 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/: get: operationId: authenticators_admin_all_list @@ -11502,6 +11560,7 @@ paths: type: string nullable: true enum: + - analytics_sent - authorize_application - configuration_error - custom_ @@ -35758,6 +35817,25 @@ components: - rsa - ecdsa 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: type: object description: Serialize Application info @@ -35773,6 +35851,7 @@ components: enum: - authentik.tenants - authentik.admin + - authentik.analytics - authentik.api - authentik.crypto - authentik.flows @@ -38960,6 +39039,7 @@ components: - model_updated - model_deleted - email_sent + - analytics_sent - update_available - custom_ type: string @@ -47361,6 +47441,13 @@ components: maximum: 2147483647 minimum: 1 description: Default token length + analytics_enabled: + type: boolean + analytics_sources: + type: array + items: + type: string + minLength: 1 PatchedSourceStageRequest: type: object description: SourceStage Serializer @@ -51001,6 +51088,23 @@ components: maximum: 2147483647 minimum: 1 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: type: object description: Settings Serializer @@ -51041,6 +51145,13 @@ components: maximum: 2147483647 minimum: 1 description: Default token length + analytics_enabled: + type: boolean + analytics_sources: + type: array + items: + type: string + minLength: 1 SeverityEnum: enum: - notice diff --git a/web/src/admin/admin-settings/AdminSettingsForm.ts b/web/src/admin/admin-settings/AdminSettingsForm.ts index 4689e092dc..8edde52cc3 100644 --- a/web/src/admin/admin-settings/AdminSettingsForm.ts +++ b/web/src/admin/admin-settings/AdminSettingsForm.ts @@ -5,6 +5,8 @@ import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-text-input"; import "@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 "@goauthentik/elements/forms/FormGroup"; 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 { 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") export class AdminSettingsForm extends Form { @@ -211,6 +223,42 @@ export class AdminSettingsForm extends Form { value="${first(this._settings?.defaultTokenLength, 60)}" help=${msg("Default length of generated tokens")} > + + ${msg("Analytics")} +
+ + + + => { + 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")}" + > + +

${msg("Choose what data to send")}

+
+
+
`; } }