diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 26e46d0791..daba404cd1 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -50,6 +50,7 @@ from authentik.lib.utils.reflection import get_apps from authentik.outposts.models import OutpostServiceConnection from authentik.policies.models import Policy, PolicyBindingModel from authentik.rbac.models import Role +from authentik.sources.ldap.models import LDAPSourceSync # Context set when the serializer is created in a blueprint context # Update website/docs/customize/blueprints/v1/models.md when used @@ -84,6 +85,7 @@ def excluded_models() -> list[type[Model]]: # Classes that have other dependencies Session, AuthenticatedSession, + LDAPSourceSync, ) diff --git a/authentik/lib/sync/api.py b/authentik/lib/sync/api.py index 88c4d54483..8c029a6204 100644 --- a/authentik/lib/sync/api.py +++ b/authentik/lib/sync/api.py @@ -1,4 +1,4 @@ -from rest_framework.fields import BooleanField, ChoiceField, DateTimeField, SerializerMethodField +from rest_framework.fields import BooleanField, ChoiceField, DateTimeField from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.lib.sync.models import Sync @@ -14,16 +14,12 @@ class SyncStatusSerializer(PassiveSerializer): class SyncSerializer(ModelSerializer): - done = SerializerMethodField() - - def get_done(self, obj: Sync) -> bool: - return obj.is_done() - class Meta: model = Sync fields = [ "pk", "tasks", "started_at", - "done", + "finished_at", + "status", ] diff --git a/authentik/lib/sync/models.py b/authentik/lib/sync/models.py index 197beb3bba..82cc49f84a 100644 --- a/authentik/lib/sync/models.py +++ b/authentik/lib/sync/models.py @@ -1,3 +1,4 @@ +from datetime import datetime from uuid import uuid4 from django.db import models @@ -6,22 +7,66 @@ from dramatiq.broker import get_broker from dramatiq.message import Message from authentik.core.models import ExpiringModel -from authentik.tasks.models import Task +from authentik.tasks.models import Task, TaskStatus + + +class SyncStatus(models.TextChoices): + RUNNING = TaskStatus.RUNNING + ERROR = TaskStatus.ERROR + WARNING = TaskStatus.WARNING + DONE = TaskStatus.DONE class Sync(ExpiringModel): - uuid = models.UUIDField(default=uuid4) + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) tasks = models.ManyToManyField(Task, related_name="+") started_at = models.DateTimeField(auto_now_add=True) - def is_done(self) -> bool: - return any( + @property + def done(self) -> bool: + return not any( state not in (TaskState.DONE, TaskState.REJECTED) for state in self.tasks.values_list("state", flat=True) ) + @property + def finished_at(self) -> datetime | None: + last_task = self.tasks.order_by("-mtime").first() + if last_task: + return last_task.mtime + return None + + @property + def status(self) -> SyncStatus: + states = self.tasks.values_list("aggregated_status", flat=True) + if any( + state + in ( + TaskStatus.WAITING_FOR_DEPENDENCIES, + TaskStatus.QUEUED, + TaskStatus.CONSUMED, + TaskStatus.PREPROCESS, + TaskStatus.RUNNING, + TaskStatus.POSTPROCESS, + ) + for state in states + ): + return SyncStatus.RUNNING + if any( + state + in ( + TaskStatus.REJECTED, + TaskStatus.ERROR, + ) + for state in states + ): + return SyncStatus.ERROR + if any(state == TaskStatus.WARNING for state in states): + return SyncStatus.WARNING + return SyncStatus.DONE + def enqueue(self, messages: list[Message], existing_tasks_as_dependencies: bool = True) -> None: broker = get_broker() if existing_tasks_as_dependencies: diff --git a/authentik/sources/ldap/api/sources.py b/authentik/sources/ldap/api/sources.py index 2b76a92ad4..43e39c79e4 100644 --- a/authentik/sources/ldap/api/sources.py +++ b/authentik/sources/ldap/api/sources.py @@ -16,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin from authentik.crypto.models import CertificateKeyPair -from authentik.lib.sync.api import SyncSerializer, SyncStatusSerializer +from authentik.lib.sync.api import SyncSerializer from authentik.rbac.filters import ObjectFilter from authentik.sources.ldap.models import LDAPSource, LDAPSourceSync from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES @@ -163,9 +163,15 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet): def syncs(self, request: Request, slug: str) -> Response: """Get provider's sync statuses""" source: LDAPSource = self.get_object() - syncs = LDAPSourceSync.objects.filter(source=source) - return Response(SyncStatusSerializer(syncs, many=True).data) + syncs = LDAPSourceSync.objects.filter(source=source).order_by("-started_at") + + page = self.paginate_queryset(syncs) + if page is not None: + serializer = LDAPSourceSyncSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + return Response(LDAPSourceSyncSerializer(syncs, many=True).data) @extend_schema( responses={ diff --git a/authentik/sources/ldap/migrations/0012_ldapsourcesync.py b/authentik/sources/ldap/migrations/0012_ldapsourcesync.py index 28cefb03d3..2e5cb345d2 100644 --- a/authentik/sources/ldap/migrations/0012_ldapsourcesync.py +++ b/authentik/sources/ldap/migrations/0012_ldapsourcesync.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.15 on 2026-06-16 12:03 +# Generated by Django 5.2.15 on 2026-06-16 14:08 import django.db.models.deletion import uuid @@ -16,15 +16,14 @@ class Migration(migrations.Migration): migrations.CreateModel( name="LDAPSourceSync", fields=[ - ( - "id", - models.AutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" - ), - ), ("expires", models.DateTimeField(default=None, null=True)), ("expiring", models.BooleanField(default=True)), - ("uuid", models.UUIDField(default=uuid.uuid4)), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), ("started_at", models.DateTimeField(auto_now_add=True)), ("users_count", models.PositiveBigIntegerField(default=0)), ("groups_count", models.PositiveBigIntegerField(default=0)), diff --git a/authentik/sources/ldap/tasks.py b/authentik/sources/ldap/tasks.py index 8126da2848..618b0611a6 100644 --- a/authentik/sources/ldap/tasks.py +++ b/authentik/sources/ldap/tasks.py @@ -80,6 +80,7 @@ def ldap_sync(source_pk: str): current_sync = LDAPSourceSync.objects.create( source=source, expires=now() + timedelta(days=60) ) + current_sync.tasks.add(task) # User and group sync can happen at once, they have no dependencies on each other current_sync.enqueue( diff --git a/authentik/tasks/api/tasks.py b/authentik/tasks/api/tasks.py index 1e13129985..a4fd68efe7 100644 --- a/authentik/tasks/api/tasks.py +++ b/authentik/tasks/api/tasks.py @@ -2,7 +2,7 @@ from typing import cast from django.db.models import Count from django_dramatiq_postgres.models import TaskState -from django_filters.filters import BooleanFilter, MultipleChoiceFilter +from django_filters.filters import BaseInFilter, BooleanFilter, MultipleChoiceFilter, UUIDFilter from django_filters.filterset import FilterSet from dramatiq.actor import Actor from dramatiq.broker import get_broker @@ -17,10 +17,7 @@ from drf_spectacular.utils import ( ) from rest_framework.decorators import action from rest_framework.fields import IntegerField, ReadOnlyField, SerializerMethodField -from rest_framework.mixins import ( - ListModelMixin, - RetrieveModelMixin, -) +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -94,16 +91,23 @@ class TaskSerializer(ModelSerializer): return actor.options["description"] +class UUIDInFilter(BaseInFilter, UUIDFilter): + pass + + class TaskFilter(FilterSet): rel_obj_id__isnull = BooleanFilter("rel_obj_id", "isnull") aggregated_status = MultipleChoiceFilter( choices=TaskStatus.choices, field_name="aggregated_status", ) + message_id__in = UUIDInFilter(field_name="message_id", lookup_expr="in") class Meta: model = Task fields = ( + "message_id", + "message_id__in", "queue_name", "actor_name", "state", diff --git a/packages/client-ts/src/apis/TasksApi.ts b/packages/client-ts/src/apis/TasksApi.ts index 9da1dd0d61..31c80f01d1 100644 --- a/packages/client-ts/src/apis/TasksApi.ts +++ b/packages/client-ts/src/apis/TasksApi.ts @@ -64,6 +64,8 @@ export interface TasksSchedulesUpdateRequest { export interface TasksTasksListRequest { actorName?: string; aggregatedStatus?: Array; + messageId?: string; + messageIdIn?: Array; ordering?: string; page?: number; pageSize?: number; @@ -452,6 +454,16 @@ export class TasksApi extends runtime.BaseAPI { queryParameters["aggregated_status"] = requestParameters["aggregatedStatus"]; } + if (requestParameters["messageId"] != null) { + queryParameters["message_id"] = requestParameters["messageId"]; + } + + if (requestParameters["messageIdIn"] != null) { + queryParameters["message_id__in"] = requestParameters["messageIdIn"]!.join( + runtime.COLLECTION_FORMATS["csv"], + ); + } + if (requestParameters["ordering"] != null) { queryParameters["ordering"] = requestParameters["ordering"]; } diff --git a/packages/client-ts/src/models/LDAPSourceSync.ts b/packages/client-ts/src/models/LDAPSourceSync.ts index ee9534021e..88810c50b4 100644 --- a/packages/client-ts/src/models/LDAPSourceSync.ts +++ b/packages/client-ts/src/models/LDAPSourceSync.ts @@ -12,6 +12,9 @@ * Do not edit the class manually. */ +import type { LDAPSourceSyncStatusEnum } from "./LDAPSourceSyncStatusEnum"; +import { LDAPSourceSyncStatusEnumFromJSON } from "./LDAPSourceSyncStatusEnum"; + /** * * @export @@ -20,10 +23,10 @@ export interface LDAPSourceSync { /** * - * @type {number} + * @type {string} * @memberof LDAPSourceSync */ - readonly pk: number; + readonly pk: string; /** * * @type {Array} @@ -38,10 +41,16 @@ export interface LDAPSourceSync { readonly startedAt: Date; /** * - * @type {boolean} + * @type {Date} * @memberof LDAPSourceSync */ - readonly done: boolean; + readonly finishedAt: Date | null; + /** + * + * @type {LDAPSourceSyncStatusEnum} + * @memberof LDAPSourceSync + */ + readonly status: LDAPSourceSyncStatusEnum; /** * * @type {string} @@ -87,7 +96,8 @@ export function instanceOfLDAPSourceSync(value: object): value is LDAPSourceSync if (!("pk" in value) || value["pk"] === undefined) return false; if (!("tasks" in value) || value["tasks"] === undefined) return false; if (!("startedAt" in value) || value["startedAt"] === undefined) return false; - if (!("done" in value) || value["done"] === undefined) return false; + if (!("finishedAt" in value) || value["finishedAt"] === undefined) return false; + if (!("status" in value) || value["status"] === undefined) return false; if (!("source" in value) || value["source"] === undefined) return false; return true; } @@ -107,7 +117,8 @@ export function LDAPSourceSyncFromJSONTyped( pk: json["pk"], tasks: json["tasks"], startedAt: new Date(json["started_at"]), - done: json["done"], + finishedAt: json["finished_at"] == null ? null : new Date(json["finished_at"]), + status: LDAPSourceSyncStatusEnumFromJSON(json["status"]), source: json["source"], usersCount: json["users_count"] == null ? undefined : json["users_count"], groupsCount: json["groups_count"] == null ? undefined : json["groups_count"], @@ -124,7 +135,7 @@ export function LDAPSourceSyncToJSON(json: any): LDAPSourceSync { } export function LDAPSourceSyncToJSONTyped( - value?: Omit | null, + value?: Omit | null, ignoreDiscriminator: boolean = false, ): any { if (value == null) { diff --git a/packages/client-ts/src/models/LDAPSourceSyncStatusEnum.ts b/packages/client-ts/src/models/LDAPSourceSyncStatusEnum.ts new file mode 100644 index 0000000000..e47f9ed6d8 --- /dev/null +++ b/packages/client-ts/src/models/LDAPSourceSyncStatusEnum.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.8.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + */ +export const LDAPSourceSyncStatusEnum = { + Running: "running", + Error: "error", + Warning: "warning", + Done: "done", + UnknownDefaultOpenApi: "11184809", +} as const; +export type LDAPSourceSyncStatusEnum = + (typeof LDAPSourceSyncStatusEnum)[keyof typeof LDAPSourceSyncStatusEnum]; + +export function instanceOfLDAPSourceSyncStatusEnum(value: any): boolean { + for (const key in LDAPSourceSyncStatusEnum) { + if (Object.prototype.hasOwnProperty.call(LDAPSourceSyncStatusEnum, key)) { + if (LDAPSourceSyncStatusEnum[key as keyof typeof LDAPSourceSyncStatusEnum] === value) { + return true; + } + } + } + return false; +} + +export function LDAPSourceSyncStatusEnumFromJSON(json: any): LDAPSourceSyncStatusEnum { + return LDAPSourceSyncStatusEnumFromJSONTyped(json, false); +} + +export function LDAPSourceSyncStatusEnumFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): LDAPSourceSyncStatusEnum { + return json as LDAPSourceSyncStatusEnum; +} + +export function LDAPSourceSyncStatusEnumToJSON(value?: LDAPSourceSyncStatusEnum | null): any { + return value as any; +} + +export function LDAPSourceSyncStatusEnumToJSONTyped( + value: any, + ignoreDiscriminator: boolean, +): LDAPSourceSyncStatusEnum { + return value as LDAPSourceSyncStatusEnum; +} diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index 119bcc27e7..fe30224813 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -280,6 +280,7 @@ export * from "./LDAPSourcePropertyMapping"; export * from "./LDAPSourcePropertyMappingRequest"; export * from "./LDAPSourceRequest"; export * from "./LDAPSourceSync"; +export * from "./LDAPSourceSyncStatusEnum"; export * from "./LangEnum"; export * from "./LastTaskStatusEnum"; export * from "./License"; diff --git a/schema.yml b/schema.yml index 3908542f16..365fc6e601 100644 --- a/schema.yml +++ b/schema.yml @@ -33358,6 +33358,21 @@ paths: $ref: '#/components/schemas/TaskAggregatedStatusEnum' explode: true style: form + - in: query + name: message_id + schema: + type: string + format: uuid + - in: query + name: message_id__in + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - $ref: '#/components/parameters/QueryPaginationOrdering' - $ref: '#/components/parameters/QueryPaginationPage' - $ref: '#/components/parameters/QueryPaginationPageSize' @@ -42547,9 +42562,10 @@ components: type: object properties: pk: - type: integer + type: string + format: uuid readOnly: true - title: ID + title: Uuid tasks: type: array items: @@ -42559,8 +42575,14 @@ components: type: string format: date-time readOnly: true - done: - type: boolean + finished_at: + type: string + format: date-time + nullable: true + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/LDAPSourceSyncStatusEnum' readOnly: true source: type: string @@ -42591,11 +42613,19 @@ components: minimum: 0 format: int64 required: - - done + - finished_at - pk - source - started_at + - status - tasks + LDAPSourceSyncStatusEnum: + enum: + - running + - error + - warning + - done + type: string LangEnum: type: string enum: diff --git a/web/src/admin/sources/ldap/LDAPSourceSyncList.ts b/web/src/admin/sources/ldap/LDAPSourceSyncList.ts new file mode 100644 index 0000000000..fbc2e3be37 --- /dev/null +++ b/web/src/admin/sources/ldap/LDAPSourceSyncList.ts @@ -0,0 +1,77 @@ +import "#elements/forms/DeleteBulkForm"; +import "#elements/tasks/TaskList"; +import "#elements/tasks/TaskStatus"; +import "#elements/forms/ModalForm"; +import "#admin/sources/ldap/LDAPSourceUserForm"; + +import { aki } from "#common/api/client"; + +import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table"; +import { SlottedTemplateResult } from "#elements/types"; + +import { LDAPSource, LDAPSourceSync, SourcesApi } from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { html, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("ak-source-ldap-sync-list") +export class LDAPSourceSyncList extends Table { + @property({ attribute: false }) + source: LDAPSource; + + expandable = true; + clearOnRefresh = true; + + @property() + order = "-started_at"; + + async apiEndpoint(): Promise> { + return aki(SourcesApi).sourcesLdapSyncsList({ + ...(await this.defaultEndpointConfig()), + slug: this.source?.slug, + }); + } + + protected override rowLabel(item: LDAPSourceSync): string { + return item.pk; + } + + get columns(): TableColumn[] { + return [ + [msg("Status"), "status"], + [msg("Started"), "started_at"], + [msg("Finished"), "finished_at"], + [msg("User count"), "users_count"], + [msg("Group count"), "groups_count"], + [msg("Membership count"), "membership_count"], + [msg("User deleted count"), "user_deletions_count"], + [msg("Group deleted count"), "group_deletions_count"], + ]; + } + + row(item: LDAPSourceSync): SlottedTemplateResult[] { + return [ + html``, + html`${Timestamp(item.startedAt)}`, + html`${Timestamp(item.finishedAt)}`, + html`${item.usersCount}`, + html`${item.groupsCount}`, + html`${item.membershipCount}`, + html`${item.userDeletionsCount}`, + html`${item.groupDeletionsCount}`, + ]; + } + + renderExpanded(item: LDAPSourceSync): TemplateResult { + return html`
+ +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-source-ldap-sync-list": LDAPSourceSyncList; + } +} diff --git a/web/src/admin/sources/ldap/LDAPSourceViewPage.ts b/web/src/admin/sources/ldap/LDAPSourceViewPage.ts index 65ac0bc4e5..7048b350f9 100644 --- a/web/src/admin/sources/ldap/LDAPSourceViewPage.ts +++ b/web/src/admin/sources/ldap/LDAPSourceViewPage.ts @@ -1,9 +1,9 @@ import "#admin/rbac/ak-rbac-object-permission-page"; import "#admin/sources/ldap/LDAPSourceConnectivity"; import "#admin/sources/ldap/LDAPSourceUserList"; +import "#admin/sources/ldap/LDAPSourceSyncList"; import "#admin/sources/ldap/LDAPSourceGroupList"; import "#admin/events/ObjectChangelog"; -import "#components/sync/SyncStatusCard"; import "#elements/CodeMirror"; import "#elements/Tabs"; import "#elements/buttons/ActionButton/index"; @@ -120,17 +120,9 @@ export class LDAPSourceViewPage extends AKElement { )} -
- { - if (!this.source) return Promise.reject(); - return aki(SourcesApi).sourcesLdapSyncStatusRetrieve({ - slug: this.source.slug, - }); - }} - > -
-
+

