outposts: migrate service connections to web

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-03-31 22:40:48 +02:00
parent 884c91062d
commit 656fe00302
10 changed files with 256 additions and 162 deletions

View file

@ -2,7 +2,6 @@
from django.urls import path
from authentik.admin.views import (
outposts_service_connections,
policies,
property_mappings,
providers,
@ -60,15 +59,4 @@ urlpatterns = [
property_mappings.PropertyMappingUpdateView.as_view(),
name="property-mapping-update",
),
# Outpost Service Connections
path(
"outpost_service_connections/create/",
outposts_service_connections.OutpostServiceConnectionCreateView.as_view(),
name="outpost-service-connection-create",
),
path(
"outpost_service_connections/<uuid:pk>/update/",
outposts_service_connections.OutpostServiceConnectionUpdateView.as_view(),
name="outpost-service-connection-update",
),
]

View file

@ -1,44 +0,0 @@
"""authentik OutpostServiceConnection administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
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 PermissionRequiredMixin
from authentik.admin.views.utils import InheritanceCreateView, InheritanceUpdateView
from authentik.outposts.models import OutpostServiceConnection
class OutpostServiceConnectionCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
InheritanceCreateView,
):
"""Create new OutpostServiceConnection"""
model = OutpostServiceConnection
permission_required = "authentik_outposts.add_outpostserviceconnection"
template_name = "generic/create.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully created Outpost Service Connection")
class OutpostServiceConnectionUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
InheritanceUpdateView,
):
"""Update outpostserviceconnection"""
model = OutpostServiceConnection
permission_required = "authentik_outposts.change_outpostserviceconnection"
template_name = "generic/update.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully updated Outpost Service Connection")

View file

@ -1,9 +1,12 @@
"""Outpost API Views"""
from dataclasses import asdict
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins
from kubernetes.client.configuration import Configuration
from kubernetes.config.config_exception import ConfigException
from kubernetes.config.kube_config import load_kube_config_from_dict
from rest_framework import mixins, serializers
from rest_framework.decorators import action
from rest_framework.fields import BooleanField, CharField, SerializerMethodField
from rest_framework.request import Request
@ -77,8 +80,7 @@ class ServiceConnectionViewSet(
{
"name": verbose_name(subclass),
"description": subclass.__doc__,
"link": reverse("authentik_admin:outpost-service-connection-create")
+ f"?type={subclass.__name__}",
"component": subclass().component,
}
)
return Response(TypeCreateSerializer(data, many=True).data)
@ -115,6 +117,24 @@ class DockerServiceConnectionViewSet(ModelViewSet):
class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
"""KubernetesServiceConnection Serializer"""
def validate_kubeconfig(self, kubeconfig):
"""Validate kubeconfig by attempting to load it"""
if kubeconfig == {}:
if not self.validated_data["local"]:
raise serializers.ValidationError(
_(
"You can only use an empty kubeconfig when connecting to a local cluster."
)
)
# Empty kubeconfig is valid
return kubeconfig
config = Configuration()
try:
load_kube_config_from_dict(kubeconfig, client_configuration=config)
except ConfigException:
raise serializers.ValidationError(_("Invalid kubeconfig"))
return kubeconfig
class Meta:
model = KubernetesServiceConnection

View file

@ -1,75 +0,0 @@
"""Outpost forms"""
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from kubernetes.client.configuration import Configuration
from kubernetes.config.config_exception import ConfigException
from kubernetes.config.kube_config import load_kube_config_from_dict
from authentik.admin.fields import CodeMirrorWidget, YAMLField
from authentik.crypto.models import CertificateKeyPair
from authentik.outposts.models import (
DockerServiceConnection,
KubernetesServiceConnection,
)
class DockerServiceConnectionForm(forms.ModelForm):
"""Docker service-connection form"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["tls_authentication"].queryset = CertificateKeyPair.objects.filter(
key_data__isnull=False
)
class Meta:
model = DockerServiceConnection
fields = ["name", "local", "url", "tls_verification", "tls_authentication"]
widgets = {
"name": forms.TextInput,
"url": forms.TextInput,
}
labels = {
"url": _("URL"),
"tls_verification": _("TLS Verification Certificate"),
"tls_authentication": _("TLS Authentication Certificate"),
}
class KubernetesServiceConnectionForm(forms.ModelForm):
"""Kubernetes service-connection form"""
def clean_kubeconfig(self):
"""Validate kubeconfig by attempting to load it"""
kubeconfig = self.cleaned_data["kubeconfig"]
if kubeconfig == {}:
if not self.cleaned_data["local"]:
raise ValidationError(
_("You can only use an empty kubeconfig when local is enabled.")
)
# Empty kubeconfig is valid
return kubeconfig
config = Configuration()
try:
load_kube_config_from_dict(kubeconfig, client_configuration=config)
except ConfigException:
raise ValidationError(_("Invalid kubeconfig"))
return kubeconfig
class Meta:
model = KubernetesServiceConnection
fields = [
"name",
"local",
"kubeconfig",
]
widgets = {
"name": forms.TextInput,
"kubeconfig": CodeMirrorWidget,
}
field_classes = {
"kubeconfig": YAMLField,
}

