Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Marc 'risson' Schmitt
2026-06-16 17:37:11 +02:00
parent 3499007e58
commit da2621b0c7
16 changed files with 315 additions and 58 deletions
+2
View File
@@ -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,
)
+3 -7
View File
@@ -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",
]
+49 -4
View File
@@ -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:
+9 -3
View File
@@ -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={
@@ -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)),
+1
View File
@@ -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(
+9 -5
View File
@@ -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",
+12
View File
@@ -64,6 +64,8 @@ export interface TasksSchedulesUpdateRequest {
export interface TasksTasksListRequest {
actorName?: string;
aggregatedStatus?: Array<TaskAggregatedStatusEnum>;
messageId?: string;
messageIdIn?: Array<string>;
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"];
}
+18 -7
View File
@@ -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<string>}
@@ -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<LDAPSourceSync, "pk" | "started_at" | "done"> | null,
value?: Omit<LDAPSourceSync, "pk" | "started_at" | "finished_at" | "status"> | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
@@ -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;
}
+1
View File
@@ -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";
+35 -5
View File
@@ -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:
@@ -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<LDAPSourceSync> {
@property({ attribute: false })
source: LDAPSource;
expandable = true;
clearOnRefresh = true;
@property()
order = "-started_at";
async apiEndpoint(): Promise<PaginatedResponse<LDAPSourceSync>> {
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`<ak-task-status .status=${item.status}></ak-task-status>`,
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`<div class="pf-c-content">
<ak-task-list .taskIds=${item.tasks}></ak-task-list>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-source-ldap-sync-list": LDAPSourceSyncList;
}
}
@@ -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 {
)}
</div>
</div>
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-6-col-on-2xl">
<ak-sync-status-card
.fetch=${() => {
if (!this.source) return Promise.reject();
return aki(SourcesApi).sourcesLdapSyncStatusRetrieve({
slug: this.source.slug,
});
}}
></ak-sync-status-card>
</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-12-col pf-m-6-col-on-xl pf-m-6-col-on-2xl"
>
<div class="pf-c-card__title">
<p>${msg("Connectivity")}</p>
</div>
@@ -150,6 +142,14 @@ export class LDAPSourceViewPage extends AKElement {
.relObjId="${this.source?.pk}"
></ak-schedule-list>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">
<p>${msg("Previous synchronisations")}</p>
</div>
<ak-source-ldap-sync-list
.source=${this.source}
></ak-source-ldap-sync-list>
</div>
</div>
</div>
<section
+10 -5
View File
@@ -42,6 +42,8 @@ export class TaskList extends Table<Task> {
expandable = true;
clearOnRefresh = true;
@property()
taskIds?: string[];
@property()
relObjAppLabel?: string;
@property()
@@ -68,11 +70,13 @@ export class TaskList extends Table<Task> {
async apiEndpoint(): Promise<PaginatedResponse<Task>> {
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<Task> {
}
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<Task> {
return html`<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
${this.relObjId === undefined
${this.relObjId === undefined && this.taskIds === undefined
? html` <label class="pf-c-switch">
<input
class="pf-c-switch__input"
+10 -2
View File
@@ -3,7 +3,11 @@ import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { AKElement } from "#elements/Base";
import { PFColor } from "#elements/Label";
import { LastTaskStatusEnum, TaskAggregatedStatusEnum } from "@goauthentik/api";
import {
LastTaskStatusEnum,
LDAPSourceSyncStatusEnum,
TaskAggregatedStatusEnum,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
@@ -16,7 +20,7 @@ export class TaskStatus extends AKElement {
public static styles: CSSResult[] = [PFButton];
@property()
status?: TaskAggregatedStatusEnum | TaskAggregatedStatusEnum | LastTaskStatusEnum;
status?: TaskAggregatedStatusEnum | LastTaskStatusEnum | LDAPSourceSyncStatusEnum;
render(): TemplateResult {
switch (this.status) {
@@ -36,6 +40,7 @@ export class TaskStatus extends AKElement {
return html`<ak-label color=${PFColor.Blue}>${msg("Pre-processing")}</ak-label>`;
case TaskAggregatedStatusEnum.Running:
case LastTaskStatusEnum.Running:
case LDAPSourceSyncStatusEnum.Running:
return html`<ak-label color=${PFColor.Blue}>${msg("Running")}</ak-label>`;
case TaskAggregatedStatusEnum.Postprocess:
case LastTaskStatusEnum.Postprocess:
@@ -44,14 +49,17 @@ export class TaskStatus extends AKElement {
case LastTaskStatusEnum.Done:
case TaskAggregatedStatusEnum.Info:
case LastTaskStatusEnum.Info:
case LDAPSourceSyncStatusEnum.Done:
return html`<ak-label color=${PFColor.Green}>${msg("Successful")}</ak-label>`;
case TaskAggregatedStatusEnum.Warning:
case LastTaskStatusEnum.Warning:
case LDAPSourceSyncStatusEnum.Warning:
return html`<ak-label color=${PFColor.Orange}>${msg("Warning")}</ak-label>`;
case TaskAggregatedStatusEnum.Rejected:
case LastTaskStatusEnum.Rejected:
case TaskAggregatedStatusEnum.Error:
case LastTaskStatusEnum.Error:
case LDAPSourceSyncStatusEnum.Error:
return html`<ak-label color=${PFColor.Red}>${msg("Error")}</ak-label>`;
default:
return html`<ak-label color=${PFColor.Gray}>${msg("Unknown")}</ak-label>`;