re-introduce sync status api

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Marc 'risson' Schmitt
2025-06-26 15:40:34 +02:00
parent f264989f9e
commit 64e7bff16c
10 changed files with 478 additions and 117 deletions

View File

@ -7,6 +7,7 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.enterprise.providers.google_workspace.tasks import (
google_workspace_sync,
google_workspace_sync_objects,
)
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
@ -54,4 +55,5 @@ class GoogleWorkspaceProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixi
]
search_fields = ["name"]
ordering = ["name"]
sync_task = google_workspace_sync
sync_objects_task = google_workspace_sync_objects

View File

@ -7,6 +7,7 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.enterprise.providers.microsoft_entra.tasks import (
microsoft_entra_sync,
microsoft_entra_sync_objects,
)
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
@ -52,4 +53,5 @@ class MicrosoftEntraProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin
]
search_fields = ["name"]
ordering = ["name"]
sync_task = microsoft_entra_sync
sync_objects_task = microsoft_entra_sync_objects

17
authentik/lib/sync/api.py Normal file
View File

@ -0,0 +1,17 @@
from rest_framework.fields import BooleanField, ChoiceField, DateTimeField
from authentik.core.api.utils import PassiveSerializer
from authentik.tasks.models import TaskStatus
class SyncStatusSerializer(PassiveSerializer):
"""Provider/source sync status"""
is_running = BooleanField(read_only=True, default=False)
last_successful_sync = DateTimeField(read_only=True, required=False, default=None)
last_sync_status = ChoiceField(
read_only=True,
required=False,
default=None,
choices=TaskStatus.choices,
)

View File

