Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Marc 'risson' Schmitt
2025-06-24 17:34:15 +02:00
parent 6321537c8d
commit 96b4d5aee4
7 changed files with 127 additions and 107 deletions

View File

@ -212,14 +212,14 @@ def apply_blueprint(instance_pk: UUID):
if not valid: if not valid:
instance.status = BlueprintInstanceStatus.ERROR instance.status = BlueprintInstanceStatus.ERROR
instance.save() instance.save()
self.error(*logs) self.logs(logs)
return return
with capture_logs() as logs: with capture_logs() as logs:
applied = importer.apply() applied = importer.apply()
if not applied: if not applied:
instance.status = BlueprintInstanceStatus.ERROR instance.status = BlueprintInstanceStatus.ERROR
instance.save() instance.save()
self.error(*logs) self.logs(logs)
return return
instance.status = BlueprintInstanceStatus.SUCCESSFUL instance.status = BlueprintInstanceStatus.SUCCESSFUL
instance.last_applied_hash = file_hash instance.last_applied_hash = file_hash

View File

@ -101,7 +101,13 @@ class KubernetesController(BaseController):
all_logs = [] all_logs = []
for reconcile_key in self.reconcile_order: for reconcile_key in self.reconcile_order:
if reconcile_key in self.outpost.config.kubernetes_disabled_components: 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 continue
with capture_logs() as logs: with capture_logs() as logs:
reconciler_cls = self.reconcilers.get(reconcile_key) reconciler_cls = self.reconcilers.get(reconcile_key)
@ -134,7 +140,13 @@ class KubernetesController(BaseController):
all_logs = [] all_logs = []
for reconcile_key in self.reconcile_order: for reconcile_key in self.reconcile_order:
if reconcile_key in self.outpost.config.kubernetes_disabled_components: 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 continue
with capture_logs() as logs: with capture_logs() as logs:
reconciler_cls = self.reconcilers.get(reconcile_key) reconciler_cls = self.reconcilers.get(reconcile_key)

View File

@ -1,37 +1,28 @@
"""authentik outpost signals""" """authentik outpost signals"""
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.brands.models import Brand 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.crypto.models import CertificateKeyPair
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.outposts.models import Outpost, OutpostServiceConnection from authentik.outposts.models import Outpost, OutpostServiceConnection
from authentik.outposts.tasks import ( from authentik.outposts.tasks import (
CACHE_KEY_OUTPOST_DOWN, CACHE_KEY_OUTPOST_DOWN,
outpost_controller, outpost_controller,
outpost_post_save,
outpost_session_end, outpost_session_end,
outposts_and_related_update_dispatch,
) )
LOGGER = get_logger() LOGGER = get_logger()
UPDATE_TRIGGERING_MODELS = (
Outpost,
OutpostServiceConnection,
Provider,
CertificateKeyPair,
Brand,
)
@receiver(pre_save, sender=Outpost) @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, """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""" we call down and then wait for the up after save"""
old_instances = Outpost.objects.filter(pk=instance.pk) 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) @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""" """Update outpost on m2m change, when providers are added or removed"""
if action in ["post_add", "post_remove", "post_clear"]: if action in ["post_add", "post_remove", "post_clear"]:
outpost_post_save.send_with_options( outposts_and_related_update_dispatch.send(class_to_path(instance.__class__), instance.pk)
args=(class_to_path(instance.__class__), instance.pk),
# TODO: how do we get the outpost here, if it makes sense
rel_obj=None,
)
@receiver(post_save) def outposts_and_related_post_save(sender, instance: Model, created: bool, **_):
def post_save_update(sender, instance: Model, created: bool, **_):
"""If an Outpost is saved, Ensure that token is created/updated """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, 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""" 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: if isinstance(instance, Outpost) and created:
LOGGER.info("New outpost saved, ensuring initial token and user are created") LOGGER.info("New outpost saved, ensuring initial token and user are created")
_ = instance.token _ = instance.token
outpost_post_save.send_with_options( outposts_and_related_update_dispatch.send(class_to_path(instance.__class__), instance.pk)
args=(class_to_path(instance.__class__), instance.pk),
# TODO: how do we get the outpost here, if it makes sense
rel_obj=None, 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) @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)""" """Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
instance.user.delete() instance.user.delete()
cache.set(CACHE_KEY_OUTPOST_DOWN % instance.pk.hex, instance) cache.set(CACHE_KEY_OUTPOST_DOWN % instance.pk.hex, instance)
outpost_controller.send(instance.pk.hex, action="down", from_cache=True) 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) @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""" """Catch logout by expiring sessions being deleted"""
outpost_session_end.send(instance.session.session_key) outpost_session_end.send(instance.session.session_key)

