diff --git a/authentik/admin/templates/administration/outpost_service_connection/list.html b/authentik/admin/templates/administration/outpost_service_connection/list.html deleted file mode 100644 index b43dcf2dc..000000000 --- a/authentik/admin/templates/administration/outpost_service_connection/list.html +++ /dev/null @@ -1,153 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load humanize %} -{% load authentik_utils %} - -{% block content %} -
-
-

- - {% trans 'Outpost Service-Connections' %} -

-

{% trans "Outpost Service-Connections define how authentik connects to external platforms to manage and deploy Outposts." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - - - -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for sc in object_list %} - - - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Type' %}{% trans 'Local?' %}{% trans 'Status' %}
- {{ sc.name }} - - - {{ sc|verbose_name }} - - - - {{ sc.local|yesno:"Yes,No" }} - - - - {% if sc.state.healthy %} - {{ sc.state.version }} - {% else %} - {% trans 'Unhealthy' %} - {% endif %} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Outpost Service Connections.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any outposts." %} - {% else %} - {% trans 'Currently no service connections exist. Click the button below to create one.' %} - {% endif %} -
- - - - -
-
- {% endif %} -
-
-{% endblock %} diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py index 1e5191fcd..7432e7c29 100644 --- a/authentik/admin/urls.py +++ b/authentik/admin/urls.py @@ -308,11 +308,6 @@ urlpatterns = [ name="outpost-delete", ), # Outpost Service Connections - path( - "outpost_service_connections/", - outposts_service_connections.OutpostServiceConnectionListView.as_view(), - name="outpost-service-connections", - ), path( "outpost_service_connections/create/", outposts_service_connections.OutpostServiceConnectionCreateView.as_view(), diff --git a/authentik/admin/views/outposts_service_connections.py b/authentik/admin/views/outposts_service_connections.py index a1aded022..4f2b107ed 100644 --- a/authentik/admin/views/outposts_service_connections.py +++ b/authentik/admin/views/outposts_service_connections.py @@ -4,38 +4,18 @@ from django.contrib.auth.mixins import ( PermissionRequiredMixin as DjangoPermissionRequiredMixin, ) from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy from django.utils.translation import gettext as _ -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin +from guardian.mixins import PermissionRequiredMixin from authentik.admin.views.utils import ( BackSuccessUrlMixin, DeleteMessageView, InheritanceCreateView, - InheritanceListView, InheritanceUpdateView, - SearchListMixin, - UserPaginateListMixin, ) from authentik.outposts.models import OutpostServiceConnection -class OutpostServiceConnectionListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - InheritanceListView, -): - """Show list of all outpost-service-connections""" - - model = OutpostServiceConnection - permission_required = "authentik_outposts.add_outpostserviceconnection" - template_name = "administration/outpost_service_connection/list.html" - ordering = "pk" - search_fields = ["pk", "name"] - - class OutpostServiceConnectionCreateView( SuccessMessageMixin, BackSuccessUrlMixin, @@ -49,8 +29,8 @@ class OutpostServiceConnectionCreateView( permission_required = "authentik_outposts.add_outpostserviceconnection" template_name = "generic/create.html" - success_url = reverse_lazy("authentik_admin:outpost-service-connections") - success_message = _("Successfully created OutpostServiceConnection") + success_url = "/" + success_message = _("Successfully created Outpost Service Connection") class OutpostServiceConnectionUpdateView( @@ -66,8 +46,8 @@ class OutpostServiceConnectionUpdateView( permission_required = "authentik_outposts.change_outpostserviceconnection" template_name = "generic/update.html" - success_url = reverse_lazy("authentik_admin:outpost-service-connections") - success_message = _("Successfully updated OutpostServiceConnection") + success_url = "/" + success_message = _("Successfully updated Outpost Service Connection") class OutpostServiceConnectionDeleteView( @@ -79,5 +59,5 @@ class OutpostServiceConnectionDeleteView( permission_required = "authentik_outposts.delete_outpostserviceconnection" template_name = "generic/delete.html" - success_url = reverse_lazy("authentik_admin:outpost-service-connections") - success_message = _("Successfully deleted OutpostServiceConnection") + success_url = "/" + success_message = _("Successfully deleted Outpost Service Connection") diff --git a/authentik/outposts/api/outpost_service_connections.py b/authentik/outposts/api/outpost_service_connections.py index 46f142ae6..986002b4d 100644 --- a/authentik/outposts/api/outpost_service_connections.py +++ b/authentik/outposts/api/outpost_service_connections.py @@ -1,7 +1,19 @@ """Outpost API Views""" -from rest_framework.serializers import ModelSerializer +from dataclasses import asdict + +from django.db.models.base import Model +from django.shortcuts import reverse +from drf_yasg2.utils import swagger_auto_schema +from rest_framework.decorators import action +from rest_framework.fields import BooleanField, CharField, SerializerMethodField +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer, Serializer from rest_framework.viewsets import ModelViewSet +from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer +from authentik.lib.templatetags.authentik_utils import verbose_name +from authentik.lib.utils.reflection import all_subclasses from authentik.outposts.models import ( DockerServiceConnection, KubernetesServiceConnection, @@ -9,32 +21,79 @@ from authentik.outposts.models import ( ) -class ServiceConnectionSerializer(ModelSerializer): +class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): """ServiceConnection Serializer""" + object_type = SerializerMethodField() + + def get_object_type(self, obj: OutpostServiceConnection) -> str: + """Get object type so that we know which API Endpoint to use to get the full object""" + return obj._meta.object_name.lower().replace("serviceconnection", "") + class Meta: model = OutpostServiceConnection - fields = ["pk", "name"] + fields = [ + "pk", + "name", + "local", + "object_type", + "verbose_name", + "verbose_name_plural", + ] + + +class ServiceConnectionStateSerializer(Serializer): + """Serializer for Service connection state""" + + healthy = BooleanField(read_only=True) + version = CharField(read_only=True) + + def create(self, validated_data: dict) -> Model: + raise NotImplementedError + + def update(self, instance: Model, validated_data: dict) -> Model: + raise NotImplementedError class ServiceConnectionViewSet(ModelViewSet): """ServiceConnection Viewset""" - queryset = OutpostServiceConnection.objects.all() + queryset = OutpostServiceConnection.objects.select_subclasses() serializer_class = ServiceConnectionSerializer + @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) + @action(detail=False) + def types(self, request: Request) -> Response: + """Get all creatable service connection types""" + data = [] + for subclass in all_subclasses(self.queryset.model): + data.append( + { + "name": verbose_name(subclass), + "description": subclass.__doc__, + "link": reverse("authentik_admin:outpost-service-connection-create") + + f"?type={subclass.__name__}", + } + ) + return Response(TypeCreateSerializer(data, many=True).data) -class DockerServiceConnectionSerializer(ModelSerializer): + @swagger_auto_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) + @action(detail=True) + # pylint: disable=unused-argument, invalid-name + def state(self, request: Request, pk: str) -> Response: + """Get the service connection's state""" + connection = self.get_object() + return Response(asdict(connection.state)) + + +class DockerServiceConnectionSerializer(ServiceConnectionSerializer): """DockerServiceConnection Serializer""" class Meta: model = DockerServiceConnection - fields = [ - "pk", - "name", - "local", + fields = ServiceConnectionSerializer.Meta.fields + [ "url", "tls_verification", "tls_authentication", @@ -48,13 +107,13 @@ class DockerServiceConnectionViewSet(ModelViewSet): serializer_class = DockerServiceConnectionSerializer -class KubernetesServiceConnectionSerializer(ModelSerializer): +class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): """KubernetesServiceConnection Serializer""" class Meta: model = KubernetesServiceConnection - fields = ["pk", "name", "local", "kubeconfig"] + fields = ServiceConnectionSerializer.Meta.fields + ["kubeconfig"] class KubernetesServiceConnectionViewSet(ModelViewSet): diff --git a/swagger.yaml b/swagger.yaml index 3274888bc..ad050bf70 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -2169,6 +2169,42 @@ paths: tags: - outposts parameters: [] + /outposts/service_connections/all/types/: + get: + operationId: outposts_service_connections_all_types + description: Get all creatable service connection types + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: Types of an object that can be created + schema: + description: '' + type: array + items: + $ref: '#/definitions/TypeCreate' + tags: + - outposts + parameters: [] /outposts/service_connections/all/{uuid}/: get: operationId: outposts_service_connections_all_read @@ -2229,6 +2265,25 @@ paths: required: true type: string format: uuid + /outposts/service_connections/all/{uuid}/state/: + get: + operationId: outposts_service_connections_all_state + description: Get the service connection's state + parameters: [] + responses: + '200': + description: Serializer for Service connection state + schema: + $ref: '#/definitions/ServiceConnectionState' + tags: + - outposts + parameters: + - name: uuid + in: path + description: A UUID string identifying this Outpost Service-Connection. + required: true + type: string + format: uuid /outposts/service_connections/docker/: get: operationId: outposts_service_connections_docker_list @@ -8797,6 +8852,55 @@ definitions: title: Name type: string minLength: 1 + local: + title: Local + description: If enabled, use the local connection. Required Docker socket/Kubernetes + Integration + type: boolean + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true + TypeCreate: + description: Types of an object that can be created + type: object + properties: + name: + title: Name + type: string + readOnly: true + minLength: 1 + description: + title: Description + type: string + readOnly: true + minLength: 1 + link: + title: Link + type: string + readOnly: true + minLength: 1 + ServiceConnectionState: + description: Serializer for Service connection state + type: object + properties: + healthy: + title: Healthy + type: boolean + readOnly: true + version: + title: Version + type: string + readOnly: true + minLength: 1 DockerServiceConnection: description: DockerServiceConnection Serializer required: @@ -8818,6 +8922,18 @@ definitions: description: If enabled, use the local connection. Required Docker socket/Kubernetes Integration type: boolean + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true url: title: Url description: Can be in the format of 'unix://' when connecting to a @@ -8859,6 +8975,18 @@ definitions: description: If enabled, use the local connection. Required Docker socket/Kubernetes Integration type: boolean + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true kubeconfig: title: Kubeconfig description: Paste your kubeconfig here. authentik will automatically use @@ -8898,25 +9026,6 @@ definitions: title: Bound to type: integer readOnly: true - TypeCreate: - description: Types of an object that can be created - type: object - properties: - name: - title: Name - type: string - readOnly: true - minLength: 1 - description: - title: Description - type: string - readOnly: true - minLength: 1 - link: - title: Link - type: string - readOnly: true - minLength: 1 PolicyBinding: description: PolicyBinding Serializer required: diff --git a/web/src/api/Outposts.ts b/web/src/api/Outposts.ts index 6a84cb833..10c60ed7e 100644 --- a/web/src/api/Outposts.ts +++ b/web/src/api/Outposts.ts @@ -1,5 +1,5 @@ import { DefaultClient, AKResponse, QueryArguments } from "./Client"; -import { Provider } from "./Providers"; +import { Provider, TypeCreate } from "./Providers"; export interface OutpostHealth { last_seen: number; @@ -38,3 +38,42 @@ export class Outpost { return `/administration/outposts/${rest}`; } } + +export interface OutpostServiceConnectionState { + version: string; + healthy: boolean; +} + +export class OutpostServiceConnection { + pk: string; + name: string; + local: boolean; + object_type: string; + verbose_name: string; + verbose_name_plural: string; + + constructor() { + throw Error(); + } + + static get(pk: string): Promise { + return DefaultClient.fetch(["outposts", "service_connections", "all", pk]); + } + + static list(filter?: QueryArguments): Promise> { + return DefaultClient.fetch>(["outposts", "service_connections", "all"], filter); + } + + static state(pk: string): Promise { + return DefaultClient.fetch(["outposts", "service_connections", "all", pk, "state"]); + } + + static getTypes(): Promise { + return DefaultClient.fetch(["outposts", "service_connections", "all", "types"]); + } + + static adminUrl(rest: string): string { + return `/administration/outpost_service_connections/${rest}`; + } + +} diff --git a/web/src/interfaces/AdminInterface.ts b/web/src/interfaces/AdminInterface.ts index 75d791835..d728a11d6 100644 --- a/web/src/interfaces/AdminInterface.ts +++ b/web/src/interfaces/AdminInterface.ts @@ -28,7 +28,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [ ), new SidebarItem("Providers", "/providers"), new SidebarItem("Outposts", "/outposts"), - new SidebarItem("Outpost Service Connections", "/administration/outpost_service_connections/"), + new SidebarItem("Outpost Service Connections", "/outpost-service-connections"), ).when((): Promise => { return User.me().then(u => u.is_superuser); }), diff --git a/web/src/pages/flows/BoundStagesList.ts b/web/src/pages/flows/BoundStagesList.ts index 464ef47a4..a1b95c3db 100644 --- a/web/src/pages/flows/BoundStagesList.ts +++ b/web/src/pages/flows/BoundStagesList.ts @@ -7,6 +7,7 @@ import "../../elements/Tabs"; import "../../elements/AdminLoginsChart"; import "../../elements/buttons/ModalButton"; import "../../elements/buttons/SpinnerButton"; +import "../../elements/buttons/Dropdown"; import "../../elements/policies/BoundPoliciesList"; import { FlowStageBinding, Stage } from "../../api/Flows"; import { until } from "lit-html/directives/until"; diff --git a/web/src/pages/outposts/OutpostServiceConnectionListPage.ts b/web/src/pages/outposts/OutpostServiceConnectionListPage.ts new file mode 100644 index 000000000..7e8eb4333 --- /dev/null +++ b/web/src/pages/outposts/OutpostServiceConnectionListPage.ts @@ -0,0 +1,102 @@ +import { gettext } from "django"; +import { customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { AKResponse } from "../../api/Client"; +import { OutpostServiceConnection } from "../../api/Outposts"; +import { TableColumn } from "../../elements/table/Table"; +import { TablePage } from "../../elements/table/TablePage"; + +import "./OutpostHealth"; +import "../../elements/buttons/SpinnerButton"; +import "../../elements/buttons/ModalButton"; +import "../../elements/buttons/Dropdown"; +import { until } from "lit-html/directives/until"; + +@customElement("ak-outpost-service-connection-list") +export class OutpostServiceConnectionListPage extends TablePage { + pageTitle(): string { + return "Outpost Service-Connections"; + } + pageDescription(): string | undefined { + return "Outpost Service-Connections define how authentik connects to external platforms to manage and deploy Outposts."; + } + pageIcon(): string { + return "pf-icon pf-icon-integration"; + } + searchEnabled(): boolean { + return true; + } + + apiEndpoint(page: number): Promise> { + return OutpostServiceConnection.list({ + ordering: this.order, + page: page, + search: this.search || "", + }); + } + columns(): TableColumn[] { + return [ + new TableColumn("Name", "name"), + new TableColumn("Type"), + new TableColumn("Local", "local"), + new TableColumn("State"), + new TableColumn(""), + ]; + } + + @property() + order = "name"; + + row(item: OutpostServiceConnection): TemplateResult[] { + return [ + html`${item.name}`, + html`${item.verbose_name}`, + html`${item.local ? "Yes" : "No"}`, + html`${until(OutpostServiceConnection.state(item.pk).then((state) => { + if (state.healthy) { + return html` ${state.version}`; + } + return html` ${gettext("Unhealthy")}`; + }), html``)}`, + html` + + + ${gettext("Edit")} + +
+
+ + + ${gettext("Delete")} + +
+
`, + ]; + } + + renderToolbar(): TemplateResult { + return html` + + + + + ${super.renderToolbar()}`; + } + +} diff --git a/web/src/pages/policies/PolicyListPage.ts b/web/src/pages/policies/PolicyListPage.ts index 503e2c660..90ad15e08 100644 --- a/web/src/pages/policies/PolicyListPage.ts +++ b/web/src/pages/policies/PolicyListPage.ts @@ -31,7 +31,7 @@ export class PolicyListPage extends TablePage { apiEndpoint(page: number): Promise> { return Policy.list({ ordering: this.order, - page: page, + page: page, search: this.search || "", }); } @@ -54,7 +54,7 @@ export class PolicyListPage extends TablePage { ${gettext(`Assigned to ${item.bound_to} objects.`)} `: html` - ${gettext("Warning: Policy is not assigned.")}/small>`} + ${gettext("Warning: Policy is not assigned.")}`} `, html`${item.verbose_name}`, html` diff --git a/web/src/routes.ts b/web/src/routes.ts index 0675d2ba2..453325ad8 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -13,6 +13,7 @@ import "./pages/flows/FlowListPage"; import "./pages/flows/FlowViewPage"; import "./pages/LibraryPage"; import "./pages/outposts/OutpostListPage"; +import "./pages/outposts/OutpostServiceConnectionListPage"; import "./pages/policies/PolicyListPage"; import "./pages/property-mappings/PropertyMappingListPage"; import "./pages/providers/ProviderListPage"; @@ -53,5 +54,6 @@ export const ROUTES: Route[] = [ new Route(new RegExp("^/events/rules$"), html``), new Route(new RegExp("^/property-mappings$"), html``), new Route(new RegExp("^/outposts$"), html``), + new Route(new RegExp("^/outpost-service-connections$"), html``), new Route(new RegExp("^/crypto/certificates$"), html``), ];