@ -1,5 +1,5 @@
from dramatiq.actor import Actor
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.fields import BooleanField, CharField, ChoiceField
from rest_framework.request import Request
@ -8,10 +8,11 @@ from rest_framework.response import Response
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import Group, User
from authentik.events.logs import LogEventSerializer
from authentik.lib.sync.api import SyncStatusSerializer
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.utils.reflection import class_to_path
from authentik.rbac.filters import ObjectFilter
from authentik.tasks.models import Task
from authentik.tasks.models import Task, TaskStatus
class SyncObjectSerializer(PassiveSerializer):
@ -36,8 +37,55 @@ class SyncObjectResultSerializer(PassiveSerializer):
class OutgoingSyncProviderStatusMixin:
"""Common API Endpoints for Outgoing sync providers"""
sync_task: Actor
sync_objects_task: Actor
@extend_schema(responses={200: SyncStatusSerializer()})
@action(
methods=["GET"],
detail=True,
pagination_class=None,
url_path="sync/status",
filter_backends=[ObjectFilter],
)
def sync_status(self, request: Request, pk: int) -> Response:
"""Get provider's sync status"""
provider: OutgoingSyncProvider = self.get_object()
sync_schedule = None
for schedule in provider.schedules.all():
if schedule.actor_name == self.sync_task.actor_name:
sync_schedule = schedule
if not sync_schedule:
return Response(SyncStatusSerializer({}).data)
status = {}
last_task: Task = (
sync_schedule.tasks.exclude(
aggregated_status__in=(TaskStatus.CONSUMED, TaskStatus.QUEUED)
)
.order_by("-mtime")
.first()
)
last_successful_task: Task = (
sync_schedule.tasks.filter(aggregated_status__in=(TaskStatus.DONE, TaskStatus.INFO))
.order_by("-mtime")
.first()
)
if last_task:
status["last_sync_status"] = last_task.aggregated_status
if last_successful_task:
status["last_successful_sync"] = last_successful_task.mtime
with provider.sync_lock as lock_acquired:
# If we could not acquire the lock, it means a task is using it, and thus is running
status["is_running"] = not lock_acquired
return Response(SyncStatusSerializer(status).data)
@extend_schema(
request=SyncObjectSerializer,
responses={200: SyncObjectResultSerializer()},

View File

@ -6,7 +6,7 @@ from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
from authentik.providers.scim.models import SCIMProvider
from authentik.providers.scim.tasks import scim_sync_objects
from authentik.providers.scim.tasks import scim_sync, scim_sync_objects
class SCIMProviderSerializer(ProviderSerializer):
@ -44,4 +44,5 @@ class SCIMProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelVie
filterset_fields = ["name", "exclude_users_service_account", "url", "filter_group"]
search_fields = ["name", "url"]
ordering = ["name", "url"]
sync_task = scim_sync
sync_objects_task = scim_sync_objects

View File

@ -170,6 +170,7 @@ SPECTACULAR_SETTINGS = {
"UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification",
"UserTypeEnum": "authentik.core.models.UserTypes",
"OutgoingSyncDeleteAction": "authentik.lib.sync.outgoing.models.OutgoingSyncDeleteAction",
"TaskAggregatedStatusEnum": "authentik.tasks.models.TaskStatus",
},
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
"ENUM_GENERATE_CHOICE_DESCRIPTION": False,

View File

@ -1,13 +1,20 @@
"""Source API Views"""
from django.core.cache import cache
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.lib.sync.api import SyncStatusSerializer
from authentik.rbac.filters import ObjectFilter
from authentik.sources.kerberos.models import KerberosSource
from authentik.sources.kerberos.tasks import CACHE_KEY_STATUS
from authentik.sources.kerberos.tasks import CACHE_KEY_STATUS, kerberos_sync
from authentik.tasks.models import Task, TaskStatus
class KerberosSourceSerializer(SourceSerializer):
@ -73,3 +80,49 @@ class KerberosSourceViewSet(UsedByMixin, ModelViewSet):
"spnego_server_name",
]
ordering = ["name"]
@extend_schema(responses={200: SyncStatusSerializer()})
@action(
methods=["GET"],
detail=True,
pagination_class=None,
url_path="sync/status",
filter_backends=[ObjectFilter],
)
def sync_status(self, request: Request, pk: int) -> Response:
"""Get provider's sync status"""
source: KerberosSource = self.get_object()
sync_schedule = None
for schedule in source.schedules.all():
if schedule.actor_name == kerberos_sync.actor_name:
sync_schedule = schedule
if not sync_schedule:
return Response(SyncStatusSerializer({}).data)
status = {}
last_task: Task = (
sync_schedule.tasks.exclude(
aggregated_status__in=(TaskStatus.CONSUMED, TaskStatus.QUEUED)
)
.order_by("-mtime")
.first()
)
last_successful_task: Task = (
sync_schedule.tasks.filter(aggregated_status__in=(TaskStatus.DONE, TaskStatus.INFO))
.order_by("-mtime")
.first()
)
if last_task:
status["last_sync_status"] = last_task.aggregated_status
if last_successful_task:
status["last_successful_sync"] = last_successful_task.mtime
with source.sync_lock as lock_acquired:
# If we could not acquire the lock, it means a task is using it, and thus is running
status["is_running"] = not lock_acquired
return Response(SyncStatusSerializer(status).data)

View File

