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:
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

View File

@ -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)

View File

@ -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)

View File

@ -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,27 +200,29 @@ 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()
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
LOGGER.debug("sending update", channel=group, outpost=outpost)

View File

@ -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,7 +121,6 @@ class Task(SerializerModel, TaskBase):
):
self._messages: list
self._messages.append(
sanitize_item(
self._make_message(
logger,
log_level,
@ -125,7 +128,6 @@ class Task(SerializerModel, TaskBase):
**attributes,
)
)
)
if save:
self.save()

View File

@ -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<Outpost> {
}
renderExpanded(item: Outpost): TemplateResult {
const [appLabel, modelName] = ModelEnum.AuthentikOutpostsOutpost.split(".");
return html`<td role="cell" colspan="5">
<div class="pf-c-table__expandable-row-content">
<h3>
@ -181,6 +185,38 @@ export class OutpostListPage extends TablePage<Outpost> {
</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("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>
</td>`;
}

View File

@ -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<ServiceConnectio
const [appLabel, modelName] = item.metaModelName.split(".");
return html` <td role="cell" colspan="5">
<div class="pf-c-table__expandable-row-content">
<div class="pf-c-content">
<ak-tabs>
<section
slot="page-schedules"
data-tab-title="${msg("Schedules")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<div
class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-12-col-on-2xl"
>
<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>
</section>
<section
slot="page-tasks"
data-tab-title="${msg("Tasks")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<div
class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-12-col-on-2xl"
>
</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>
</section>
</ak-tabs>
</div>
</dl>
</div>
</td>`;
}