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:
parent
4080080acd
commit
f728bbb14b
|
@ -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 a new issue