View file

@ -1,14 +1,13 @@
"""Outpost models"""
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import Iterable, Optional, Type, Union
from typing import Iterable, Optional, Union
from uuid import uuid4
from dacite import from_dict
from django.core.cache import cache
from django.db import models, transaction
from django.db.models.base import Model
from django.forms.models import ModelForm
from django.utils.translation import gettext_lazy as _
from docker.client import DockerClient
from docker.errors import DockerException
@ -132,8 +131,8 @@ class OutpostServiceConnection(models.Model):
raise NotImplementedError
@property
def form(self) -> Type[ModelForm]:
"""Return Form class used to edit this object"""
def component(self) -> str:
"""Return component used to edit this object"""
raise NotImplementedError
class Meta:
@ -180,10 +179,8 @@ class DockerServiceConnection(OutpostServiceConnection):
)
@property
def form(self) -> Type[ModelForm]:
from authentik.outposts.forms import DockerServiceConnectionForm
return DockerServiceConnectionForm
def component(self) -> str:
return "ak-service-connection-docker-form"
def __str__(self) -> str:
return f"Docker Service-Connection {self.name}"
@ -237,10 +234,8 @@ class KubernetesServiceConnection(OutpostServiceConnection):
)
@property
def form(self) -> Type[ModelForm]:
from authentik.outposts.forms import KubernetesServiceConnectionForm
return KubernetesServiceConnectionForm
def component(self) -> str:
return "ak-service-connection-kubernetes-form"
def __str__(self) -> str:
return f"Kubernetes Service-Connection {self.name}"

View file

@ -12,10 +12,6 @@ export class AdminURLManager {
return `/administration/property-mappings/${rest}`;
}
static outpostServiceConnections(rest: string): string {
return `/administration/outpost_service_connections/${rest}`;
}
static stages(rest: string): string {
return `/administration/stages/${rest}`;
}

View file

