sources/ldap: add check command to verify ldap connectivity (#7263)
* sources/ldap: add check command to verify ldap connectivity Signed-off-by: Jens Langhammer <jens@goauthentik.io> * default to checking all sources Signed-off-by: Jens Langhammer <jens@goauthentik.io> * start adding an API for ldap connectivity Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add webui for ldap source connection status Signed-off-by: Jens Langhammer <jens@goauthentik.io> * better show sync status, clear previous tasks Signed-off-by: Jens Langhammer <jens@goauthentik.io> * set timeout on redis lock for ldap sync Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix py lint Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix web lint Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
		| @ -1,13 +1,14 @@ | ||||
| """Source API Views""" | ||||
| from typing import Any | ||||
| from typing import Any, Optional | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from django_filters.filters import AllValuesMultipleFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import extend_schema, extend_schema_field, inline_serializer | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.fields import DictField, ListField | ||||
| from rest_framework.fields import BooleanField, DictField, ListField, SerializerMethodField | ||||
| from rest_framework.relations import PrimaryKeyRelatedField | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| @ -17,15 +18,17 @@ from authentik.admin.api.tasks import TaskSerializer | ||||
| from authentik.core.api.propertymappings import PropertyMappingSerializer | ||||
| from authentik.core.api.sources import SourceSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.events.monitored_tasks import TaskInfo | ||||
| from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource | ||||
| from authentik.sources.ldap.tasks import SYNC_CLASSES | ||||
| from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES | ||||
|  | ||||
|  | ||||
| class LDAPSourceSerializer(SourceSerializer): | ||||
|     """LDAP Source Serializer""" | ||||
|  | ||||
|     connectivity = SerializerMethodField() | ||||
|     client_certificate = PrimaryKeyRelatedField( | ||||
|         allow_null=True, | ||||
|         help_text="Client certificate to authenticate against the LDAP Server's Certificate.", | ||||
| @ -35,6 +38,10 @@ class LDAPSourceSerializer(SourceSerializer): | ||||
|         required=False, | ||||
|     ) | ||||
|  | ||||
|     def get_connectivity(self, source: LDAPSource) -> Optional[dict[str, dict[str, str]]]: | ||||
|         """Get cached source connectivity""" | ||||
|         return cache.get(CACHE_KEY_STATUS + source.slug, None) | ||||
|  | ||||
|     def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: | ||||
|         """Check that only a single source has password_sync on""" | ||||
|         sync_users_password = attrs.get("sync_users_password", True) | ||||
| @ -75,10 +82,18 @@ class LDAPSourceSerializer(SourceSerializer): | ||||
|             "sync_parent_group", | ||||
|             "property_mappings", | ||||
|             "property_mappings_group", | ||||
|             "connectivity", | ||||
|         ] | ||||
|         extra_kwargs = {"bind_password": {"write_only": True}} | ||||
|  | ||||
|  | ||||
| class LDAPSyncStatusSerializer(PassiveSerializer): | ||||
|     """LDAP Source sync status""" | ||||
|  | ||||
|     is_running = BooleanField(read_only=True) | ||||
|     tasks = TaskSerializer(many=True, read_only=True) | ||||
|  | ||||
|  | ||||
| class LDAPSourceViewSet(UsedByMixin, ModelViewSet): | ||||
|     """LDAP Source Viewset""" | ||||
|  | ||||
| @ -114,19 +129,19 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet): | ||||
|  | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             200: TaskSerializer(many=True), | ||||
|             200: LDAPSyncStatusSerializer(), | ||||
|         } | ||||
|     ) | ||||
|     @action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[]) | ||||
|     def sync_status(self, request: Request, slug: str) -> Response: | ||||
|         """Get source's sync status""" | ||||
|         source = self.get_object() | ||||
|         results = [] | ||||
|         tasks = TaskInfo.by_name(f"ldap_sync:{source.slug}:*") | ||||
|         if tasks: | ||||
|             for task in tasks: | ||||
|                 results.append(task) | ||||
|         return Response(TaskSerializer(results, many=True).data) | ||||
|         source: LDAPSource = self.get_object() | ||||
|         tasks = TaskInfo.by_name(f"ldap_sync:{source.slug}:*") or [] | ||||
|         status = { | ||||
|             "tasks": tasks, | ||||
|             "is_running": source.sync_lock.locked(), | ||||
|         } | ||||
|         return Response(LDAPSyncStatusSerializer(status).data) | ||||
|  | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|  | ||||
| @ -0,0 +1,24 @@ | ||||
| """LDAP Connection check""" | ||||
| from json import dumps | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.sources.ldap.models import LDAPSource | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     """Check connectivity to LDAP servers for a source""" | ||||
|  | ||||
|     def add_arguments(self, parser): | ||||
|         parser.add_argument("source_slugs", nargs="?", type=str) | ||||
|  | ||||
|     def handle(self, **options): | ||||
|         sources = LDAPSource.objects.filter(enabled=True) | ||||
|         if options["source_slugs"]: | ||||
|             sources = LDAPSource.objects.filter(slug__in=options["source_slugs"]) | ||||
|         for source in sources.order_by("slug"): | ||||
|             status = source.check_connection() | ||||
|             self.stdout.write(dumps(status, indent=4)) | ||||
| @ -4,10 +4,12 @@ from ssl import CERT_REQUIRED | ||||
| from tempfile import NamedTemporaryFile, mkdtemp | ||||
| from typing import Optional | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from django.db import models | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls | ||||
| from ldap3.core.exceptions import LDAPInsufficientAccessRightsResult, LDAPSchemaError | ||||
| from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError | ||||
| from redis.lock import Lock | ||||
| from rest_framework.serializers import Serializer | ||||
|  | ||||
| from authentik.core.models import Group, PropertyMapping, Source | ||||
| @ -117,7 +119,7 @@ class LDAPSource(Source): | ||||
|  | ||||
|         return LDAPSourceSerializer | ||||
|  | ||||
|     def server(self, **kwargs) -> Server: | ||||
|     def server(self, **kwargs) -> ServerPool: | ||||
|         """Get LDAP Server/ServerPool""" | ||||
|         servers = [] | ||||
|         tls_kwargs = {} | ||||
| @ -154,7 +156,10 @@ class LDAPSource(Source): | ||||
|         return ServerPool(servers, RANDOM, active=5, exhaust=True) | ||||
|  | ||||
|     def connection( | ||||
|         self, server_kwargs: Optional[dict] = None, connection_kwargs: Optional[dict] = None | ||||
|         self, | ||||
|         server: Optional[Server] = None, | ||||
|         server_kwargs: Optional[dict] = None, | ||||
|         connection_kwargs: Optional[dict] = None, | ||||
|     ) -> Connection: | ||||
|         """Get a fully connected and bound LDAP Connection""" | ||||
|         server_kwargs = server_kwargs or {} | ||||
| @ -164,7 +169,7 @@ class LDAPSource(Source): | ||||
|         if self.bind_password is not None: | ||||
|             connection_kwargs.setdefault("password", self.bind_password) | ||||
|         connection = Connection( | ||||
|             self.server(**server_kwargs), | ||||
|             server or self.server(**server_kwargs), | ||||
|             raise_exceptions=True, | ||||
|             receive_timeout=LDAP_TIMEOUT, | ||||
|             **connection_kwargs, | ||||
| @ -183,9 +188,55 @@ class LDAPSource(Source): | ||||
|             if server_kwargs.get("get_info", ALL) == NONE: | ||||
|                 raise exc | ||||
|             server_kwargs["get_info"] = NONE | ||||
|             return self.connection(server_kwargs, connection_kwargs) | ||||
|             return self.connection(server, server_kwargs, connection_kwargs) | ||||
|         return RuntimeError("Failed to bind") | ||||
|  | ||||
|     @property | ||||
|     def sync_lock(self) -> Lock: | ||||
|         """Redis lock for syncing LDAP to prevent multiple parallel syncs happening""" | ||||
|         return Lock( | ||||
|             cache.client.get_client(), | ||||
|             name=f"goauthentik.io/sources/ldap/sync-{self.slug}", | ||||
|             # Convert task timeout hours to seconds, and multiply times 3 | ||||
|             # (see authentik/sources/ldap/tasks.py:54) | ||||
|             # multiply by 3 to add even more leeway | ||||
|             timeout=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 3, | ||||
|         ) | ||||
|  | ||||
|     def check_connection(self) -> dict[str, dict[str, str]]: | ||||
|         """Check LDAP Connection""" | ||||
|         from authentik.sources.ldap.sync.base import flatten | ||||
|  | ||||
|         servers = self.server() | ||||
|         server_info = {} | ||||
|         # Check each individual server | ||||
|         for server in servers.servers: | ||||
|             server: Server | ||||
|             try: | ||||
|                 connection = self.connection(server=server) | ||||
|                 server_info[server.host] = { | ||||
|                     "vendor": str(flatten(connection.server.info.vendor_name)), | ||||
|                     "version": str(flatten(connection.server.info.vendor_version)), | ||||
|                     "status": "ok", | ||||
|                 } | ||||
|             except LDAPException as exc: | ||||
|                 server_info[server.host] = { | ||||
|                     "status": str(exc), | ||||
|                 } | ||||
|         # Check server pool | ||||
|         try: | ||||
|             connection = self.connection() | ||||
|             server_info["__all__"] = { | ||||
|                 "vendor": str(flatten(connection.server.info.vendor_name)), | ||||
|                 "version": str(flatten(connection.server.info.vendor_version)), | ||||
|                 "status": "ok", | ||||
|             } | ||||
|         except LDAPException as exc: | ||||
|             server_info["__all__"] = { | ||||
|                 "status": str(exc), | ||||
|             } | ||||
|         return server_info | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("LDAP Source") | ||||
|         verbose_name_plural = _("LDAP Sources") | ||||
|  | ||||
| @ -8,5 +8,10 @@ CELERY_BEAT_SCHEDULE = { | ||||
|         "task": "authentik.sources.ldap.tasks.ldap_sync_all", | ||||
|         "schedule": crontab(minute=fqdn_rand("sources_ldap_sync"), hour="*/2"), | ||||
|         "options": {"queue": "authentik_scheduled"}, | ||||
|     } | ||||
|     }, | ||||
|     "sources_ldap_connectivity_check": { | ||||
|         "task": "authentik.sources.ldap.tasks.ldap_connectivity_check", | ||||
|         "schedule": crontab(minute=fqdn_rand("sources_ldap_connectivity_check"), hour="*"), | ||||
|         "options": {"queue": "authentik_scheduled"}, | ||||
|     }, | ||||
| } | ||||
|  | ||||
| @ -14,7 +14,7 @@ from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||
| from authentik.sources.ldap.models import LDAPSource | ||||
| from authentik.sources.ldap.password import LDAPPasswordChanger | ||||
| from authentik.sources.ldap.tasks import ldap_sync_single | ||||
| from authentik.sources.ldap.tasks import ldap_connectivity_check, ldap_sync_single | ||||
| from authentik.stages.prompt.signals import password_validate | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| @ -32,6 +32,7 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_): | ||||
|     if not instance.property_mappings.exists() or not instance.property_mappings_group.exists(): | ||||
|         return | ||||
|     ldap_sync_single.delay(instance.pk) | ||||
|     ldap_connectivity_check.delay(instance.pk) | ||||
|  | ||||
|  | ||||
| @receiver(password_validate) | ||||
|  | ||||
| @ -17,6 +17,15 @@ from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource | ||||
| LDAP_UNIQUENESS = "ldap_uniq" | ||||
|  | ||||
|  | ||||
| def flatten(value: Any) -> Any: | ||||
|     """Flatten `value` if its a list""" | ||||
|     if isinstance(value, list): | ||||
|         if len(value) < 1: | ||||
|             return None | ||||
|         return value[0] | ||||
|     return value | ||||
|  | ||||
|  | ||||
| class BaseLDAPSynchronizer: | ||||
|     """Sync LDAP Users and groups into authentik""" | ||||
|  | ||||
| @ -122,14 +131,6 @@ class BaseLDAPSynchronizer: | ||||
|                 cookie = None | ||||
|             yield self._connection.response | ||||
|  | ||||
|     def _flatten(self, value: Any) -> Any: | ||||
|         """Flatten `value` if its a list""" | ||||
|         if isinstance(value, list): | ||||
|             if len(value) < 1: | ||||
|                 return None | ||||
|             return value[0] | ||||
|         return value | ||||
|  | ||||
|     def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]: | ||||
|         """Build attributes for User object based on property mappings.""" | ||||
|         props = self._build_object_properties(user_dn, self._source.property_mappings, **kwargs) | ||||
| @ -163,10 +164,10 @@ class BaseLDAPSynchronizer: | ||||
|                 object_field = mapping.object_field | ||||
|                 if object_field.startswith("attributes."): | ||||
|                     # Because returning a list might desired, we can't | ||||
|                     # rely on self._flatten here. Instead, just save the result as-is | ||||
|                     # rely on flatten here. Instead, just save the result as-is | ||||
|                     set_path_in_dict(properties, object_field, value) | ||||
|                 else: | ||||
|                     properties[object_field] = self._flatten(value) | ||||
|                     properties[object_field] = flatten(value) | ||||
|             except PropertyMappingExpressionException as exc: | ||||
|                 Event.new( | ||||
|                     EventAction.CONFIGURATION_ERROR, | ||||
| @ -177,7 +178,7 @@ class BaseLDAPSynchronizer: | ||||
|                 self._logger.warning("Mapping failed to evaluate", exc=exc, mapping=mapping) | ||||
|                 continue | ||||
|         if self._source.object_uniqueness_field in kwargs: | ||||
|             properties["attributes"][LDAP_UNIQUENESS] = self._flatten( | ||||
|             properties["attributes"][LDAP_UNIQUENESS] = flatten( | ||||
|                 kwargs.get(self._source.object_uniqueness_field) | ||||
|             ) | ||||
|         properties["attributes"][LDAP_DISTINGUISHED_NAME] = object_dn | ||||
|  | ||||
| @ -7,7 +7,7 @@ from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE | ||||
|  | ||||
| from authentik.core.models import Group | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer | ||||
| from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten | ||||
|  | ||||
|  | ||||
| class GroupLDAPSynchronizer(BaseLDAPSynchronizer): | ||||
| @ -39,7 +39,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): | ||||
|             if "attributes" not in group: | ||||
|                 continue | ||||
|             attributes = group.get("attributes", {}) | ||||
|             group_dn = self._flatten(self._flatten(group.get("entryDN", group.get("dn")))) | ||||
|             group_dn = flatten(flatten(group.get("entryDN", group.get("dn")))) | ||||
|             if self._source.object_uniqueness_field not in attributes: | ||||
|                 self.message( | ||||
|                     f"Cannot find uniqueness field in attributes: '{group_dn}'", | ||||
| @ -47,7 +47,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): | ||||
|                     dn=group_dn, | ||||
|                 ) | ||||
|                 continue | ||||
|             uniq = self._flatten(attributes[self._source.object_uniqueness_field]) | ||||
|             uniq = flatten(attributes[self._source.object_uniqueness_field]) | ||||
|             try: | ||||
|                 defaults = self.build_group_properties(group_dn, **attributes) | ||||
|                 defaults["parent"] = self._source.sync_parent_group | ||||
|  | ||||
| @ -7,7 +7,7 @@ from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer | ||||
| from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten | ||||
| from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA | ||||
| from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory | ||||
|  | ||||
| @ -41,7 +41,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): | ||||
|             if "attributes" not in user: | ||||
|                 continue | ||||
|             attributes = user.get("attributes", {}) | ||||
|             user_dn = self._flatten(user.get("entryDN", user.get("dn"))) | ||||
|             user_dn = flatten(user.get("entryDN", user.get("dn"))) | ||||
|             if self._source.object_uniqueness_field not in attributes: | ||||
|                 self.message( | ||||
|                     f"Cannot find uniqueness field in attributes: '{user_dn}'", | ||||
| @ -49,7 +49,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): | ||||
|                     dn=user_dn, | ||||
|                 ) | ||||
|                 continue | ||||
|             uniq = self._flatten(attributes[self._source.object_uniqueness_field]) | ||||
|             uniq = flatten(attributes[self._source.object_uniqueness_field]) | ||||
|             try: | ||||
|                 defaults = self.build_user_properties(user_dn, **attributes) | ||||
|                 self._logger.debug("Writing user with attributes", **defaults) | ||||
|  | ||||
| @ -5,7 +5,7 @@ from typing import Any, Generator | ||||
| from pytz import UTC | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer | ||||
| from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer, flatten | ||||
|  | ||||
|  | ||||
| class FreeIPA(BaseLDAPSynchronizer): | ||||
| @ -47,7 +47,7 @@ class FreeIPA(BaseLDAPSynchronizer): | ||||
|             return | ||||
|         # For some reason, nsaccountlock is not defined properly in the schema as bool | ||||
|         # hence we get it as a list of strings | ||||
|         _is_locked = str(self._flatten(attributes.get("nsaccountlock", ["FALSE"]))) | ||||
|         _is_locked = str(flatten(attributes.get("nsaccountlock", ["FALSE"]))) | ||||
|         # So we have to attempt to convert it to a bool | ||||
|         is_locked = _is_locked.lower() == "true" | ||||
|         # And then invert it since freeipa saves locked and we save active | ||||
|  | ||||
| @ -1,13 +1,14 @@ | ||||
| """LDAP Sync tasks""" | ||||
| from typing import Optional | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from celery import chain, group | ||||
| from django.core.cache import cache | ||||
| from ldap3.core.exceptions import LDAPException | ||||
| from redis.exceptions import LockError | ||||
| from redis.lock import Lock | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.events.monitored_tasks import CACHE_KEY_PREFIX as CACHE_KEY_PREFIX_TASKS | ||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| @ -26,6 +27,7 @@ SYNC_CLASSES = [ | ||||
|     MembershipLDAPSynchronizer, | ||||
| ] | ||||
| CACHE_KEY_PREFIX = "goauthentik.io/sources/ldap/page/" | ||||
| CACHE_KEY_STATUS = "goauthentik.io/sources/ldap/status/" | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task() | ||||
| @ -35,6 +37,19 @@ def ldap_sync_all(): | ||||
|         ldap_sync_single.apply_async(args=[source.pk]) | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task() | ||||
| def ldap_connectivity_check(pk: Optional[str] = None): | ||||
|     """Check connectivity for LDAP Sources""" | ||||
|     # 2 hour timeout, this task should run every hour | ||||
|     timeout = 60 * 60 * 2 | ||||
|     sources = LDAPSource.objects.filter(enabled=True) | ||||
|     if pk: | ||||
|         sources = sources.filter(pk=pk) | ||||
|     for source in sources: | ||||
|         status = source.check_connection() | ||||
|         cache.set(CACHE_KEY_STATUS + source.slug, status, timeout=timeout) | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task( | ||||
|     # We take the configured hours timeout time by 2.5 as we run user and | ||||
|     # group in parallel and then membership, so 2x is to cover the serial tasks, | ||||
| @ -47,12 +62,15 @@ def ldap_sync_single(source_pk: str): | ||||
|     source: LDAPSource = LDAPSource.objects.filter(pk=source_pk).first() | ||||
|     if not source: | ||||
|         return | ||||
|     lock = Lock(cache.client.get_client(), name=f"goauthentik.io/sources/ldap/sync-{source.slug}") | ||||
|     lock = source.sync_lock | ||||
|     if lock.locked(): | ||||
|         LOGGER.debug("LDAP sync locked, skipping task", source=source.slug) | ||||
|         return | ||||
|     try: | ||||
|         with lock: | ||||
|             # Delete all sync tasks from the cache | ||||
|             keys = cache.keys(f"{CACHE_KEY_PREFIX_TASKS}ldap_sync:{source.slug}*") | ||||
|             cache.delete_many(keys) | ||||
|             task = chain( | ||||
|                 # User and group sync can happen at once, they have no dependencies on each other | ||||
|                 group( | ||||
|  | ||||
							
								
								
									
										31
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								schema.yml
									
									
									
									
									
								
							| @ -18942,7 +18942,7 @@ paths: | ||||
|           description: '' | ||||
|   /sources/ldap/{slug}/sync_status/: | ||||
|     get: | ||||
|       operationId: sources_ldap_sync_status_list | ||||
|       operationId: sources_ldap_sync_status_retrieve | ||||
|       description: Get source's sync status | ||||
|       parameters: | ||||
|       - in: path | ||||
| @ -18960,9 +18960,7 @@ paths: | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 type: array | ||||
|                 items: | ||||
|                   $ref: '#/components/schemas/Task' | ||||
|                 $ref: '#/components/schemas/LDAPSyncStatus' | ||||
|           description: '' | ||||
|         '400': | ||||
|           content: | ||||
| @ -32812,9 +32810,19 @@ components: | ||||
|             type: string | ||||
|             format: uuid | ||||
|           description: Property mappings used for group creation/updating. | ||||
|         connectivity: | ||||
|           type: object | ||||
|           additionalProperties: | ||||
|             type: object | ||||
|             additionalProperties: | ||||
|               type: string | ||||
|           nullable: true | ||||
|           description: Get cached source connectivity | ||||
|           readOnly: true | ||||
|       required: | ||||
|       - base_dn | ||||
|       - component | ||||
|       - connectivity | ||||
|       - icon | ||||
|       - managed | ||||
|       - meta_model_name | ||||
| @ -32948,6 +32956,21 @@ components: | ||||
|       - name | ||||
|       - server_uri | ||||
|       - slug | ||||
|     LDAPSyncStatus: | ||||
|       type: object | ||||
|       description: LDAP Source sync status | ||||
|       properties: | ||||
|         is_running: | ||||
|           type: boolean | ||||
|           readOnly: true | ||||
|         tasks: | ||||
|           type: array | ||||
|           items: | ||||
|             $ref: '#/components/schemas/Task' | ||||
|           readOnly: true | ||||
|       required: | ||||
|       - is_running | ||||
|       - tasks | ||||
|     LayoutEnum: | ||||
|       enum: | ||||
|       - stacked | ||||
|  | ||||
| @ -44,11 +44,11 @@ export class LDAPSyncStatusChart extends AKChart<SyncStatus[]> { | ||||
|         await Promise.all( | ||||
|             sources.results.map(async (element) => { | ||||
|                 try { | ||||
|                     const health = await api.sourcesLdapSyncStatusList({ | ||||
|                     const health = await api.sourcesLdapSyncStatusRetrieve({ | ||||
|                         slug: element.slug, | ||||
|                     }); | ||||
|  | ||||
|                     health.forEach((task) => { | ||||
|                     health.tasks.forEach((task) => { | ||||
|                         if (task.status !== TaskStatusEnum.Successful) { | ||||
|                             metrics.failed += 1; | ||||
|                         } | ||||
| @ -60,7 +60,7 @@ export class LDAPSyncStatusChart extends AKChart<SyncStatus[]> { | ||||
|                             metrics.healthy += 1; | ||||
|                         } | ||||
|                     }); | ||||
|                     if (health.length < 1) { | ||||
|                     if (health.tasks.length < 1) { | ||||
|                         metrics.unsynced += 1; | ||||
|                     } | ||||
|                 } catch { | ||||
|  | ||||
							
								
								
									
										50
									
								
								web/src/admin/sources/ldap/LDAPSourceConnectivity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								web/src/admin/sources/ldap/LDAPSourceConnectivity.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,50 @@ | ||||
| import { AKElement } from "@goauthentik/app/elements/Base"; | ||||
| import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; | ||||
|  | ||||
| import { msg } from "@lit/localize"; | ||||
| import { CSSResult, TemplateResult, html } from "lit"; | ||||
| import { customElement, property } from "lit/decorators.js"; | ||||
|  | ||||
| import PFList from "@patternfly/patternfly/components/List/list.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| @customElement("ak-source-ldap-connectivity") | ||||
| export class LDAPSourceConnectivity extends AKElement { | ||||
|     @property() | ||||
|     connectivity?: { | ||||
|         [key: string]: { | ||||
|             [key: string]: string; | ||||
|         }; | ||||
|     }; | ||||
|  | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [PFBase, PFList]; | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         if (!this.connectivity) { | ||||
|             return html``; | ||||
|         } | ||||
|         return html`<ul class="pf-c-list"> | ||||
|             ${Object.keys(this.connectivity).map((serverKey) => { | ||||
|                 let serverLabel = html`${serverKey}`; | ||||
|                 if (serverKey === "__all__") { | ||||
|                     serverLabel = html`<b>${msg("Global status")}</b>`; | ||||
|                 } | ||||
|                 const server = this.connectivity![serverKey]; | ||||
|                 const content = html`${serverLabel}: ${server.status}`; | ||||
|                 let tooltip = html`${content}`; | ||||
|                 if (server.status === "ok") { | ||||
|                     tooltip = html`<pf-tooltip position="top"> | ||||
|                         <ul slot="content" class="pf-c-list"> | ||||
|                             <li>${msg("Vendor")}: ${server.vendor}</li> | ||||
|                             <li>${msg("Version")}: ${server.version}</li> | ||||
|                         </ul> | ||||
|                         ${content} | ||||
|                     </pf-tooltip>`; | ||||
|                 } | ||||
|                 return html`<li>${tooltip}</li>`; | ||||
|             })} | ||||
|         </ul>`; | ||||
|     } | ||||
| } | ||||
| @ -1,3 +1,4 @@ | ||||
| import "@goauthentik/admin/sources/ldap/LDAPSourceConnectivity"; | ||||
| import "@goauthentik/admin/sources/ldap/LDAPSourceForm"; | ||||
| import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| @ -25,9 +26,9 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { | ||||
|     LDAPSource, | ||||
|     LDAPSyncStatus, | ||||
|     RbacPermissionsAssignedByUsersListModelEnum, | ||||
|     SourcesApi, | ||||
|     Task, | ||||
|     TaskStatusEnum, | ||||
| } from "@goauthentik/api"; | ||||
|  | ||||
| @ -48,7 +49,7 @@ export class LDAPSourceViewPage extends AKElement { | ||||
|     source!: LDAPSource; | ||||
|  | ||||
|     @state() | ||||
|     syncState: Task[] = []; | ||||
|     syncState?: LDAPSyncStatus; | ||||
|  | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [PFBase, PFPage, PFButton, PFGrid, PFContent, PFCard, PFDescriptionList, PFList]; | ||||
| @ -62,6 +63,51 @@ export class LDAPSourceViewPage extends AKElement { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     renderSyncStatus(): TemplateResult { | ||||
|         if (!this.syncState) { | ||||
|             return html`${msg("No sync status.")}`; | ||||
|         } | ||||
|         if (this.syncState.isRunning) { | ||||
|             return html`${msg("Sync currently running.")}`; | ||||
|         } | ||||
|         if (this.syncState.tasks.length < 1) { | ||||
|             return html`${msg("Not synced yet.")}`; | ||||
|         } | ||||
|         return html` | ||||
|             <ul class="pf-c-list"> | ||||
|                 ${this.syncState.tasks.map((task) => { | ||||
|                     let header = ""; | ||||
|                     if (task.status === TaskStatusEnum.Warning) { | ||||
|                         header = msg("Task finished with warnings"); | ||||
|                     } else if (task.status === TaskStatusEnum.Error) { | ||||
|                         header = msg("Task finished with errors"); | ||||
|                     } else { | ||||
|                         header = msg(str`Last sync: ${task.taskFinishTimestamp.toLocaleString()}`); | ||||
|                     } | ||||
|                     return html`<li> | ||||
|                         <p>${task.taskName}</p> | ||||
|                         <ul class="pf-c-list"> | ||||
|                             <li>${header}</li> | ||||
|                             ${task.messages.map((m) => { | ||||
|                                 return html`<li>${m}</li>`; | ||||
|                             })} | ||||
|                         </ul> | ||||
|                     </li> `; | ||||
|                 })} | ||||
|             </ul> | ||||
|         `; | ||||
|     } | ||||
|  | ||||
|     load(): void { | ||||
|         new SourcesApi(DEFAULT_CONFIG) | ||||
|             .sourcesLdapSyncStatusRetrieve({ | ||||
|                 slug: this.source.slug, | ||||
|             }) | ||||
|             .then((state) => { | ||||
|                 this.syncState = state; | ||||
|             }); | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         if (!this.source) { | ||||
|             return html``; | ||||
| @ -72,13 +118,7 @@ export class LDAPSourceViewPage extends AKElement { | ||||
|                 data-tab-title="${msg("Overview")}" | ||||
|                 class="pf-c-page__main-section pf-m-no-padding-mobile" | ||||
|                 @activate=${() => { | ||||
|                     new SourcesApi(DEFAULT_CONFIG) | ||||
|                         .sourcesLdapSyncStatusList({ | ||||
|                             slug: this.source.slug, | ||||
|                         }) | ||||
|                         .then((state) => { | ||||
|                             this.syncState = state; | ||||
|                         }); | ||||
|                     this.load(); | ||||
|                 }} | ||||
|             > | ||||
|                 <div class="pf-l-grid pf-m-gutter"> | ||||
| @ -137,42 +177,25 @@ export class LDAPSourceViewPage extends AKElement { | ||||
|                             </ak-forms-modal> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="pf-c-card pf-l-grid__item pf-m-12-col"> | ||||
|                     <div class="pf-c-card pf-l-grid__item pf-m-2-col"> | ||||
|                         <div class="pf-c-card__title"> | ||||
|                             <p>${msg("Connectivity")}</p> | ||||
|                         </div> | ||||
|                         <div class="pf-c-card__body"> | ||||
|                             <ak-source-ldap-connectivity | ||||
|                                 .connectivity=${this.source.connectivity} | ||||
|                             ></ak-source-ldap-connectivity> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="pf-c-card pf-l-grid__item pf-m-10-col"> | ||||
|                         <div class="pf-c-card__title"> | ||||
|                             <p>${msg("Sync status")}</p> | ||||
|                         </div> | ||||
|                         <div class="pf-c-card__body"> | ||||
|                             ${this.syncState.length < 1 | ||||
|                                 ? html`<p>${msg("Not synced yet.")}</p>` | ||||
|                                 : html` | ||||
|                                       <ul class="pf-c-list"> | ||||
|                                           ${this.syncState.map((task) => { | ||||
|                                               let header = ""; | ||||
|                                               if (task.status === TaskStatusEnum.Warning) { | ||||
|                                                   header = msg("Task finished with warnings"); | ||||
|                                               } else if (task.status === TaskStatusEnum.Error) { | ||||
|                                                   header = msg("Task finished with errors"); | ||||
|                                               } else { | ||||
|                                                   header = msg( | ||||
|                                                       str`Last sync: ${task.taskFinishTimestamp.toLocaleString()}`, | ||||
|                                                   ); | ||||
|                                               } | ||||
|                                               return html`<li> | ||||
|                                                   <p>${task.taskName}</p> | ||||
|                                                   <ul class="pf-c-list"> | ||||
|                                                       <li>${header}</li> | ||||
|                                                       ${task.messages.map((m) => { | ||||
|                                                           return html`<li>${m}</li>`; | ||||
|                                                       })} | ||||
|                                                   </ul> | ||||
|                                               </li> `; | ||||
|                                           })} | ||||
|                                       </ul> | ||||
|                                   `} | ||||
|                         </div> | ||||
|                         <div class="pf-c-card__body">${this.renderSyncStatus()}</div> | ||||
|                         <div class="pf-c-card__footer"> | ||||
|                             <ak-action-button | ||||
|                                 class="pf-m-secondary" | ||||
|                                 ?disabled=${this.syncState?.isRunning} | ||||
|                                 .apiRequest=${() => { | ||||
|                                     return new SourcesApi(DEFAULT_CONFIG) | ||||
|                                         .sourcesLdapPartialUpdate({ | ||||
| @ -186,6 +209,7 @@ export class LDAPSourceViewPage extends AKElement { | ||||
|                                                     composed: true, | ||||
|                                                 }), | ||||
|                                             ); | ||||
|                                             this.load(); | ||||
|                                         }); | ||||
|                                 }} | ||||
|                             > | ||||
|  | ||||
| @ -39,9 +39,8 @@ const container = (testItem: TemplateResult) => | ||||
| export const NumberInput = () => { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
|     const displayChange = (ev: any) => { | ||||
|         document.getElementById( | ||||
|             "number-message-pad", | ||||
|         )!.innerText = `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; | ||||
|         document.getElementById("number-message-pad")!.innerText = | ||||
|             `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; | ||||
|     }; | ||||
|  | ||||
|     return container( | ||||
|  | ||||
| @ -46,9 +46,8 @@ export const SwitchInput = () => { | ||||
|  | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
|     const displayChange = (ev: any) => { | ||||
|         document.getElementById( | ||||
|             "switch-message-pad", | ||||
|         )!.innerText = `Value selected: ${JSON.stringify(ev.target.checked, null, 2)}`; | ||||
|         document.getElementById("switch-message-pad")!.innerText = | ||||
|             `Value selected: ${JSON.stringify(ev.target.checked, null, 2)}`; | ||||
|     }; | ||||
|  | ||||
|     return container( | ||||
|  | ||||
| @ -39,9 +39,8 @@ const container = (testItem: TemplateResult) => | ||||
| export const TextareaInput = () => { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
|     const displayChange = (ev: any) => { | ||||
|         document.getElementById( | ||||
|             "textarea-message-pad", | ||||
|         )!.innerText = `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; | ||||
|         document.getElementById("textarea-message-pad")!.innerText = | ||||
|             `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; | ||||
|     }; | ||||
|  | ||||
|     return container( | ||||
|  | ||||
| @ -54,9 +54,8 @@ const testOptions = [ | ||||
| export const ToggleGroup = () => { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
|     const displayChange = (ev: any) => { | ||||
|         document.getElementById( | ||||
|             "toggle-message-pad", | ||||
|         )!.innerText = `Value selected: ${ev.detail.value}`; | ||||
|         document.getElementById("toggle-message-pad")!.innerText = | ||||
|             `Value selected: ${ev.detail.value}`; | ||||
|     }; | ||||
|  | ||||
|     return container( | ||||
|  | ||||
| @ -5,6 +5,7 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; | ||||
|  | ||||
| import { Task, TaskStatus } from "@lit-labs/task"; | ||||
| import { css, html } from "lit"; | ||||
| import { property } from "lit/decorators.js"; | ||||
|  | ||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||
| import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css"; | ||||
| @ -57,6 +58,9 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) { | ||||
|  | ||||
|     actionTask: Task; | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     disabled = false; | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         this.onSuccess = this.onSuccess.bind(this); | ||||
| @ -121,6 +125,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) { | ||||
|             part="spinner-button" | ||||
|             class="pf-c-button pf-m-progress ${this.buttonClasses}" | ||||
|             @click=${this.onClick} | ||||
|             ?disabled=${this.disabled} | ||||
|         > | ||||
|             ${this.actionTask.render({ pending: () => this.spinner })} | ||||
|             <slot></slot> | ||||
|  | ||||
| @ -13,3 +13,15 @@ or, for Kubernetes, run | ||||
| ``` | ||||
| kubectl exec -it deployment/authentik-worker -c authentik -- ak ldap_sync *slug of the source* | ||||
| ``` | ||||
|  | ||||
| Starting with authentik 2023.10, you can also run command below to explicitly check the connectivity to the configured LDAP Servers: | ||||
|  | ||||
| ``` | ||||
| docker-compose run --rm worker ldap_check_connection *slug of the source* | ||||
| ``` | ||||
|  | ||||
| or, for Kubernetes, run | ||||
|  | ||||
| ``` | ||||
| kubectl exec -it deployment/authentik-worker -c authentik -- ak ldap_check_connection *slug of the source* | ||||
| ``` | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L