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 django.urls import path
from authentik.admin.views import ( from authentik.admin.views import (
outposts_service_connections,
policies, policies,
property_mappings, property_mappings,
providers, providers,
@ -60,15 +59,4 @@ urlpatterns = [
property_mappings.PropertyMappingUpdateView.as_view(), property_mappings.PropertyMappingUpdateView.as_view(),
name="property-mapping-update", 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""" """Outpost API Views"""
from dataclasses import asdict 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 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.decorators import action
from rest_framework.fields import BooleanField, CharField, SerializerMethodField from rest_framework.fields import BooleanField, CharField, SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
@ -77,8 +80,7 @@ class ServiceConnectionViewSet(
{ {
"name": verbose_name(subclass), "name": verbose_name(subclass),
"description": subclass.__doc__, "description": subclass.__doc__,
"link": reverse("authentik_admin:outpost-service-connection-create") "component": subclass().component,
+ f"?type={subclass.__name__}",
} }
) )
return Response(TypeCreateSerializer(data, many=True).data) return Response(TypeCreateSerializer(data, many=True).data)
@ -115,6 +117,24 @@ class DockerServiceConnectionViewSet(ModelViewSet):
class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
"""KubernetesServiceConnection Serializer""" """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: class Meta:
model = KubernetesServiceConnection 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""" """Outpost models"""
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime from datetime import datetime
from typing import Iterable, Optional, Type, Union from typing import Iterable, Optional, Union
from uuid import uuid4 from uuid import uuid4
from dacite import from_dict from dacite import from_dict
from django.core.cache import cache from django.core.cache import cache
from django.db import models, transaction from django.db import models, transaction
from django.db.models.base import Model from django.db.models.base import Model
from django.forms.models import ModelForm
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from docker.client import DockerClient from docker.client import DockerClient
from docker.errors import DockerException from docker.errors import DockerException
@ -132,8 +131,8 @@ class OutpostServiceConnection(models.Model):
raise NotImplementedError raise NotImplementedError
@property @property
def form(self) -> Type[ModelForm]: def component(self) -> str:
"""Return Form class used to edit this object""" """Return component used to edit this object"""
raise NotImplementedError raise NotImplementedError
class Meta: class Meta:
@ -180,10 +179,8 @@ class DockerServiceConnection(OutpostServiceConnection):
) )
@property @property
def form(self) -> Type[ModelForm]: def component(self) -> str:
from authentik.outposts.forms import DockerServiceConnectionForm return "ak-service-connection-docker-form"
return DockerServiceConnectionForm
def __str__(self) -> str: def __str__(self) -> str:
return f"Docker Service-Connection {self.name}" return f"Docker Service-Connection {self.name}"
@ -237,10 +234,8 @@ class KubernetesServiceConnection(OutpostServiceConnection):
) )
@property @property
def form(self) -> Type[ModelForm]: def component(self) -> str:
from authentik.outposts.forms import KubernetesServiceConnectionForm return "ak-service-connection-kubernetes-form"
return KubernetesServiceConnectionForm
def __str__(self) -> str: def __str__(self) -> str:
return f"Kubernetes Service-Connection {self.name}" return f"Kubernetes Service-Connection {self.name}"

View File

@ -12,10 +12,6 @@ export class AdminURLManager {
return `/administration/property-mappings/${rest}`; return `/administration/property-mappings/${rest}`;
} }
static outpostServiceConnections(rest: string): string {
return `/administration/outpost_service_connections/${rest}`;
}
static stages(rest: string): string { static stages(rest: string): string {
return `/administration/stages/${rest}`; 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/ModalButton";
import "../../elements/buttons/Dropdown"; import "../../elements/buttons/Dropdown";
import "../../elements/forms/DeleteForm"; import "../../elements/forms/DeleteForm";
import "../../elements/forms/ModalForm";
import "./ServiceConnectionKubernetesForm";
import "./ServiceConnectionDockerForm";
import { until } from "lit-html/directives/until"; import { until } from "lit-html/directives/until";
import { PAGE_SIZE } from "../../constants"; import { PAGE_SIZE } from "../../constants";
import { OutpostsApi, ServiceConnection } from "authentik-api"; import { OutpostsApi, ServiceConnection } from "authentik-api";
import { DEFAULT_CONFIG } from "../../api/Config"; 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") @customElement("ak-outpost-service-connection-list")
export class OutpostServiceConnectionListPage extends TablePage<ServiceConnection> { 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")}`; return html`<i class="fas fa-times pf-m-danger"></i> ${gettext("Unhealthy")}`;
}), html`<ak-spinner></ak-spinner>`)}`, }), html`<ak-spinner></ak-spinner>`)}`,
html` html`
<ak-modal-button href="${AdminURLManager.outpostServiceConnections(`${item.pk}/update/`)}"> <ak-forms-modal>
<ak-spinner-button slot="trigger" class="pf-m-secondary"> <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")} ${gettext("Edit")}
</ak-spinner-button> </button>
<div slot="modal"></div> </ak-forms-modal>
</ak-modal-button>
<ak-forms-delete <ak-forms-delete
.obj=${item} .obj=${item}
objectLabel=${gettext("Outpost Service-connection")} objectLabel=${gettext("Outpost Service-connection")}
@ -100,12 +120,22 @@ export class OutpostServiceConnectionListPage extends TablePage<ServiceConnectio
${until(new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypes({}).then((types) => { ${until(new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypes({}).then((types) => {
return types.map((type) => { return types.map((type) => {
return html`<li> return html`<li>
<ak-modal-button href="${type.link}"> <ak-forms-modal>
<button slot="trigger" class="pf-c-dropdown__menu-item">${type.name}<br> <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> <small>${type.description}</small>
</button> </button>
<div slot="modal"></div> </ak-forms-modal>
</ak-modal-button>
</li>`; </li>`;
}); });
}), html`<ak-spinner></ak-spinner>`)} }), html`<ak-spinner></ak-spinner>`)}

View File

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