@ -0,0 +1,111 @@
import { CryptoApi, DockerServiceConnection, OutpostsApi } from "authentik-api";
import { gettext } from "django";
import { customElement, property } from "lit-element";
import { html, TemplateResult } from "lit-html";
import { DEFAULT_CONFIG } from "../../api/Config";
import { Form } from "../../elements/forms/Form";
import { until } from "lit-html/directives/until";
import { ifDefined } from "lit-html/directives/if-defined";
import "../../elements/forms/HorizontalFormElement";
@customElement("ak-service-connection-docker-form")
export class ServiceConnectionDockerForm extends Form<DockerServiceConnection> {
set scUUID(value: string) {
new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsDockerRead({
uuid: value,
}).then(sc => {
this.sc = sc;
});
}
@property({attribute: false})
sc?: DockerServiceConnection;
getSuccessMessage(): string {
if (this.sc) {
return gettext("Successfully updated service-connection.");
} else {
return gettext("Successfully created service-connection.");
}
}
send = (data: DockerServiceConnection): Promise<DockerServiceConnection> => {
if (this.sc) {
return new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsDockerUpdate({
uuid: this.sc.pk || "",
data: data
});
} else {
return new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsDockerCreate({
data: data
});
}
};
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${gettext("Name")}
?required=${true}
name="name">
<input type="text" value="${ifDefined(this.sc?.name)}" class="pf-c-form-control" required>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="local">
<div class="pf-c-check">
<input type="checkbox" class="pf-c-check__input" ?checked=${this.sc?.local || false}>
<label class="pf-c-check__label">
${gettext("Local")}
</label>
</div>
<p class="pf-c-form__helper-text">${gettext("If enabled, use the local connection. Required Docker socket/Kubernetes Integration.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Docker URL")}
?required=${true}
name="url">
<input type="text" value="${ifDefined(this.sc?.url)}" class="pf-c-form-control" required>
<p class="pf-c-form__helper-text">${gettext("Can be in the format of 'unix://' when connecting to a local docker daemon, or 'https://:2376' when connecting to a remote system.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("TLS Verification Certificate")}
?required=${true}
name="tlsVerification">
<select class="pf-c-form-control">
<option value="" ?selected=${this.sc?.tlsVerification === undefined}>---------</option>
${until(new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList({
ordering: "pk"
}).then(certs => {
return certs.results.map(cert => {
const selected = Array.from(this.sc?.tlsVerification || []).some(sp => {
return sp == cert.pk;
});
return html`<option value=${ifDefined(cert.pk)} ?selected=${selected}>${cert.name}</option>`;
});
}))}
</select>
<p class="pf-c-form__helper-text">${gettext("CA which the endpoint's Certificate is verified against. Can be left empty for no validation.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("TLS Authentication Certificate")}
?required=${true}
name="tlsAuthentication">
<select class="pf-c-form-control">
<option value="" ?selected=${this.sc?.tlsAuthentication === undefined}>---------</option>
${until(new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList({
ordering: "pk"
}).then(certs => {
return certs.results.map(cert => {
const selected = Array.from(this.sc?.tlsAuthentication || []).some(sp => {
return sp == cert.pk;
});
return html`<option value=${ifDefined(cert.pk)} ?selected=${selected}>${cert.name}</option>`;
});
}))}
</select>
<p class="pf-c-form__helper-text">${gettext("Certificate/Key used for authentication. Can be left empty for no authentication.")}</p>
</ak-form-element-horizontal>
</form>`;
}
}

View file

@ -0,0 +1,73 @@
import { KubernetesServiceConnection, OutpostsApi } from "authentik-api";
import { gettext } from "django";
import { customElement, property } from "lit-element";
import { html, TemplateResult } from "lit-html";
import { DEFAULT_CONFIG } from "../../api/Config";
import { Form } from "../../elements/forms/Form";
import { ifDefined } from "lit-html/directives/if-defined";
import "../../elements/forms/HorizontalFormElement";
import "../../elements/CodeMirror";
import YAML from "yaml";
@customElement("ak-service-connection-kubernetes-form")
export class ServiceConnectionKubernetesForm extends Form<KubernetesServiceConnection> {
set scUUID(value: string) {
new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsKubernetesRead({
uuid: value,
}).then(sc => {
this.sc = sc;
});
}
@property({attribute: false})
sc?: KubernetesServiceConnection;
getSuccessMessage(): string {
if (this.sc) {
return gettext("Successfully updated service-connection.");
} else {
return gettext("Successfully created service-connection.");
}
}
send = (data: KubernetesServiceConnection): Promise<KubernetesServiceConnection> => {
if (this.sc) {
return new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsKubernetesUpdate({
uuid: this.sc.pk || "",
data: data
});
} else {
return new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsKubernetesCreate({
data: data
});
}
};
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${gettext("Name")}
?required=${true}
name="name">
<input type="text" value="${ifDefined(this.sc?.name)}" class="pf-c-form-control" required>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="local">
<div class="pf-c-check">
<input type="checkbox" class="pf-c-check__input" ?checked=${this.sc?.local || false}>
<label class="pf-c-check__label">
${gettext("Local")}
</label>
</div>
<p class="pf-c-form__helper-text">${gettext("If enabled, use the local connection. Required Docker socket/Kubernetes Integration.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Kubeconfig")}
name="kubeconfig">
<ak-codemirror mode="yaml" value="${YAML.stringify(this.sc?.kubeconfig)}">
</ak-codemirror>
</ak-form-element-horizontal>
</form>`;
}
}

View file

@ -10,11 +10,15 @@ import "../../elements/buttons/SpinnerButton";
import "../../elements/buttons/ModalButton";
import "../../elements/buttons/Dropdown";
import "../../elements/forms/DeleteForm";
import "../../elements/forms/ModalForm";
import "./ServiceConnectionKubernetesForm";
import "./ServiceConnectionDockerForm";
import { until } from "lit-html/directives/until";
import { PAGE_SIZE } from "../../constants";
import { OutpostsApi, ServiceConnection } from "authentik-api";
import { DEFAULT_CONFIG } from "../../api/Config";
import { AdminURLManager } from "../../api/legacy";
import "../../elements/forms/ProxyForm";
import { ifDefined } from "lit-html/directives/if-defined";
@customElement("ak-outpost-service-connection-list")
export class OutpostServiceConnectionListPage extends TablePage<ServiceConnection> {
@ -68,12 +72,28 @@ export class OutpostServiceConnectionListPage extends TablePage<ServiceConnectio
return html`<i class="fas fa-times pf-m-danger"></i> ${gettext("Unhealthy")}`;
}), html`<ak-spinner></ak-spinner>`)}`,
html`
<ak-modal-button href="${AdminURLManager.outpostServiceConnections(`${item.pk}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
<ak-forms-modal>
<span slot="submit">
${gettext("Update")}
</span>
<span slot="header">
${gettext(`Update ${item.verboseName}`)}
</span>
<ak-proxy-form
slot="form"
.args=${{
"scUUID": item.pk
}}
type=${ifDefined(item.objectType)}
.typeMap=${{
"docker": "ak-service-connection-docker-form",
"kubernetes": "ak-service-connection-kubernetes-form"
}}>
</ak-proxy-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${gettext("Edit")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</button>
</ak-forms-modal>
<ak-forms-delete
.obj=${item}
objectLabel=${gettext("Outpost Service-connection")}
@ -100,12 +120,22 @@ export class OutpostServiceConnectionListPage extends TablePage<ServiceConnectio
${until(new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypes({}).then((types) => {
return types.map((type) => {
return html`<li>
<ak-modal-button href="${type.link}">
<button slot="trigger" class="pf-c-dropdown__menu-item">${type.name}<br>
<ak-forms-modal>
<span slot="submit">
${gettext("Create")}
</span>
<span slot="header">
${gettext(`Create ${type.name}`)}
</span>
<ak-proxy-form
slot="form"
type=${type.link}>
</ak-proxy-form>
<button slot="trigger" class="pf-c-dropdown__menu-item">
${type.name}<br>
<small>${type.description}</small>
</button>
<div slot="modal"></div>
</ak-modal-button>
</ak-forms-modal>
</li>`;
});
}), html`<ak-spinner></ak-spinner>`)}

View file

@ -14,7 +14,7 @@ import "./pages/flows/FlowViewPage";
import "./pages/groups/GroupListPage";
import "./pages/LibraryPage";
import "./pages/outposts/OutpostListPage";
import "./pages/outposts/OutpostServiceConnectionListPage";
import "./pages/outposts/ServiceConnectionListPage";
import "./pages/policies/PolicyListPage";
import "./pages/property-mappings/PropertyMappingListPage";
import "./pages/providers/ProviderListPage";