View File

@ -138,8 +138,7 @@ def outpost_controller(outpost_pk: str, action: str = "up", from_cache: bool = F
else: else:
if from_cache: if from_cache:
cache.delete(CACHE_KEY_OUTPOST_DOWN % outpost_pk) cache.delete(CACHE_KEY_OUTPOST_DOWN % outpost_pk)
for log in logs: self.logs(logs)
self.info(log)
@actor(description=_("Ensure that all Outposts have valid Service Accounts and Tokens.")) @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.") self.info(f"Successfully checked {len(all_outposts)} Outposts.")
@actor(description=_("If an Outpost is saved, ensure that token is created/updated.")) @actor(description=_("Dispatch tasks to update outposts when related objects are updated."))
def outpost_post_save(model_class: str, model_pk: Any): def outposts_and_related_update_dispatch(model_class: str, pk: Any):
"""If an Outpost is saved, Ensure that token is created/updated """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, 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""" we send a message down the relevant OutpostModels WS connection to trigger an update"""
model: Model = path_to_class(model_class) model: Model = path_to_class(model_class)
try: try:
instance = model.objects.get(pk=model_pk) instance = model.objects.get(pk=pk)
except model.DoesNotExist: 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 return
if isinstance(instance, Outpost): if isinstance(instance, Outpost):
@ -175,7 +175,7 @@ def outpost_post_save(model_class: str, model_pk: Any):
if isinstance(instance, OutpostModel | Outpost): if isinstance(instance, OutpostModel | Outpost):
LOGGER.debug("triggering outpost update from outpostmodel/outpost", instance=instance) LOGGER.debug("triggering outpost update from outpostmodel/outpost", instance=instance)
outpost_send_update(instance) outposts_and_related_send_update(instance)
if isinstance(instance, OutpostServiceConnection): if isinstance(instance, OutpostServiceConnection):
LOGGER.debug("triggering ServiceConnection state update", instance=instance) 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, # Because the Outpost Model has an M2M to Provider,
# we have to iterate over the entire QS # we have to iterate over the entire QS
for reverse in getattr(instance, field_name).all(): 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): def outposts_and_related_send_update(model_instance: Model):
"""Send outpost update to all registered outposts, regardless to which authentik """Send outpost update to all related outposts"""
instance they are connected"""
channel_layer = get_channel_layer()
if isinstance(model_instance, OutpostModel): if isinstance(model_instance, OutpostModel):
for outpost in model_instance.outpost_set.all(): 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): 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): @actor(description=_("Send update to outpost"))
"""Update outpost instances connected to a single 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 # Ensure token again, because this function is called when anything related to an
# OutpostModel is saved, so we can be sure permissions are right # OutpostModel is saved, so we can be sure permissions are right
_ = outpost.token _ = outpost.token
outpost.build_user_permissions(outpost.user) 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)} group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
LOGGER.debug("sending update", channel=group, outpost=outpost) LOGGER.debug("sending update", channel=group, outpost=outpost)
async_to_sync(layer.group_send)(group, {"type": "event.update"}) async_to_sync(layer.group_send)(group, {"type": "event.update"})

View File

@ -107,6 +107,10 @@ class Task(SerializerModel, TaskBase):
) )
return sanitize_item(log) return sanitize_item(log)
def logs(self, logs: list[LogEvent]):
for log in logs:
self._messages.append(sanitize_item(log))
def log( def log(
self, self,
logger: str, logger: str,
@ -117,13 +121,11 @@ class Task(SerializerModel, TaskBase):
): ):
self._messages: list self._messages: list
self._messages.append( self._messages.append(
sanitize_item( self._make_message(
self._make_message( logger,
logger, log_level,
log_level, message,
message, **attributes,
**attributes,
)
) )
) )
if save: if save:

View File

@ -4,6 +4,8 @@ import "@goauthentik/admin/outposts/OutpostForm";
import "@goauthentik/admin/outposts/OutpostHealth"; import "@goauthentik/admin/outposts/OutpostHealth";
import "@goauthentik/admin/outposts/OutpostHealthSimple"; import "@goauthentik/admin/outposts/OutpostHealthSimple";
import "@goauthentik/admin/rbac/ObjectPermissionModal"; 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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js"; import { PFSize } from "@goauthentik/common/enums.js";
import { PFColor } from "@goauthentik/elements/Label"; 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 PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { import {
ModelEnum,
Outpost, Outpost,
OutpostHealth, OutpostHealth,
OutpostTypeEnum, OutpostTypeEnum,
@ -163,6 +166,7 @@ export class OutpostListPage extends TablePage<Outpost> {
} }
renderExpanded(item: Outpost): TemplateResult { renderExpanded(item: Outpost): TemplateResult {
const [appLabel, modelName] = ModelEnum.AuthentikOutpostsOutpost.split(".");
return html`<td role="cell" colspan="5"> return html`<td role="cell" colspan="5">
<div class="pf-c-table__expandable-row-content"> <div class="pf-c-table__expandable-row-content">
<h3> <h3>
@ -181,6 +185,38 @@ export class OutpostListPage extends TablePage<Outpost> {
</div>`; </div>`;
})} })}
</dl> </dl>
<dl class="pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Schedules")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-schedule-list
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${item.pk}"
></ak-schedule-list>
</div>
</dd>
</div>
</dl>
<dl class="pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Tasks")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-task-list
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${item.pk}"
></ak-task-list>
</div>
</dd>
</div>
</dl>
</div> </div>
</td>`; </td>`;
} }

View File

@ -8,7 +8,6 @@ import "@goauthentik/admin/system-tasks/TaskList";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
import { PFColor } from "@goauthentik/elements/Label"; import { PFColor } from "@goauthentik/elements/Label";
import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
@ -117,44 +116,38 @@ export class OutpostServiceConnectionListPage extends TablePage<ServiceConnectio
const [appLabel, modelName] = item.metaModelName.split("."); const [appLabel, modelName] = item.metaModelName.split(".");
return html` <td role="cell" colspan="5"> return html` <td role="cell" colspan="5">
<div class="pf-c-table__expandable-row-content"> <div class="pf-c-table__expandable-row-content">
<div class="pf-c-content"> <dl class="pf-c-description-list pf-m-horizontal">
<ak-tabs> <div class="pf-c-description-list__group">
<section <dt class="pf-c-description-list__term">
slot="page-schedules" <span class="pf-c-description-list__text">${msg("Schedules")}</span>
data-tab-title="${msg("Schedules")}" </dt>
class="pf-c-page__main-section pf-m-no-padding-mobile" <dd class="pf-c-description-list__description">
> <div class="pf-c-description-list__text">
<div class="pf-l-grid pf-m-gutter"> <ak-schedule-list
<div .relObjAppLabel=${appLabel}
class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-12-col-on-2xl" .relObjModel=${modelName}
> .relObjId="${item.pk}"
<ak-schedule-list ></ak-schedule-list>
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${item.pk}"
></ak-schedule-list>
</div>
</div> </div>
</section> </dd>
<section </div>
slot="page-tasks" </dl>
data-tab-title="${msg("Tasks")}" <dl class="pf-c-description-list pf-m-horizontal">
class="pf-c-page__main-section pf-m-no-padding-mobile" <div class="pf-c-description-list__group">
> <dt class="pf-c-description-list__term">
<div class="pf-l-grid pf-m-gutter"> <span class="pf-c-description-list__text">${msg("Tasks")}</span>
<div </dt>
class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-12-col-on-2xl" <dd class="pf-c-description-list__description">
> <div class="pf-c-description-list__text">
<ak-task-list <ak-task-list
.relObjAppLabel=${appLabel} .relObjAppLabel=${appLabel}
.relObjModel=${modelName} .relObjModel=${modelName}
.relObjId="${item.pk}" .relObjId="${item.pk}"
></ak-task-list> ></ak-task-list>
</div>
</div> </div>
</section> </dd>
</ak-tabs> </div>
</div> </dl>
</div> </div>
</td>`; </td>`;
} }