${msg("Connectivity")}

@@ -150,6 +142,14 @@ export class LDAPSourceViewPage extends AKElement { .relObjId="${this.source?.pk}" >
+
+
+

${msg("Previous synchronisations")}

+
+ +
{ expandable = true; clearOnRefresh = true; + @property() + taskIds?: string[]; @property() relObjAppLabel?: string; @property() @@ -68,11 +70,13 @@ export class TaskList extends Table { async apiEndpoint(): Promise> { const relObjIdIsnull = - typeof this.relObjId !== "undefined" + typeof this.taskIds !== "undefined" ? undefined - : this.showOnlyStandalone - ? true - : undefined; + : typeof this.relObjId !== "undefined" + ? undefined + : this.showOnlyStandalone + ? true + : undefined; const aggregatedStatus = this.excludeSuccessful ? [ TaskAggregatedStatusEnum.WaitingForDependencies, @@ -91,6 +95,7 @@ export class TaskList extends Table { } return aki(TasksApi).tasksTasksList({ ...(await this.defaultEndpointConfig()), + messageIdIn: this.taskIds, relObjContentTypeAppLabel: this.relObjAppLabel, relObjContentTypeModel: this.relObjModel, relObjId: this.relObjId ? this.relObjId.toString() : undefined, @@ -135,7 +140,7 @@ export class TaskList extends Table { return html`
- ${this.relObjId === undefined + ${this.relObjId === undefined && this.taskIds === undefined ? html`