Compare commits
	
		
			3 Commits
		
	
	
		
			enterprise
			...
			analytics
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 435ba598bb | |||
| 582511abcc | |||
| 80ea1dae81 | 
							
								
								
									
										20
									
								
								authentik/admin/analytics.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								authentik/admin/analytics.py
									
									
									
									
									
										Normal 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, | ||||
|     } | ||||
							
								
								
									
										0
									
								
								authentik/analytics/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/analytics/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										54
									
								
								authentik/analytics/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								authentik/analytics/api.py
									
									
									
									
									
										Normal 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), | ||||
|             } | ||||
|         ) | ||||
							
								
								
									
										12
									
								
								authentik/analytics/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								authentik/analytics/apps.py
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										19
									
								
								authentik/analytics/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								authentik/analytics/models.py
									
									
									
									
									
										Normal 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()} | ||||
							
								
								
									
										17
									
								
								authentik/analytics/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								authentik/analytics/settings.py
									
									
									
									
									
										Normal 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"}, | ||||
|     } | ||||
| } | ||||
							
								
								
									
										45
									
								
								authentik/analytics/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								authentik/analytics/tasks.py
									
									
									
									
									
										Normal 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) | ||||
							
								
								
									
										76
									
								
								authentik/analytics/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								authentik/analytics/tests.py
									
									
									
									
									
										Normal 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()) | ||||
							
								
								
									
										8
									
								
								authentik/analytics/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								authentik/analytics/urls.py
									
									
									
									
									
										Normal 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"), | ||||
| ] | ||||
							
								
								
									
										112
									
								
								authentik/analytics/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								authentik/analytics/utils.py
									
									
									
									
									
										Normal 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(), | ||||
|     } | ||||
| @ -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) | ||||
|  | ||||
							
								
								
									
										49
									
								
								authentik/events/migrations/0008_alter_event_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								authentik/events/migrations/0008_alter_event_action.py
									
									
									
									
									
										Normal 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"), | ||||
|                 ] | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -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_" | ||||
|  | ||||
| @ -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, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -70,6 +70,7 @@ TENANT_APPS = [ | ||||
|     "django.contrib.contenttypes", | ||||
|     "django.contrib.sessions", | ||||
|     "authentik.admin", | ||||
|     "authentik.analytics", | ||||
|     "authentik.api", | ||||
|     "authentik.crypto", | ||||
|     "authentik.flows", | ||||
|  | ||||
| @ -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""" | ||||
|  | ||||
| @ -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 | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -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") | ||||
|  | ||||
| @ -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_" | ||||
|                             ], | ||||
|  | ||||
							
								
								
									
										111
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										111
									
								
								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 | ||||
|  | ||||
| @ -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<SettingsRequest> { | ||||
| @ -211,6 +223,42 @@ export class AdminSettingsForm extends Form<SettingsRequest> { | ||||
|                 value="${first(this._settings?.defaultTokenLength, 60)}" | ||||
|                 help=${msg("Default length of generated tokens")} | ||||
|             ></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> | ||||
|         `; | ||||
|     } | ||||
| } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	