From 96b4d5aee4785163b5f396641a9d2ad5acb3be18 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Tue, 24 Jun 2025 17:34:15 +0200 Subject: [PATCH] outposts Signed-off-by: Marc 'risson' Schmitt --- authentik/blueprints/v1/tasks.py | 4 +- authentik/outposts/controllers/kubernetes.py | 16 ++++- authentik/outposts/signals.py | 57 +++++----------- authentik/outposts/tasks.py | 38 ++++++----- authentik/tasks/models.py | 16 +++-- web/src/admin/outposts/OutpostListPage.ts | 36 ++++++++++ .../outposts/ServiceConnectionListPage.ts | 67 +++++++++---------- 7 files changed, 127 insertions(+), 107 deletions(-) diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index 383834a0f0..7af00d8450 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -212,14 +212,14 @@ def apply_blueprint(instance_pk: UUID): if not valid: instance.status = BlueprintInstanceStatus.ERROR instance.save() - self.error(*logs) + self.logs(logs) return with capture_logs() as logs: applied = importer.apply() if not applied: instance.status = BlueprintInstanceStatus.ERROR instance.save() - self.error(*logs) + self.logs(logs) return instance.status = BlueprintInstanceStatus.SUCCESSFUL instance.last_applied_hash = file_hash diff --git a/authentik/outposts/controllers/kubernetes.py b/authentik/outposts/controllers/kubernetes.py index 139fa4d5c0..e0d8f49db0 100644 --- a/authentik/outposts/controllers/kubernetes.py +++ b/authentik/outposts/controllers/kubernetes.py @@ -101,7 +101,13 @@ class KubernetesController(BaseController): all_logs = [] for reconcile_key in self.reconcile_order: if reconcile_key in self.outpost.config.kubernetes_disabled_components: - all_logs += [f"{reconcile_key.title()}: Disabled"] + all_logs.append( + LogEvent( + log_level="info", + event=f"{reconcile_key.title()}: Disabled", + logger=str(type(self)), + ) + ) continue with capture_logs() as logs: reconciler_cls = self.reconcilers.get(reconcile_key) @@ -134,7 +140,13 @@ class KubernetesController(BaseController): all_logs = [] for reconcile_key in self.reconcile_order: if reconcile_key in self.outpost.config.kubernetes_disabled_components: - all_logs += [f"{reconcile_key.title()}: Disabled"] + all_logs.append( + LogEvent( + log_level="info", + event=f"{reconcile_key.title()}: Disabled", + logger=str(type(self)), + ) + ) continue with capture_logs() as logs: reconciler_cls = self.reconcilers.get(reconcile_key) diff --git a/authentik/outposts/signals.py b/authentik/outposts/signals.py index f75687c26f..b630cf3457 100644 --- a/authentik/outposts/signals.py +++ b/authentik/outposts/signals.py @@ -1,37 +1,28 @@ """authentik outpost signals""" -from django.contrib.auth.signals import user_logged_out from django.core.cache import cache from django.db.models import Model from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save from django.dispatch import receiver -from django.http import HttpRequest from structlog.stdlib import get_logger from authentik.brands.models import Brand -from authentik.core.models import AuthenticatedSession, Provider, User +from authentik.core.models import AuthenticatedSession, Provider from authentik.crypto.models import CertificateKeyPair from authentik.lib.utils.reflection import class_to_path from authentik.outposts.models import Outpost, OutpostServiceConnection from authentik.outposts.tasks import ( CACHE_KEY_OUTPOST_DOWN, outpost_controller, - outpost_post_save, outpost_session_end, + outposts_and_related_update_dispatch, ) LOGGER = get_logger() -UPDATE_TRIGGERING_MODELS = ( - Outpost, - OutpostServiceConnection, - Provider, - CertificateKeyPair, - Brand, -) @receiver(pre_save, sender=Outpost) -def pre_save_outpost(sender, instance: Outpost, **_): +def outpost_pre_save(sender, instance: Outpost, **_): """Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes, we call down and then wait for the up after save""" old_instances = Outpost.objects.filter(pk=instance.pk) @@ -54,55 +45,39 @@ def pre_save_outpost(sender, instance: Outpost, **_): @receiver(m2m_changed, sender=Outpost.providers.through) -def m2m_changed_update(sender, instance: Model, action: str, **_): +def outpost_m2m_changed(sender, instance: Model, action: str, **_): """Update outpost on m2m change, when providers are added or removed""" if action in ["post_add", "post_remove", "post_clear"]: - outpost_post_save.send_with_options( - args=(class_to_path(instance.__class__), instance.pk), - # TODO: how do we get the outpost here, if it makes sense - rel_obj=None, - ) + outposts_and_related_update_dispatch.send(class_to_path(instance.__class__), instance.pk) -@receiver(post_save) -def post_save_update(sender, instance: Model, created: bool, **_): +def outposts_and_related_post_save(sender, instance: Model, created: bool, **_): """If an Outpost is saved, Ensure that token is created/updated If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, we send a message down the relevant OutpostModels WS connection to trigger an update""" - if instance.__module__ == "django.db.migrations.recorder": - return - if instance.__module__ == "__fake__": - return - if not isinstance(instance, UPDATE_TRIGGERING_MODELS): - return if isinstance(instance, Outpost) and created: LOGGER.info("New outpost saved, ensuring initial token and user are created") _ = instance.token - outpost_post_save.send_with_options( - args=(class_to_path(instance.__class__), instance.pk), - # TODO: how do we get the outpost here, if it makes sense - rel_obj=None, - ) + outposts_and_related_update_dispatch.send(class_to_path(instance.__class__), instance.pk) + + +post_save.connect(outposts_and_related_post_save, sender=Outpost, weak=False) +post_save.connect(outposts_and_related_post_save, sender=OutpostServiceConnection, weak=False) +post_save.connect(outposts_and_related_post_save, sender=Provider, weak=False) +post_save.connect(outposts_and_related_post_save, sender=CertificateKeyPair, weak=False) +post_save.connect(outposts_and_related_post_save, sender=Brand, weak=False) @receiver(pre_delete, sender=Outpost) -def pre_delete_cleanup(sender, instance: Outpost, **_): +def outpost_pre_delete_cleanup(sender, instance: Outpost, **_): """Ensure that Outpost's user is deleted (which will delete the token through cascade)""" instance.user.delete() cache.set(CACHE_KEY_OUTPOST_DOWN % instance.pk.hex, instance) outpost_controller.send(instance.pk.hex, action="down", from_cache=True) -@receiver(user_logged_out) -def logout_revoke_direct(sender: type[User], request: HttpRequest, **_): - """Catch logout by direct logout and forward to providers""" - if not request.session or not request.session.session_key: - return - outpost_session_end.send(request.session.session_key) - - @receiver(pre_delete, sender=AuthenticatedSession) -def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): +def outpost_logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): """Catch logout by expiring sessions being deleted""" outpost_session_end.send(instance.session.session_key) diff --git a/authentik/outposts/tasks.py b/authentik/outposts/tasks.py index 569197a9dd..bc3df2a827 100644 --- a/authentik/outposts/tasks.py +++ b/authentik/outposts/tasks.py @@ -138,8 +138,7 @@ def outpost_controller(outpost_pk: str, action: str = "up", from_cache: bool = F else: if from_cache: cache.delete(CACHE_KEY_OUTPOST_DOWN % outpost_pk) - for log in logs: - self.info(log) + self.logs(logs) @actor(description=_("Ensure that all Outposts have valid Service Accounts and Tokens.")) @@ -155,17 +154,18 @@ def outpost_token_ensurer(): self.info(f"Successfully checked {len(all_outposts)} Outposts.") -@actor(description=_("If an Outpost is saved, ensure that token is created/updated.")) -def outpost_post_save(model_class: str, model_pk: Any): +@actor(description=_("Dispatch tasks to update outposts when related objects are updated.")) +def outposts_and_related_update_dispatch(model_class: str, pk: Any): """If an Outpost is saved, Ensure that token is created/updated If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, we send a message down the relevant OutpostModels WS connection to trigger an update""" + model: Model = path_to_class(model_class) try: - instance = model.objects.get(pk=model_pk) + instance = model.objects.get(pk=pk) except model.DoesNotExist: - LOGGER.warning("Model does not exist", model=model, pk=model_pk) + LOGGER.warning("Model does not exist", model=model, pk=pk) return if isinstance(instance, Outpost): @@ -175,7 +175,7 @@ def outpost_post_save(model_class: str, model_pk: Any): if isinstance(instance, OutpostModel | Outpost): LOGGER.debug("triggering outpost update from outpostmodel/outpost", instance=instance) - outpost_send_update(instance) + outposts_and_related_send_update(instance) if isinstance(instance, OutpostServiceConnection): LOGGER.debug("triggering ServiceConnection state update", instance=instance) @@ -200,28 +200,30 @@ def outpost_post_save(model_class: str, model_pk: Any): # Because the Outpost Model has an M2M to Provider, # we have to iterate over the entire QS for reverse in getattr(instance, field_name).all(): - outpost_send_update(reverse) + outposts_and_related_send_update(reverse) -def outpost_send_update(model_instance: Model): - """Send outpost update to all registered outposts, regardless to which authentik - instance they are connected""" - channel_layer = get_channel_layer() +def outposts_and_related_send_update(model_instance: Model): + """Send outpost update to all related outposts""" if isinstance(model_instance, OutpostModel): for outpost in model_instance.outpost_set.all(): - _outpost_single_update(outpost, channel_layer) + outpost_send_update.send_with_options(args=(outpost.pk,), rel_obj=outpost) elif isinstance(model_instance, Outpost): - _outpost_single_update(model_instance, channel_layer) + outpost = model_instance + outpost_send_update.send_with_options(args=(outpost.pk,), rel_obj=outpost) -def _outpost_single_update(outpost: Outpost, layer=None): - """Update outpost instances connected to a single outpost""" +@actor(description=_("Send update to outpost")) +def outpost_send_update(pk: Any): + """Update outpost instance""" + outpost = Outpost.objects.filter(pk=pk).first() + if not outpost: + return # Ensure token again, because this function is called when anything related to an # OutpostModel is saved, so we can be sure permissions are right _ = outpost.token outpost.build_user_permissions(outpost.user) - if not layer: # pragma: no cover - layer = get_channel_layer() + layer = get_channel_layer() group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)} LOGGER.debug("sending update", channel=group, outpost=outpost) async_to_sync(layer.group_send)(group, {"type": "event.update"}) diff --git a/authentik/tasks/models.py b/authentik/tasks/models.py index 8916474ec3..cad3e1166e 100644 --- a/authentik/tasks/models.py +++ b/authentik/tasks/models.py @@ -107,6 +107,10 @@ class Task(SerializerModel, TaskBase): ) return sanitize_item(log) + def logs(self, logs: list[LogEvent]): + for log in logs: + self._messages.append(sanitize_item(log)) + def log( self, logger: str, @@ -117,13 +121,11 @@ class Task(SerializerModel, TaskBase): ): self._messages: list self._messages.append( - sanitize_item( - self._make_message( - logger, - log_level, - message, - **attributes, - ) + self._make_message( + logger, + log_level, + message, + **attributes, ) ) if save: diff --git a/web/src/admin/outposts/OutpostListPage.ts b/web/src/admin/outposts/OutpostListPage.ts index 7b1a6466fb..2d675de013 100644 --- a/web/src/admin/outposts/OutpostListPage.ts +++ b/web/src/admin/outposts/OutpostListPage.ts @@ -4,6 +4,8 @@ import "@goauthentik/admin/outposts/OutpostForm"; import "@goauthentik/admin/outposts/OutpostHealth"; import "@goauthentik/admin/outposts/OutpostHealthSimple"; import "@goauthentik/admin/rbac/ObjectPermissionModal"; +import "@goauthentik/admin/system-tasks/ScheduleList"; +import "@goauthentik/admin/system-tasks/TaskList"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { PFSize } from "@goauthentik/common/enums.js"; import { PFColor } from "@goauthentik/elements/Label"; @@ -24,6 +26,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import { + ModelEnum, Outpost, OutpostHealth, OutpostTypeEnum, @@ -163,6 +166,7 @@ export class OutpostListPage extends TablePage { } renderExpanded(item: Outpost): TemplateResult { + const [appLabel, modelName] = ModelEnum.AuthentikOutpostsOutpost.split("."); return html`

@@ -181,6 +185,38 @@ export class OutpostListPage extends TablePage {

`; })} +
+
+
+ ${msg("Schedules")} +
+
+
+ +
+
+
+
+
+
+
+ ${msg("Tasks")} +
+
+
+ +
+
+
+
`; } diff --git a/web/src/admin/outposts/ServiceConnectionListPage.ts b/web/src/admin/outposts/ServiceConnectionListPage.ts index 3532f1bbdd..a914b2c880 100644 --- a/web/src/admin/outposts/ServiceConnectionListPage.ts +++ b/web/src/admin/outposts/ServiceConnectionListPage.ts @@ -8,7 +8,6 @@ import "@goauthentik/admin/system-tasks/TaskList"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/components/ak-status-label"; import { PFColor } from "@goauthentik/elements/Label"; -import "@goauthentik/elements/Tabs"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; @@ -117,44 +116,38 @@ export class OutpostServiceConnectionListPage extends TablePage
-
- -
-
-
- -
+
+
+
+ ${msg("Schedules")} +
+
+
+
-
-
-
-
- -
+ +
+ +
+
+
+ ${msg("Tasks")} +
+
+
+
-
-
-
+ +
+ `; }