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.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.enterprise.providers.google_workspace.tasks import ( from authentik.enterprise.providers.google_workspace.tasks import (
google_workspace_sync,
google_workspace_sync_objects, google_workspace_sync_objects,
) )
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
@ -54,4 +55,5 @@ class GoogleWorkspaceProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixi
] ]
search_fields = ["name"] search_fields = ["name"]
ordering = ["name"] ordering = ["name"]
sync_task = google_workspace_sync
sync_objects_task = google_workspace_sync_objects 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.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.enterprise.providers.microsoft_entra.tasks import ( from authentik.enterprise.providers.microsoft_entra.tasks import (
microsoft_entra_sync,
microsoft_entra_sync_objects, microsoft_entra_sync_objects,
) )
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
@ -52,4 +53,5 @@ class MicrosoftEntraProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin
] ]
search_fields = ["name"] search_fields = ["name"]
ordering = ["name"] ordering = ["name"]
sync_task = microsoft_entra_sync
sync_objects_task = microsoft_entra_sync_objects 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 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.decorators import action
from rest_framework.fields import BooleanField, CharField, ChoiceField from rest_framework.fields import BooleanField, CharField, ChoiceField
from rest_framework.request import Request 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.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.events.logs import LogEventSerializer from authentik.events.logs import LogEventSerializer
from authentik.lib.sync.api import SyncStatusSerializer
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.rbac.filters import ObjectFilter from authentik.rbac.filters import ObjectFilter
from authentik.tasks.models import Task from authentik.tasks.models import Task, TaskStatus
class SyncObjectSerializer(PassiveSerializer): class SyncObjectSerializer(PassiveSerializer):
@ -36,8 +37,55 @@ class SyncObjectResultSerializer(PassiveSerializer):
class OutgoingSyncProviderStatusMixin: class OutgoingSyncProviderStatusMixin:
"""Common API Endpoints for Outgoing sync providers""" """Common API Endpoints for Outgoing sync providers"""
sync_task: Actor
sync_objects_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( @extend_schema(
request=SyncObjectSerializer, request=SyncObjectSerializer,
responses={200: SyncObjectResultSerializer()}, 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.core.api.used_by import UsedByMixin
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
from authentik.providers.scim.models import SCIMProvider 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): class SCIMProviderSerializer(ProviderSerializer):
@ -44,4 +44,5 @@ class SCIMProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelVie
filterset_fields = ["name", "exclude_users_service_account", "url", "filter_group"] filterset_fields = ["name", "exclude_users_service_account", "url", "filter_group"]
search_fields = ["name", "url"] search_fields = ["name", "url"]
ordering = ["name", "url"] ordering = ["name", "url"]
sync_task = scim_sync
sync_objects_task = scim_sync_objects sync_objects_task = scim_sync_objects

View File

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

View File

@ -1,13 +1,20 @@
"""Source API Views""" """Source API Views"""
from django.core.cache import cache 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.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin 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.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): class KerberosSourceSerializer(SourceSerializer):
@ -73,3 +80,49 @@ class KerberosSourceViewSet(UsedByMixin, ModelViewSet):
"spnego_server_name", "spnego_server_name",
] ]
ordering = ["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.core.cache import cache
from django.utils.translation import gettext_lazy as _ 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.decorators import action
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.fields import DictField, ListField, SerializerMethodField 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.core.api.used_by import UsedByMixin
from authentik.crypto.models import CertificateKeyPair 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 ( from authentik.sources.ldap.models import (
GroupLDAPSourceConnection, GroupLDAPSourceConnection,
LDAPSource, LDAPSource,
LDAPSourcePropertyMapping, LDAPSourcePropertyMapping,
UserLDAPSourceConnection, 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): class LDAPSourceSerializer(SourceSerializer):
@ -153,6 +156,52 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
search_fields = ["name", "slug"] search_fields = ["name", "slug"]
ordering = ["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: 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( @extend_schema(
responses={ responses={
200: inline_serializer( 200: inline_serializer(

View File

@ -19451,6 +19451,40 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' 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/: /providers/google_workspace/{id}/used_by/:
get: get:
operationId: providers_google_workspace_used_by_list operationId: providers_google_workspace_used_by_list
@ -20458,6 +20492,40 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' 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/: /providers/microsoft_entra/{id}/used_by/:
get: get:
operationId: providers_microsoft_entra_used_by_list operationId: providers_microsoft_entra_used_by_list
@ -22957,6 +23025,40 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' 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/: /providers/scim/{id}/used_by/:
get: get:
operationId: providers_scim_used_by_list operationId: providers_scim_used_by_list
@ -28045,6 +28147,40 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' 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/: /sources/kerberos/{slug}/used_by/:
get: get:
operationId: sources_kerberos_used_by_list operationId: sources_kerberos_used_by_list
@ -28461,6 +28597,40 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' 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/: /sources/ldap/{slug}/used_by/:
get: get:
operationId: sources_ldap_used_by_list operationId: sources_ldap_used_by_list
@ -41211,16 +41381,6 @@ components:
required: required:
- pending_user - pending_user
- pending_user_avatar - pending_user_avatar
AggregatedStatusEnum:
enum:
- queued
- consumed
- rejected
- done
- info
- warning
- error
type: string
AlgEnum: AlgEnum:
enum: enum:
- rsa - rsa
@ -60224,6 +60384,26 @@ components:
readOnly: true readOnly: true
required: required:
- messages - 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: SystemInfo:
type: object type: object
description: Get system information. description: Get system information.
@ -60372,7 +60552,7 @@ components:
items: items:
$ref: '#/components/schemas/LogEvent' $ref: '#/components/schemas/LogEvent'
aggregated_status: aggregated_status:
$ref: '#/components/schemas/AggregatedStatusEnum' $ref: '#/components/schemas/TaskAggregatedStatusEnum'
required: required:
- actor_name - actor_name
- aggregated_status - aggregated_status
@ -60381,6 +60561,16 @@ components:
- rel_obj_app_label - rel_obj_app_label
- rel_obj_model - rel_obj_model
- uid - uid
TaskAggregatedStatusEnum:
enum:
- queued
- consumed
- rejected
- done
- info
- warning
- error
type: string
Tenant: Tenant:
type: object type: object
description: Tenant Serializer description: Tenant Serializer

View File

@ -152,15 +152,14 @@ export class SCIMProviderViewPage extends AKElement {
</div>` </div>`
: html``} : html``}
<div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter"> <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
<div class="pf-c-card pf-m-12-col pf-l-stack__item"> 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"> <div class="pf-c-card__body">
<dl class="pf-c-description-list pf-m-4-col-on-lg"> <dl class="pf-c-description-list">
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text" <span class="pf-c-description-list__text">${msg("Name")}</span>
>${msg("Name")}</span
>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
@ -202,9 +201,7 @@ export class SCIMProviderViewPage extends AKElement {
</div> </div>
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text" <span class="pf-c-description-list__text">${msg("URL")}</span>
>${msg("URL")}</span
>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
@ -254,8 +251,9 @@ export class SCIMProviderViewPage extends AKElement {
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div class="pf-c-card pf-l-grid__item pf-m-5-col"> 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"> <div class="pf-c-card__body">
<ak-mdx .url=${MDSCIMProvider}></ak-mdx> <ak-mdx .url=${MDSCIMProvider}></ak-mdx>
</div> </div>