@ -4,7 +4,7 @@ from typing import Any
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema, inline_serializer
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import DictField, ListField, SerializerMethodField
@ -23,13 +23,16 @@ from authentik.core.api.sources import (
)
from authentik.core.api.used_by import UsedByMixin
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.sync.api import SyncStatusSerializer
from authentik.rbac.filters import ObjectFilter
from authentik.sources.ldap.models import (
GroupLDAPSourceConnection,
LDAPSource,
LDAPSourcePropertyMapping,
UserLDAPSourceConnection,
)
from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES
from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES, ldap_sync
from authentik.tasks.models import Task, TaskStatus
class LDAPSourceSerializer(SourceSerializer):
@ -153,6 +156,52 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
search_fields = ["name", "slug"]
ordering = ["name"]
@extend_schema(responses={200: SyncStatusSerializer()})
@action(
methods=["GET"],
detail=True,
pagination_class=None,
url_path="sync/status",
filter_backends=[ObjectFilter],
)
def sync_status(self, request: Request, pk: int) -> Response:
"""Get provider's sync status"""
source: LDAPSource = self.get_object()
sync_schedule = None
for schedule in source.schedules.all():
if schedule.actor_name == ldap_sync.actor_name:
sync_schedule = schedule
if not sync_schedule:
return Response(SyncStatusSerializer({}).data)
status = {}
last_task: Task = (
sync_schedule.tasks.exclude(
aggregated_status__in=(TaskStatus.CONSUMED, TaskStatus.QUEUED)
)
.order_by("-mtime")
.first()
)
last_successful_task: Task = (
sync_schedule.tasks.filter(aggregated_status__in=(TaskStatus.DONE, TaskStatus.INFO))
.order_by("-mtime")
.first()
)
if last_task:
status["last_sync_status"] = last_task.aggregated_status
if last_successful_task:
status["last_successful_sync"] = last_successful_task.mtime
with source.sync_lock as lock_acquired:
# If we could not acquire the lock, it means a task is using it, and thus is running
status["is_running"] = not lock_acquired
return Response(SyncStatusSerializer(status).data)
@extend_schema(
responses={
200: inline_serializer(

View File

@ -19451,6 +19451,40 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/providers/google_workspace/{id}/sync/status/:
get:
operationId: providers_google_workspace_sync_status_retrieve
description: Get provider's sync status
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this Google Workspace Provider.
required: true
tags:
- providers
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SyncStatus'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/providers/google_workspace/{id}/used_by/:
get:
operationId: providers_google_workspace_used_by_list
@ -20458,6 +20492,40 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/providers/microsoft_entra/{id}/sync/status/:
get:
operationId: providers_microsoft_entra_sync_status_retrieve
description: Get provider's sync status
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this Microsoft Entra Provider.
required: true
tags:
- providers
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SyncStatus'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/providers/microsoft_entra/{id}/used_by/:
get:
operationId: providers_microsoft_entra_used_by_list
@ -22957,6 +23025,40 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/providers/scim/{id}/sync/status/:
get:
operationId: providers_scim_sync_status_retrieve
description: Get provider's sync status
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this SCIM Provider.
required: true
tags:
- providers
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SyncStatus'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/providers/scim/{id}/used_by/:
get:
operationId: providers_scim_used_by_list
@ -28045,6 +28147,40 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/kerberos/{slug}/sync/status/:
get:
operationId: sources_kerberos_sync_status_retrieve
description: Get provider's sync status
parameters:
- in: path
name: slug
schema:
type: string
description: Internal source name, used in URLs.
required: true
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SyncStatus'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/kerberos/{slug}/used_by/:
get:
operationId: sources_kerberos_used_by_list
@ -28461,6 +28597,40 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/ldap/{slug}/sync/status/:
get:
operationId: sources_ldap_sync_status_retrieve
description: Get provider's sync status
parameters:
- in: path
name: slug
schema:
type: string
description: Internal source name, used in URLs.
required: true
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/SyncStatus'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/ldap/{slug}/used_by/:
get:
operationId: sources_ldap_used_by_list
@ -41211,16 +41381,6 @@ components:
required:
- pending_user
- pending_user_avatar
AggregatedStatusEnum:
enum:
- queued
- consumed
- rejected
- done
- info
- warning
- error
type: string
AlgEnum:
enum:
- rsa
@ -60224,6 +60384,26 @@ components:
readOnly: true
required:
- messages
SyncStatus:
type: object
description: Provider/source sync status
properties:
is_running:
type: boolean
readOnly: true
default: false
last_successful_sync:
type: string
format: date-time
readOnly: true
last_sync_status:
allOf:
- $ref: '#/components/schemas/TaskAggregatedStatusEnum'
readOnly: true
required:
- is_running
- last_successful_sync
- last_sync_status
SystemInfo:
type: object
description: Get system information.
@ -60372,7 +60552,7 @@ components:
items:
$ref: '#/components/schemas/LogEvent'
aggregated_status:
$ref: '#/components/schemas/AggregatedStatusEnum'
$ref: '#/components/schemas/TaskAggregatedStatusEnum'
required:
- actor_name
- aggregated_status
@ -60381,6 +60561,16 @@ components:
- rel_obj_app_label
- rel_obj_model
- uid
TaskAggregatedStatusEnum:
enum:
- queued
- consumed
- rejected
- done
- info
- warning
- error
type: string
Tenant:
type: object
description: Tenant Serializer

View File

@ -152,110 +152,108 @@ export class SCIMProviderViewPage extends AKElement {
</div>`
: html``}
<div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-l-grid__item pf-m-7-col pf-l-stack pf-m-gutter">
<div class="pf-c-card pf-m-12-col pf-l-stack__item">
<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__body">
<dl class="pf-c-description-list">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Name")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.provider.name}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Assigned to application")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-provider-related-application
mode="backchannel"
.provider=${this.provider}
></ak-provider-related-application>
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Dry-run")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-status-label
?good=${!this.provider.dryRun}
type="info"
good-label=${msg("No")}
bad-label=${msg("Yes")}
></ak-status-label>
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("URL")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.provider.url}
</div>
</dd>
</div>
</dl>
</div>
<div class="pf-c-card__footer">
<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update SCIM Provider")} </span>
<ak-provider-scim-form slot="form" .instancePk=${this.provider.pk}>
</ak-provider-scim-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Edit")}
</button>
</ak-forms-modal>
</div>
</div>
<div class="pf-l-grid__item pf-m-12-col pf-l-stack__item">
<div class="pf-c-card">
<div class="pf-c-card__header">
<div class="pf-c-card__title">${msg("Schedules")}</div>
</div>
<div class="pf-c-card__body">
<dl class="pf-c-description-list pf-m-4-col-on-lg">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Name")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.provider.name}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Assigned to application")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-provider-related-application
mode="backchannel"
.provider=${this.provider}
></ak-provider-related-application>
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Dry-run")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-status-label
?good=${!this.provider.dryRun}
type="info"
good-label=${msg("No")}
bad-label=${msg("Yes")}
></ak-status-label>
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("URL")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.provider.url}
</div>
</dd>
</div>
</dl>
</div>
<div class="pf-c-card__footer">
<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update SCIM Provider")} </span>
<ak-provider-scim-form slot="form" .instancePk=${this.provider.pk}>
</ak-provider-scim-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Edit")}
</button>
</ak-forms-modal>
</div>
</div>
<div class="pf-l-grid__item pf-m-12-col pf-l-stack__item">
<div class="pf-c-card">
<div class="pf-c-card__header">
<div class="pf-c-card__title">${msg("Schedules")}</div>
</div>
<div class="pf-c-card__body">
<ak-schedule-list
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${this.provider.pk}"
></ak-schedule-list>
</div>
</div>
</div>
<div class="pf-l-grid__item pf-m-12-col pf-l-stack__item">
<div class="pf-c-card">
<div class="pf-c-card__header">
<div class="pf-c-card__title">${msg("Tasks")}</div>
</div>
<div class="pf-c-card__body">
<ak-task-list
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${this.provider.pk}"
></ak-task-list>
</div>
<ak-schedule-list
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${this.provider.pk}"
></ak-schedule-list>
</div>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-5-col">
<div class="pf-l-grid__item pf-m-12-col pf-l-stack__item">
<div class="pf-c-card">
<div class="pf-c-card__header">
<div class="pf-c-card__title">${msg("Tasks")}</div>
</div>
<div class="pf-c-card__body">
<ak-task-list
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${this.provider.pk}"
></ak-task-list>
</div>
</div>
</div>
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-12-col-on-2xl"
>
<div class="pf-c-card__body">
<ak-mdx .url=${MDSCIMProvider}></ak-mdx>
</div>