wip: rename to authentik (#361)
* root: initial rename * web: rename custom element prefix * root: rename external functions with pb_ prefix * root: fix formatting * root: replace domain with goauthentik.io * proxy: update path * root: rename remaining prefixes * flows: rename file extension * root: pbadmin -> akadmin * docs: fix image filenames * lifecycle: ignore migration files * ci: copy default config from current source before loading last tagged * *: new sentry dsn * tests: fix missing python3.9-dev package * root: add additional migrations for service accounts created by outposts * core: mark system-created service accounts with attribute * policies/expression: fix pb_ replacement not working * web: fix last linting errors, add lit-analyse * policies/expressions: fix lint errors * web: fix sidebar display on screens where not all items fit * proxy: attempt to fix proxy pipeline * proxy: use go env GOPATH to get gopath * lib: fix user_default naming inconsistency * docs: add upgrade docs * docs: update screenshots to use authentik * admin: fix create button on empty-state of outpost * web: fix modal submit not refreshing SiteShell and Table * web: fix height of app-card and height of generic icon * web: fix rendering of subtext * admin: fix version check error not being caught * web: fix worker count not being shown * docs: update screenshots * root: new icon * web: fix lint error * admin: fix linting error * root: migrate coverage config to pyproject
This commit is contained in:
0
authentik/outposts/__init__.py
Normal file
0
authentik/outposts/__init__.py
Normal file
66
authentik/outposts/api.py
Normal file
66
authentik/outposts/api.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Outpost API Views"""
|
||||
from rest_framework.serializers import JSONField, ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.outposts.models import (
|
||||
DockerServiceConnection,
|
||||
KubernetesServiceConnection,
|
||||
Outpost,
|
||||
)
|
||||
|
||||
|
||||
class OutpostSerializer(ModelSerializer):
|
||||
"""Outpost Serializer"""
|
||||
|
||||
_config = JSONField()
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Outpost
|
||||
fields = ["pk", "name", "providers", "service_connection", "_config"]
|
||||
|
||||
|
||||
class OutpostViewSet(ModelViewSet):
|
||||
"""Outpost Viewset"""
|
||||
|
||||
queryset = Outpost.objects.all()
|
||||
serializer_class = OutpostSerializer
|
||||
|
||||
|
||||
class DockerServiceConnectionSerializer(ModelSerializer):
|
||||
"""DockerServiceConnection Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = DockerServiceConnection
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"local",
|
||||
"url",
|
||||
"tls_verification",
|
||||
"tls_authentication",
|
||||
]
|
||||
|
||||
|
||||
class DockerServiceConnectionViewSet(ModelViewSet):
|
||||
"""DockerServiceConnection Viewset"""
|
||||
|
||||
queryset = DockerServiceConnection.objects.all()
|
||||
serializer_class = DockerServiceConnectionSerializer
|
||||
|
||||
|
||||
class KubernetesServiceConnectionSerializer(ModelSerializer):
|
||||
"""KubernetesServiceConnection Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = KubernetesServiceConnection
|
||||
fields = ["pk", "name", "local", "kubeconfig"]
|
||||
|
||||
|
||||
class KubernetesServiceConnectionViewSet(ModelViewSet):
|
||||
"""KubernetesServiceConnection Viewset"""
|
||||
|
||||
queryset = KubernetesServiceConnection.objects.all()
|
||||
serializer_class = KubernetesServiceConnectionSerializer
|
||||
74
authentik/outposts/apps.py
Normal file
74
authentik/outposts/apps.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""authentik outposts app config"""
|
||||
from importlib import import_module
|
||||
from os import R_OK, access
|
||||
from os.path import expanduser
|
||||
from pathlib import Path
|
||||
from socket import gethostname
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
from django.apps import AppConfig
|
||||
from django.db import ProgrammingError
|
||||
from docker.constants import DEFAULT_UNIX_SOCKET
|
||||
from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME
|
||||
from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION
|
||||
from structlog import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class AuthentikOutpostConfig(AppConfig):
|
||||
"""authentik outposts app config"""
|
||||
|
||||
name = "authentik.outposts"
|
||||
label = "authentik_outposts"
|
||||
mountpoint = "outposts/"
|
||||
verbose_name = "authentik Outpost"
|
||||
|
||||
def ready(self):
|
||||
import_module("authentik.outposts.signals")
|
||||
try:
|
||||
AuthentikOutpostConfig.init_local_connection()
|
||||
except ProgrammingError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def init_local_connection():
|
||||
"""Check if local kubernetes or docker connections should be created"""
|
||||
from authentik.outposts.models import (
|
||||
KubernetesServiceConnection,
|
||||
DockerServiceConnection,
|
||||
)
|
||||
|
||||
if Path(SERVICE_TOKEN_FILENAME).exists():
|
||||
LOGGER.debug("Detected in-cluster Kubernetes Config")
|
||||
if not KubernetesServiceConnection.objects.filter(local=True).exists():
|
||||
LOGGER.debug("Created Service Connection for in-cluster")
|
||||
KubernetesServiceConnection.objects.create(
|
||||
name="Local Kubernetes Cluster", local=True, kubeconfig={}
|
||||
)
|
||||
# For development, check for the existence of a kubeconfig file
|
||||
kubeconfig_path = expanduser(KUBE_CONFIG_DEFAULT_LOCATION)
|
||||
if Path(kubeconfig_path).exists():
|
||||
LOGGER.debug("Detected kubeconfig")
|
||||
kubeconfig_local_name = f"k8s-{gethostname()}"
|
||||
if not KubernetesServiceConnection.objects.filter(
|
||||
name=kubeconfig_local_name
|
||||
).exists():
|
||||
LOGGER.debug("Creating kubeconfig Service Connection")
|
||||
with open(kubeconfig_path, "r") as _kubeconfig:
|
||||
KubernetesServiceConnection.objects.create(
|
||||
name=kubeconfig_local_name,
|
||||
kubeconfig=yaml.safe_load(_kubeconfig),
|
||||
)
|
||||
unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path
|
||||
socket = Path(unix_socket_path)
|
||||
if socket.exists() and access(socket, R_OK):
|
||||
LOGGER.debug("Detected local docker socket")
|
||||
if not DockerServiceConnection.objects.filter(local=True).exists():
|
||||
LOGGER.debug("Created Service Connection for docker")
|
||||
DockerServiceConnection.objects.create(
|
||||
name="Local Docker connection",
|
||||
local=True,
|
||||
url=unix_socket_path,
|
||||
)
|
||||
89
authentik/outposts/channels.py
Normal file
89
authentik/outposts/channels.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""Outpost websocket handler"""
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import IntEnum
|
||||
from typing import Any, Dict
|
||||
|
||||
from dacite import from_dict
|
||||
from dacite.data import Data
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.core.channels import AuthJsonConsumer
|
||||
from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class WebsocketMessageInstruction(IntEnum):
|
||||
"""Commands which can be triggered over Websocket"""
|
||||
|
||||
# Simple message used by either side when a message is acknowledged
|
||||
ACK = 0
|
||||
|
||||
# Message used by outposts to report their alive status
|
||||
HELLO = 1
|
||||
|
||||
# Message sent by us to trigger an Update
|
||||
TRIGGER_UPDATE = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebsocketMessage:
|
||||
"""Complete Websocket Message that is being sent"""
|
||||
|
||||
instruction: int
|
||||
args: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class OutpostConsumer(AuthJsonConsumer):
|
||||
"""Handler for Outposts that connect over websockets for health checks and live updates"""
|
||||
|
||||
outpost: Outpost
|
||||
|
||||
def connect(self):
|
||||
if not super().connect():
|
||||
return
|
||||
uuid = self.scope["url_route"]["kwargs"]["pk"]
|
||||
outpost = get_objects_for_user(
|
||||
self.user, "authentik_outposts.view_outpost"
|
||||
).filter(pk=uuid)
|
||||
if not outpost.exists():
|
||||
self.close()
|
||||
return
|
||||
self.accept()
|
||||
self.outpost = outpost.first()
|
||||
OutpostState(
|
||||
uid=self.channel_name, last_seen=datetime.now(), _outpost=self.outpost
|
||||
).save(timeout=OUTPOST_HELLO_INTERVAL * 1.5)
|
||||
LOGGER.debug("added channel to cache", channel_name=self.channel_name)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def disconnect(self, close_code):
|
||||
OutpostState.for_channel(self.outpost, self.channel_name).delete()
|
||||
LOGGER.debug("removed channel from cache", channel_name=self.channel_name)
|
||||
|
||||
def receive_json(self, content: Data):
|
||||
msg = from_dict(WebsocketMessage, content)
|
||||
state = OutpostState(
|
||||
uid=self.channel_name,
|
||||
last_seen=datetime.now(),
|
||||
_outpost=self.outpost,
|
||||
)
|
||||
if msg.instruction == WebsocketMessageInstruction.HELLO:
|
||||
state.version = msg.args.get("version", None)
|
||||
elif msg.instruction == WebsocketMessageInstruction.ACK:
|
||||
return
|
||||
state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5)
|
||||
|
||||
response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
|
||||
self.send_json(asdict(response))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def event_update(self, event):
|
||||
"""Event handler which is called by post_save signals, Send update instruction"""
|
||||
self.send_json(
|
||||
asdict(
|
||||
WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE)
|
||||
)
|
||||
)
|
||||
0
authentik/outposts/controllers/__init__.py
Normal file
0
authentik/outposts/controllers/__init__.py
Normal file
46
authentik/outposts/controllers/base.py
Normal file
46
authentik/outposts/controllers/base.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Base Controller"""
|
||||
from typing import Dict, List
|
||||
|
||||
from structlog import get_logger
|
||||
from structlog.testing import capture_logs
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.outposts.models import Outpost, OutpostServiceConnection
|
||||
|
||||
|
||||
class ControllerException(SentryIgnoredException):
|
||||
"""Exception raised when anything fails during controller run"""
|
||||
|
||||
|
||||
class BaseController:
|
||||
"""Base Outpost deployment controller"""
|
||||
|
||||
deployment_ports: Dict[str, int]
|
||||
|
||||
outpost: Outpost
|
||||
connection: OutpostServiceConnection
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: OutpostServiceConnection):
|
||||
self.outpost = outpost
|
||||
self.connection = connection
|
||||
self.logger = get_logger()
|
||||
self.deployment_ports = {}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def up(self):
|
||||
"""Called by scheduled task to reconcile deployment/service/etc"""
|
||||
raise NotImplementedError
|
||||
|
||||
def up_with_logs(self) -> List[str]:
|
||||
"""Call .up() but capture all log output and return it."""
|
||||
with capture_logs() as logs:
|
||||
self.up()
|
||||
return [x["event"] for x in logs]
|
||||
|
||||
def down(self):
|
||||
"""Handler to delete everything we've created"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_static_deployment(self) -> str:
|
||||
"""Return a static deployment configuration"""
|
||||
raise NotImplementedError
|
||||
160
authentik/outposts/controllers/docker.py
Normal file
160
authentik/outposts/controllers/docker.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""Docker controller"""
|
||||
from time import sleep
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from docker import DockerClient
|
||||
from docker.errors import DockerException, NotFound
|
||||
from docker.models.containers import Container
|
||||
from yaml import safe_dump
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.outposts.controllers.base import BaseController, ControllerException
|
||||
from authentik.outposts.models import (
|
||||
DockerServiceConnection,
|
||||
Outpost,
|
||||
ServiceConnectionInvalid,
|
||||
)
|
||||
|
||||
|
||||
class DockerController(BaseController):
|
||||
"""Docker controller"""
|
||||
|
||||
client: DockerClient
|
||||
|
||||
container: Container
|
||||
connection: DockerServiceConnection
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: DockerServiceConnection) -> None:
|
||||
super().__init__(outpost, connection)
|
||||
try:
|
||||
self.client = connection.client()
|
||||
except ServiceConnectionInvalid as exc:
|
||||
raise ControllerException from exc
|
||||
|
||||
def _get_labels(self) -> Dict[str, str]:
|
||||
return {}
|
||||
|
||||
def _get_env(self) -> Dict[str, str]:
|
||||
return {
|
||||
"AUTHENTIK_HOST": self.outpost.config.authentik_host,
|
||||
"AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure),
|
||||
"AUTHENTIK_TOKEN": self.outpost.token.key,
|
||||
}
|
||||
|
||||
def _comp_env(self, container: Container) -> bool:
|
||||
"""Check if container's env is equal to what we would set. Return true if container needs
|
||||
to be rebuilt."""
|
||||
should_be = self._get_env()
|
||||
container_env = container.attrs.get("Config", {}).get("Env", {})
|
||||
for key, expected_value in should_be.items():
|
||||
if key not in container_env:
|
||||
continue
|
||||
if container_env[key] != expected_value:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_container(self) -> Tuple[Container, bool]:
|
||||
container_name = f"authentik-proxy-{self.outpost.uuid.hex}"
|
||||
try:
|
||||
return self.client.containers.get(container_name), False
|
||||
except NotFound:
|
||||
self.logger.info("Container does not exist, creating")
|
||||
image_prefix = CONFIG.y("outposts.docker_image_base")
|
||||
image_name = f"{image_prefix}-{self.outpost.type}:{__version__}"
|
||||
self.client.images.pull(image_name)
|
||||
container_args = {
|
||||
"image": image_name,
|
||||
"name": f"authentik-proxy-{self.outpost.uuid.hex}",
|
||||
"detach": True,
|
||||
"ports": {x: x for _, x in self.deployment_ports.items()},
|
||||
"environment": self._get_env(),
|
||||
"labels": self._get_labels(),
|
||||
}
|
||||
if settings.TEST:
|
||||
del container_args["ports"]
|
||||
container_args["network_mode"] = "host"
|
||||
return (
|
||||
self.client.containers.create(**container_args),
|
||||
True,
|
||||
)
|
||||
|
||||
def up(self):
|
||||
try:
|
||||
container, has_been_created = self._get_container()
|
||||
# Check if the container is out of date, delete it and retry
|
||||
if len(container.image.tags) > 0:
|
||||
tag: str = container.image.tags[0]
|
||||
_, _, version = tag.partition(":")
|
||||
if version != __version__:
|
||||
self.logger.info(
|
||||
"Container has mismatched version, re-creating...",
|
||||
has=version,
|
||||
should=__version__,
|
||||
)
|
||||
container.kill()
|
||||
container.remove(force=True)
|
||||
return self.up()
|
||||
# Check that container values match our values
|
||||
if self._comp_env(container):
|
||||
self.logger.info("Container has outdated config, re-creating...")
|
||||
container.kill()
|
||||
container.remove(force=True)
|
||||
return self.up()
|
||||
# Check that container is healthy
|
||||
if (
|
||||
container.status == "running"
|
||||
and container.attrs.get("State", {}).get("Health", {}).get("Status", "")
|
||||
!= "healthy"
|
||||
):
|
||||
# At this point we know the config is correct, but the container isn't healthy,
|
||||
# so we just restart it with the same config
|
||||
if has_been_created:
|
||||
# Since we've just created the container, give it some time to start.
|
||||
# If its still not up by then, restart it
|
||||
self.logger.info(
|
||||
"Container is unhealthy and new, giving it time to boot."
|
||||
)
|
||||
sleep(60)
|
||||
self.logger.info("Container is unhealthy, restarting...")
|
||||
container.restart()
|
||||
return None
|
||||
# Check that container is running
|
||||
if container.status != "running":
|
||||
self.logger.info("Container is not running, restarting...")
|
||||
container.start()
|
||||
return None
|
||||
return None
|
||||
except DockerException as exc:
|
||||
raise ControllerException from exc
|
||||
|
||||
def down(self):
|
||||
try:
|
||||
container, _ = self._get_container()
|
||||
container.kill()
|
||||
container.remove()
|
||||
except DockerException as exc:
|
||||
raise ControllerException from exc
|
||||
|
||||
def get_static_deployment(self) -> str:
|
||||
"""Generate docker-compose yaml for proxy, version 3.5"""
|
||||
ports = [f"{x}:{x}" for _, x in self.deployment_ports.items()]
|
||||
image_prefix = CONFIG.y("outposts.docker_image_base")
|
||||
compose = {
|
||||
"version": "3.5",
|
||||
"services": {
|
||||
f"authentik_{self.outpost.type}": {
|
||||
"image": f"{image_prefix}-{self.outpost.type}:{__version__}",
|
||||
"ports": ports,
|
||||
"environment": {
|
||||
"AUTHENTIK_HOST": self.outpost.config.authentik_host,
|
||||
"AUTHENTIK_INSECURE": str(
|
||||
self.outpost.config.authentik_host_insecure
|
||||
),
|
||||
"AUTHENTIK_TOKEN": self.outpost.token.key,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return safe_dump(compose, default_flow_style=False)
|
||||
0
authentik/outposts/controllers/k8s/__init__.py
Normal file
0
authentik/outposts/controllers/k8s/__init__.py
Normal file
126
authentik/outposts/controllers/k8s/base.py
Normal file
126
authentik/outposts/controllers/k8s/base.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Base Kubernetes Reconciler"""
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||
|
||||
from kubernetes.client import V1ObjectMeta
|
||||
from kubernetes.client.rest import ApiException
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ReconcileTrigger(SentryIgnoredException):
|
||||
"""Base trigger raised by child classes to notify us"""
|
||||
|
||||
|
||||
class NeedsRecreate(ReconcileTrigger):
|
||||
"""Exception to trigger a complete recreate of the Kubernetes Object"""
|
||||
|
||||
|
||||
class NeedsUpdate(ReconcileTrigger):
|
||||
"""Exception to trigger an update to the Kubernetes Object"""
|
||||
|
||||
|
||||
class KubernetesObjectReconciler(Generic[T]):
|
||||
"""Base Kubernetes Reconciler, handles the basic logic."""
|
||||
|
||||
controller: "KubernetesController"
|
||||
|
||||
def __init__(self, controller: "KubernetesController"):
|
||||
self.controller = controller
|
||||
self.namespace = controller.outpost.config.kubernetes_namespace
|
||||
self.logger = get_logger()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the name of the object this reconciler manages"""
|
||||
raise NotImplementedError
|
||||
|
||||
def up(self):
|
||||
"""Create object if it doesn't exist, update if needed or recreate if needed."""
|
||||
current = None
|
||||
reference = self.get_reference_object()
|
||||
try:
|
||||
try:
|
||||
current = self.retrieve()
|
||||
except ApiException as exc:
|
||||
if exc.status == 404:
|
||||
self.logger.debug("Failed to get current, triggering recreate")
|
||||
raise NeedsRecreate from exc
|
||||
self.logger.debug("Other unhandled error", exc=exc)
|
||||
raise exc
|
||||
else:
|
||||
self.logger.debug("Got current, running reconcile")
|
||||
self.reconcile(current, reference)
|
||||
except NeedsRecreate:
|
||||
self.logger.debug("Recreate requested")
|
||||
if current:
|
||||
self.logger.debug("Deleted old")
|
||||
self.delete(current)
|
||||
else:
|
||||
self.logger.debug("No old found, creating")
|
||||
self.logger.debug("Created")
|
||||
self.create(reference)
|
||||
except NeedsUpdate:
|
||||
self.logger.debug("Updating")
|
||||
self.update(current, reference)
|
||||
else:
|
||||
self.logger.debug("Nothing to do...")
|
||||
|
||||
def down(self):
|
||||
"""Delete object if found"""
|
||||
try:
|
||||
current = self.retrieve()
|
||||
self.delete(current)
|
||||
self.logger.debug("Removing")
|
||||
except ApiException as exc:
|
||||
if exc.status == 404:
|
||||
self.logger.debug("Failed to get current, assuming non-existant")
|
||||
return
|
||||
self.logger.debug("Other unhandled error", exc=exc)
|
||||
raise exc
|
||||
|
||||
def get_reference_object(self) -> T:
|
||||
"""Return object as it should be"""
|
||||
raise NotImplementedError
|
||||
|
||||
def reconcile(self, current: T, reference: T):
|
||||
"""Check what operations should be done, should be raised as
|
||||
ReconcileTrigger"""
|
||||
raise NotImplementedError
|
||||
|
||||
def create(self, reference: T):
|
||||
"""API Wrapper to create object"""
|
||||
raise NotImplementedError
|
||||
|
||||
def retrieve(self) -> T:
|
||||
"""API Wrapper to retrive object"""
|
||||
raise NotImplementedError
|
||||
|
||||
def delete(self, reference: T):
|
||||
"""API Wrapper to delete object"""
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, current: T, reference: T):
|
||||
"""API Wrapper to update object"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_object_meta(self, **kwargs) -> V1ObjectMeta:
|
||||
"""Get common object metadata"""
|
||||
return V1ObjectMeta(
|
||||
namespace=self.namespace,
|
||||
labels={
|
||||
"app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}",
|
||||
"app.kubernetes.io/instance": self.controller.outpost.name,
|
||||
"app.kubernetes.io/version": __version__,
|
||||
"app.kubernetes.io/managed-by": "goauthentik.io",
|
||||
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
134
authentik/outposts/controllers/k8s/deployment.py
Normal file
134
authentik/outposts/controllers/k8s/deployment.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""Kubernetes Deployment Reconciler"""
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
from kubernetes.client import (
|
||||
AppsV1Api,
|
||||
V1Container,
|
||||
V1ContainerPort,
|
||||
V1Deployment,
|
||||
V1DeploymentSpec,
|
||||
V1EnvVar,
|
||||
V1EnvVarSource,
|
||||
V1LabelSelector,
|
||||
V1ObjectMeta,
|
||||
V1PodSpec,
|
||||
V1PodTemplateSpec,
|
||||
V1SecretKeySelector,
|
||||
)
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.outposts.controllers.k8s.base import (
|
||||
KubernetesObjectReconciler,
|
||||
NeedsUpdate,
|
||||
)
|
||||
from authentik.outposts.models import Outpost
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
|
||||
|
||||
class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
||||
"""Kubernetes Deployment Reconciler"""
|
||||
|
||||
outpost: Outpost
|
||||
|
||||
def __init__(self, controller: "KubernetesController") -> None:
|
||||
super().__init__(controller)
|
||||
self.api = AppsV1Api(controller.client)
|
||||
self.outpost = self.controller.outpost
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
|
||||
|
||||
def reconcile(self, current: V1Deployment, reference: V1Deployment):
|
||||
if current.spec.replicas != reference.spec.replicas:
|
||||
raise NeedsUpdate()
|
||||
if (
|
||||
current.spec.template.spec.containers[0].image
|
||||
!= reference.spec.template.spec.containers[0].image
|
||||
):
|
||||
raise NeedsUpdate()
|
||||
|
||||
def get_pod_meta(self) -> Dict[str, str]:
|
||||
"""Get common object metadata"""
|
||||
return {
|
||||
"app.kubernetes.io/name": "authentik-outpost",
|
||||
"app.kubernetes.io/managed-by": "goauthentik.io",
|
||||
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
|
||||
}
|
||||
|
||||
def get_reference_object(self) -> V1Deployment:
|
||||
"""Get deployment object for outpost"""
|
||||
# Generate V1ContainerPort objects
|
||||
container_ports = []
|
||||
for port_name, port in self.controller.deployment_ports.items():
|
||||
container_ports.append(V1ContainerPort(container_port=port, name=port_name))
|
||||
meta = self.get_object_meta(name=self.name)
|
||||
secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api"
|
||||
image_prefix = CONFIG.y("outposts.docker_image_base")
|
||||
return V1Deployment(
|
||||
metadata=meta,
|
||||
spec=V1DeploymentSpec(
|
||||
replicas=self.outpost.config.kubernetes_replicas,
|
||||
selector=V1LabelSelector(match_labels=self.get_pod_meta()),
|
||||
template=V1PodTemplateSpec(
|
||||
metadata=V1ObjectMeta(labels=self.get_pod_meta()),
|
||||
spec=V1PodSpec(
|
||||
containers=[
|
||||
V1Container(
|
||||
name=str(self.outpost.type),
|
||||
image=f"{image_prefix}-{self.outpost.type}:{__version__}",
|
||||
ports=container_ports,
|
||||
env=[
|
||||
V1EnvVar(
|
||||
name="AUTHENTIK_HOST",
|
||||
value_from=V1EnvVarSource(
|
||||
secret_key_ref=V1SecretKeySelector(
|
||||
name=secret_name,
|
||||
key="authentik_host",
|
||||
)
|
||||
),
|
||||
),
|
||||
V1EnvVar(
|
||||
name="AUTHENTIK_TOKEN",
|
||||
value_from=V1EnvVarSource(
|
||||
secret_key_ref=V1SecretKeySelector(
|
||||
name=secret_name,
|
||||
key="token",
|
||||
)
|
||||
),
|
||||
),
|
||||
V1EnvVar(
|
||||
name="AUTHENTIK_INSECURE",
|
||||
value_from=V1EnvVarSource(
|
||||
secret_key_ref=V1SecretKeySelector(
|
||||
name=secret_name,
|
||||
key="authentik_host_insecure",
|
||||
)
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def create(self, reference: V1Deployment):
|
||||
return self.api.create_namespaced_deployment(self.namespace, reference)
|
||||
|
||||
def delete(self, reference: V1Deployment):
|
||||
return self.api.delete_namespaced_deployment(
|
||||
reference.metadata.name, self.namespace
|
||||
)
|
||||
|
||||
def retrieve(self) -> V1Deployment:
|
||||
return self.api.read_namespaced_deployment(self.name, self.namespace)
|
||||
|
||||
def update(self, current: V1Deployment, reference: V1Deployment):
|
||||
return self.api.patch_namespaced_deployment(
|
||||
current.metadata.name, self.namespace, reference
|
||||
)
|
||||
67
authentik/outposts/controllers/k8s/secret.py
Normal file
67
authentik/outposts/controllers/k8s/secret.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Kubernetes Secret Reconciler"""
|
||||
from base64 import b64encode
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kubernetes.client import CoreV1Api, V1Secret
|
||||
|
||||
from authentik.outposts.controllers.k8s.base import (
|
||||
KubernetesObjectReconciler,
|
||||
NeedsUpdate,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
|
||||
|
||||
def b64string(source: str) -> str:
|
||||
"""Base64 Encode string"""
|
||||
return b64encode(source.encode()).decode("utf-8")
|
||||
|
||||
|
||||
class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
|
||||
"""Kubernetes Secret Reconciler"""
|
||||
|
||||
def __init__(self, controller: "KubernetesController") -> None:
|
||||
super().__init__(controller)
|
||||
self.api = CoreV1Api(controller.client)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api"
|
||||
|
||||
def reconcile(self, current: V1Secret, reference: V1Secret):
|
||||
for key in reference.data.keys():
|
||||
if current.data[key] != reference.data[key]:
|
||||
raise NeedsUpdate()
|
||||
|
||||
def get_reference_object(self) -> V1Secret:
|
||||
"""Get deployment object for outpost"""
|
||||
meta = self.get_object_meta(name=self.name)
|
||||
return V1Secret(
|
||||
metadata=meta,
|
||||
data={
|
||||
"authentik_host": b64string(
|
||||
self.controller.outpost.config.authentik_host
|
||||
),
|
||||
"authentik_host_insecure": b64string(
|
||||
str(self.controller.outpost.config.authentik_host_insecure)
|
||||
),
|
||||
"token": b64string(self.controller.outpost.token.token_uuid.hex),
|
||||
},
|
||||
)
|
||||
|
||||
def create(self, reference: V1Secret):
|
||||
return self.api.create_namespaced_secret(self.namespace, reference)
|
||||
|
||||
def delete(self, reference: V1Secret):
|
||||
return self.api.delete_namespaced_secret(
|
||||
reference.metadata.name, self.namespace
|
||||
)
|
||||
|
||||
def retrieve(self) -> V1Secret:
|
||||
return self.api.read_namespaced_secret(self.name, self.namespace)
|
||||
|
||||
def update(self, current: V1Secret, reference: V1Secret):
|
||||
return self.api.patch_namespaced_secret(
|
||||
current.metadata.name, self.namespace, reference
|
||||
)
|
||||
60
authentik/outposts/controllers/k8s/service.py
Normal file
60
authentik/outposts/controllers/k8s/service.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Kubernetes Service Reconciler"""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec
|
||||
|
||||
from authentik.outposts.controllers.k8s.base import (
|
||||
KubernetesObjectReconciler,
|
||||
NeedsUpdate,
|
||||
)
|
||||
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
|
||||
|
||||
class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
|
||||
"""Kubernetes Service Reconciler"""
|
||||
|
||||
def __init__(self, controller: "KubernetesController") -> None:
|
||||
super().__init__(controller)
|
||||
self.api = CoreV1Api(controller.client)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
|
||||
|
||||
def reconcile(self, current: V1Service, reference: V1Service):
|
||||
if len(current.spec.ports) != len(reference.spec.ports):
|
||||
raise NeedsUpdate()
|
||||
for port in reference.spec.ports:
|
||||
if port not in current.spec.ports:
|
||||
raise NeedsUpdate()
|
||||
|
||||
def get_reference_object(self) -> V1Service:
|
||||
"""Get deployment object for outpost"""
|
||||
meta = self.get_object_meta(name=self.name)
|
||||
ports = []
|
||||
for port_name, port in self.controller.deployment_ports.items():
|
||||
ports.append(V1ServicePort(name=port_name, port=port))
|
||||
selector_labels = DeploymentReconciler(self.controller).get_pod_meta()
|
||||
return V1Service(
|
||||
metadata=meta,
|
||||
spec=V1ServiceSpec(ports=ports, selector=selector_labels, type="ClusterIP"),
|
||||
)
|
||||
|
||||
def create(self, reference: V1Service):
|
||||
return self.api.create_namespaced_service(self.namespace, reference)
|
||||
|
||||
def delete(self, reference: V1Service):
|
||||
return self.api.delete_namespaced_service(
|
||||
reference.metadata.name, self.namespace
|
||||
)
|
||||
|
||||
def retrieve(self) -> V1Service:
|
||||
return self.api.read_namespaced_service(self.name, self.namespace)
|
||||
|
||||
def update(self, current: V1Service, reference: V1Service):
|
||||
return self.api.patch_namespaced_service(
|
||||
current.metadata.name, self.namespace, reference
|
||||
)
|
||||
81
authentik/outposts/controllers/kubernetes.py
Normal file
81
authentik/outposts/controllers/kubernetes.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Kubernetes deployment controller"""
|
||||
from io import StringIO
|
||||
from typing import Dict, List, Type
|
||||
|
||||
from kubernetes.client import OpenApiException
|
||||
from kubernetes.client.api_client import ApiClient
|
||||
from structlog.testing import capture_logs
|
||||
from yaml import dump_all
|
||||
|
||||
from authentik.outposts.controllers.base import BaseController, ControllerException
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
||||
from authentik.outposts.controllers.k8s.secret import SecretReconciler
|
||||
from authentik.outposts.controllers.k8s.service import ServiceReconciler
|
||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
|
||||
|
||||
|
||||
class KubernetesController(BaseController):
|
||||
"""Manage deployment of outpost in kubernetes"""
|
||||
|
||||
reconcilers: Dict[str, Type[KubernetesObjectReconciler]]
|
||||
reconcile_order: List[str]
|
||||
|
||||
client: ApiClient
|
||||
connection: KubernetesServiceConnection
|
||||
|
||||
def __init__(
|
||||
self, outpost: Outpost, connection: KubernetesServiceConnection
|
||||
) -> None:
|
||||
super().__init__(outpost, connection)
|
||||
self.client = connection.client()
|
||||
self.reconcilers = {
|
||||
"secret": SecretReconciler,
|
||||
"deployment": DeploymentReconciler,
|
||||
"service": ServiceReconciler,
|
||||
}
|
||||
self.reconcile_order = ["secret", "deployment", "service"]
|
||||
|
||||
def up(self):
|
||||
try:
|
||||
for reconcile_key in self.reconcile_order:
|
||||
reconciler = self.reconcilers[reconcile_key](self)
|
||||
reconciler.up()
|
||||
|
||||
except OpenApiException as exc:
|
||||
raise ControllerException from exc
|
||||
|
||||
def up_with_logs(self) -> List[str]:
|
||||
try:
|
||||
all_logs = []
|
||||
for reconcile_key in self.reconcile_order:
|
||||
with capture_logs() as logs:
|
||||
reconciler = self.reconcilers[reconcile_key](self)
|
||||
reconciler.up()
|
||||
all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs]
|
||||
return all_logs
|
||||
except OpenApiException as exc:
|
||||
raise ControllerException from exc
|
||||
|
||||
def down(self):
|
||||
try:
|
||||
for reconcile_key in self.reconcile_order:
|
||||
reconciler = self.reconcilers[reconcile_key](self)
|
||||
reconciler.down()
|
||||
|
||||
except OpenApiException as exc:
|
||||
raise ControllerException from exc
|
||||
|
||||
def get_static_deployment(self) -> str:
|
||||
documents = []
|
||||
for reconcile_key in self.reconcile_order:
|
||||
reconciler = self.reconcilers[reconcile_key](self)
|
||||
documents.append(reconciler.get_reference_object().to_dict())
|
||||
|
||||
with StringIO() as _str:
|
||||
dump_all(
|
||||
documents,
|
||||
stream=_str,
|
||||
default_flow_style=False,
|
||||
)
|
||||
return _str.getvalue()
|
||||
56
authentik/outposts/docker_tls.py
Normal file
56
authentik/outposts/docker_tls.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Create Docker TLSConfig from CertificateKeyPair"""
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
from typing import Optional
|
||||
|
||||
from docker.tls import TLSConfig
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
|
||||
|
||||
class DockerInlineTLS:
|
||||
"""Create Docker TLSConfig from CertificateKeyPair"""
|
||||
|
||||
verification_kp: Optional[CertificateKeyPair]
|
||||
authentication_kp: Optional[CertificateKeyPair]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verification_kp: Optional[CertificateKeyPair],
|
||||
authentication_kp: Optional[CertificateKeyPair],
|
||||
) -> None:
|
||||
self.verification_kp = verification_kp
|
||||
self.authentication_kp = authentication_kp
|
||||
|
||||
def write_file(self, name: str, contents: str) -> str:
|
||||
"""Wrapper for mkstemp that uses fdopen"""
|
||||
path = Path(gettempdir(), name)
|
||||
with open(path, "w") as _file:
|
||||
_file.write(contents)
|
||||
return str(path)
|
||||
|
||||
def write(self) -> TLSConfig:
|
||||
"""Create TLSConfig with Certificate Keypairs"""
|
||||
# So yes, this is quite ugly. But sadly, there is no clean way to pass
|
||||
# docker-py (which is using requests (which is using urllib3)) a certificate
|
||||
# for verification or authentication as string.
|
||||
# Because we run in docker, and our tmpfs is isolated to us, we can just
|
||||
# write out the certificates and keys to files and use their paths
|
||||
config_args = {}
|
||||
if self.verification_kp:
|
||||
ca_cert_path = self.write_file(
|
||||
f"{self.verification_kp.pk.hex}-cert.pem",
|
||||
self.verification_kp.certificate_data,
|
||||
)
|
||||
config_args["ca_cert"] = ca_cert_path
|
||||
if self.authentication_kp:
|
||||
auth_cert_path = self.write_file(
|
||||
f"{self.authentication_kp.pk.hex}-cert.pem",
|
||||
self.authentication_kp.certificate_data,
|
||||
)
|
||||
auth_key_path = self.write_file(
|
||||
f"{self.authentication_kp.pk.hex}-key.pem",
|
||||
self.authentication_kp.key_data,
|
||||
)
|
||||
config_args["client_cert"] = (auth_cert_path, auth_key_path)
|
||||
return TLSConfig(**config_args)
|
||||
88
authentik/outposts/forms.py
Normal file
88
authentik/outposts/forms.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""Outpost forms"""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.admin.fields import CodeMirrorWidget, YAMLField
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.outposts.models import (
|
||||
DockerServiceConnection,
|
||||
KubernetesServiceConnection,
|
||||
Outpost,
|
||||
OutpostServiceConnection,
|
||||
)
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
|
||||
|
||||
class OutpostForm(forms.ModelForm):
|
||||
"""Outpost Form"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["providers"].queryset = ProxyProvider.objects.all()
|
||||
self.fields[
|
||||
"service_connection"
|
||||
].queryset = OutpostServiceConnection.objects.select_subclasses()
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Outpost
|
||||
fields = [
|
||||
"name",
|
||||
"type",
|
||||
"service_connection",
|
||||
"providers",
|
||||
"_config",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"_config": CodeMirrorWidget,
|
||||
}
|
||||
field_classes = {
|
||||
"_config": YAMLField,
|
||||
}
|
||||
labels = {"_config": _("Configuration")}
|
||||
|
||||
|
||||
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"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = KubernetesServiceConnection
|
||||
fields = [
|
||||
"name",
|
||||
"local",
|
||||
"kubeconfig",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput,
|
||||
"kubeconfig": CodeMirrorWidget,
|
||||
}
|
||||
field_classes = {
|
||||
"kubeconfig": YAMLField,
|
||||
}
|
||||
40
authentik/outposts/migrations/0001_initial.py
Normal file
40
authentik/outposts/migrations/0001_initial.py
Normal file
@ -0,0 +1,40 @@
|
||||
# Generated by Django 3.1 on 2020-08-25 20:45
|
||||
|
||||
import uuid
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0008_auto_20200824_1532"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Outpost",
|
||||
fields=[
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
(
|
||||
"channels",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(), size=None
|
||||
),
|
||||
),
|
||||
("providers", models.ManyToManyField(to="authentik_core.Provider")),
|
||||
],
|
||||
),
|
||||
]
|
||||
27
authentik/outposts/migrations/0002_auto_20200826_1306.py
Normal file
27
authentik/outposts/migrations/0002_auto_20200826_1306.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.1 on 2020-08-26 13:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import authentik.outposts.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="outpost",
|
||||
name="_config",
|
||||
field=models.JSONField(
|
||||
default=authentik.outposts.models.default_outpost_config
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="outpost",
|
||||
name="type",
|
||||
field=models.TextField(choices=[("proxy", "Proxy")], default="proxy"),
|
||||
),
|
||||
]
|
||||
34
authentik/outposts/migrations/0003_auto_20200827_2108.py
Normal file
34
authentik/outposts/migrations/0003_auto_20200827_2108.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.1 on 2020-08-27 21:08
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0002_auto_20200826_1306"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="outpost",
|
||||
name="deployment_type",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("docker_compose", "Docker Compose"),
|
||||
("kubernetes", "Kubernetes"),
|
||||
("custom", "Custom"),
|
||||
],
|
||||
default="custom",
|
||||
help_text="Select between authentik-managed deployment types or a custom deployment.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="outpost",
|
||||
name="channels",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(), default=list, size=None
|
||||
),
|
||||
),
|
||||
]
|
||||
22
authentik/outposts/migrations/0004_auto_20200830_1056.py
Normal file
22
authentik/outposts/migrations/0004_auto_20200830_1056.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.1 on 2020-08-30 10:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0003_auto_20200827_2108"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="outpost",
|
||||
name="deployment_type",
|
||||
field=models.TextField(
|
||||
choices=[("kubernetes", "Kubernetes"), ("custom", "Custom")],
|
||||
default="custom",
|
||||
help_text="Select between authentik-managed deployment types or a custom deployment.",
|
||||
),
|
||||
),
|
||||
]
|
||||
22
authentik/outposts/migrations/0005_auto_20200909_1733.py
Normal file
22
authentik/outposts/migrations/0005_auto_20200909_1733.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-09 17:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0004_auto_20200830_1056"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="outpost",
|
||||
name="deployment_type",
|
||||
field=models.TextField(
|
||||
choices=[("custom", "Custom")],
|
||||
default="custom",
|
||||
help_text="Select between authentik-managed deployment types or a custom deployment.",
|
||||
),
|
||||
),
|
||||
]
|
||||
25
authentik/outposts/migrations/0006_auto_20201003_2239.py
Normal file
25
authentik/outposts/migrations/0006_auto_20201003_2239.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Generated by Django 3.1.2 on 2020-10-03 22:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0005_auto_20200909_1733"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="outpost",
|
||||
name="deployment_type",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("docker", "Docker"),
|
||||
("custom", "Custom"),
|
||||
],
|
||||
default="custom",
|
||||
help_text="Select between authentik-managed deployment types or a custom deployment.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.1.2 on 2020-10-14 08:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0006_auto_20201003_2239"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="outpost",
|
||||
name="channels",
|
||||
),
|
||||
]
|
||||
26
authentik/outposts/migrations/0008_auto_20201014_1547.py
Normal file
26
authentik/outposts/migrations/0008_auto_20201014_1547.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.1.2 on 2020-10-14 15:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0007_remove_outpost_channels"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="outpost",
|
||||
name="deployment_type",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("kubernetes", "Kubernetes"),
|
||||
("docker", "Docker"),
|
||||
("custom", "Custom"),
|
||||
],
|
||||
default="custom",
|
||||
help_text="Select between authentik-managed deployment types or a custom deployment.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,36 @@
|
||||
# Generated by Django 3.1.2 on 2020-10-17 14:26
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
Token = apps.get_model("authentik_core", "Token")
|
||||
from authentik.outposts.models import Outpost
|
||||
|
||||
for outpost in (
|
||||
Outpost.objects.using(schema_editor.connection.alias).all().only("pk")
|
||||
):
|
||||
user_identifier = outpost.user_identifier
|
||||
users = User.objects.filter(username=user_identifier)
|
||||
if not users.exists():
|
||||
continue
|
||||
tokens = Token.objects.filter(user=users.first())
|
||||
for token in tokens:
|
||||
if token.identifier != outpost.token_identifier:
|
||||
token.identifier = outpost.token_identifier
|
||||
token.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0014_auto_20201018_1158"),
|
||||
("authentik_outposts", "0008_auto_20201014_1547"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_missing_token_identifier),
|
||||
]
|
||||
168
authentik/outposts/migrations/0010_service_connection.py
Normal file
168
authentik/outposts/migrations/0010_service_connection.py
Normal file
@ -0,0 +1,168 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-04 09:11
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import authentik.lib.models
|
||||
|
||||
|
||||
def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
Outpost = apps.get_model("authentik_outposts", "Outpost")
|
||||
DockerServiceConnection = apps.get_model(
|
||||
"authentik_outposts", "DockerServiceConnection"
|
||||
)
|
||||
KubernetesServiceConnection = apps.get_model(
|
||||
"authentik_outposts", "KubernetesServiceConnection"
|
||||
)
|
||||
|
||||
docker = DockerServiceConnection.objects.filter(local=True).first()
|
||||
k8s = KubernetesServiceConnection.objects.filter(local=True).first()
|
||||
|
||||
try:
|
||||
for outpost in (
|
||||
Outpost.objects.using(db_alias).all().exclude(deployment_type="custom")
|
||||
):
|
||||
if outpost.deployment_type == "kubernetes":
|
||||
outpost.service_connection = k8s
|
||||
elif outpost.deployment_type == "docker":
|
||||
outpost.service_connection = docker
|
||||
outpost.save()
|
||||
except FieldError:
|
||||
# This is triggered during e2e tests when this function is called on an already-upgraded
|
||||
# schema
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0009_fix_missing_token_identifier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OutpostServiceConnection",
|
||||
fields=[
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
(
|
||||
"local",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DockerServiceConnection",
|
||||
fields=[
|
||||
(
|
||||
"outpostserviceconnection_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_outposts.outpostserviceconnection",
|
||||
),
|
||||
),
|
||||
("url", models.TextField()),
|
||||
("tls", models.BooleanField()),
|
||||
],
|
||||
bases=("authentik_outposts.outpostserviceconnection",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="KubernetesServiceConnection",
|
||||
fields=[
|
||||
(
|
||||
"outpostserviceconnection_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_outposts.outpostserviceconnection",
|
||||
),
|
||||
),
|
||||
("kubeconfig", models.JSONField()),
|
||||
],
|
||||
bases=("authentik_outposts.outpostserviceconnection",),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="outpost",
|
||||
name="service_connection",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Select Service-Connection authentik should use to manage this outpost. Leave empty if authentik should not handle the deployment.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_outposts.outpostserviceconnection",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_to_service_connection),
|
||||
migrations.RemoveField(
|
||||
model_name="outpost",
|
||||
name="deployment_type",
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="dockerserviceconnection",
|
||||
options={
|
||||
"verbose_name": "Docker Service-Connection",
|
||||
"verbose_name_plural": "Docker Service-Connections",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="kubernetesserviceconnection",
|
||||
options={
|
||||
"verbose_name": "Kubernetes Service-Connection",
|
||||
"verbose_name_plural": "Kubernetes Service-Connections",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="outpost",
|
||||
name="service_connection",
|
||||
field=authentik.lib.models.InheritanceForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Select Service-Connection authentik should use to manage this outpost. Leave empty if authentik should not handle the deployment.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_outposts.outpostserviceconnection",
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="outpostserviceconnection",
|
||||
options={
|
||||
"verbose_name": "Outpost Service-Connection",
|
||||
"verbose_name_plural": "Outpost Service-Connections",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="kubernetesserviceconnection",
|
||||
name="kubeconfig",
|
||||
field=models.JSONField(
|
||||
default=None,
|
||||
help_text="Paste your kubeconfig here. authentik will automatically use the currently selected context.",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
45
authentik/outposts/migrations/0011_docker_tls_auth.py
Normal file
45
authentik/outposts/migrations/0011_docker_tls_auth.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-18 21:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0002_create_self_signed_kp"),
|
||||
("authentik_outposts", "0010_service_connection"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="dockerserviceconnection",
|
||||
name="tls",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="dockerserviceconnection",
|
||||
name="tls_authentication",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Certificate/Key used for authentication. Can be left empty for no authentication.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
related_name="+",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="dockerserviceconnection",
|
||||
name="tls_verification",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="CA which the endpoint's Certificate is verified against. Can be left empty for no validation.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
related_name="+",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.1.3 on 2020-11-18 21:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_outposts", "0011_docker_tls_auth"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="outpostserviceconnection",
|
||||
name="local",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration",
|
||||
),
|
||||
),
|
||||
]
|
||||
30
authentik/outposts/migrations/0013_auto_20201203_2009.py
Normal file
30
authentik/outposts/migrations/0013_auto_20201203_2009.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-03 20:09
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
alias = schema_editor.connection.alias
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
Outpost = apps.get_model("authentik_outposts", "Outpost")
|
||||
|
||||
for outpost in Outpost.objects.using(alias).all():
|
||||
matching = User.objects.using(alias).filter(
|
||||
username=f"pb-outpost-{outpost.uuid.hex}"
|
||||
)
|
||||
if matching.exists():
|
||||
matching.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0016_auto_20201202_2234"),
|
||||
("authentik_outposts", "0012_service_connection_non_unique"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_pb_prefix_users),
|
||||
]
|
||||
0
authentik/outposts/migrations/__init__.py
Normal file
0
authentik/outposts/migrations/__init__.py
Normal file
427
authentik/outposts/models.py
Normal file
427
authentik/outposts/models.py
Normal file
@ -0,0 +1,427 @@
|
||||
"""Outpost models"""
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, Iterable, List, Optional, Type, 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.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from docker.client import DockerClient
|
||||
from docker.errors import DockerException
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from kubernetes.client import VersionApi, VersionInfo
|
||||
from kubernetes.client.api_client import ApiClient
|
||||
from kubernetes.client.configuration import Configuration
|
||||
from kubernetes.client.exceptions import OpenApiException
|
||||
from kubernetes.config.config_exception import ConfigException
|
||||
from kubernetes.config.incluster_config import load_incluster_config
|
||||
from kubernetes.config.kube_config import load_kube_config_from_dict
|
||||
from model_utils.managers import InheritanceManager
|
||||
from packaging.version import LegacyVersion, Version, parse
|
||||
from structlog import get_logger
|
||||
from urllib3.exceptions import HTTPError
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.core.models import USER_ATTRIBUTE_SA, Provider, Token, TokenIntents, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import InheritanceForeignKey
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.template import render_to_string
|
||||
from authentik.outposts.docker_tls import DockerInlineTLS
|
||||
|
||||
OUR_VERSION = parse(__version__)
|
||||
OUTPOST_HELLO_INTERVAL = 10
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class ServiceConnectionInvalid(SentryIgnoredException):
|
||||
""""Exception raised when a Service Connection has invalid parameters"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutpostConfig:
|
||||
"""Configuration an outpost uses to configure it self"""
|
||||
|
||||
authentik_host: str
|
||||
authentik_host_insecure: bool = False
|
||||
|
||||
log_level: str = CONFIG.y("log_level")
|
||||
error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled")
|
||||
error_reporting_environment: str = CONFIG.y(
|
||||
"error_reporting.environment", "customer"
|
||||
)
|
||||
|
||||
kubernetes_replicas: int = field(default=1)
|
||||
kubernetes_namespace: str = field(default="default")
|
||||
kubernetes_ingress_annotations: Dict[str, str] = field(default_factory=dict)
|
||||
kubernetes_ingress_secret_name: str = field(default="authentik-outpost")
|
||||
|
||||
|
||||
class OutpostModel(Model):
|
||||
"""Base model for providers that need more objects than just themselves"""
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model]:
|
||||
"""Return a list of all required objects"""
|
||||
return [self]
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
class OutpostType(models.TextChoices):
|
||||
"""Outpost types, currently only the reverse proxy is available"""
|
||||
|
||||
PROXY = "proxy"
|
||||
|
||||
|
||||
def default_outpost_config():
|
||||
"""Get default outpost config"""
|
||||
return asdict(OutpostConfig(authentik_host=""))
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutpostServiceConnectionState:
|
||||
"""State of an Outpost Service Connection"""
|
||||
|
||||
version: str
|
||||
healthy: bool
|
||||
|
||||
|
||||
class OutpostServiceConnection(models.Model):
|
||||
"""Connection details for an Outpost Controller, like Docker or Kubernetes"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
|
||||
name = models.TextField()
|
||||
|
||||
local = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
(
|
||||
"If enabled, use the local connection. Required Docker "
|
||||
"socket/Kubernetes Integration"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
@property
|
||||
def state(self) -> OutpostServiceConnectionState:
|
||||
"""Get state of service connection"""
|
||||
state_key = f"outpost_service_connection_{self.pk.hex}"
|
||||
state = cache.get(state_key, None)
|
||||
if not state:
|
||||
state = self._get_state()
|
||||
cache.set(state_key, state, timeout=0)
|
||||
return state
|
||||
|
||||
def _get_state(self) -> OutpostServiceConnectionState:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def form(self) -> Type[ModelForm]:
|
||||
"""Return Form class used to edit this object"""
|
||||
raise NotImplementedError
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Outpost Service-Connection")
|
||||
verbose_name_plural = _("Outpost Service-Connections")
|
||||
|
||||
|
||||
class DockerServiceConnection(OutpostServiceConnection):
|
||||
"""Service Connection to a Docker endpoint"""
|
||||
|
||||
url = models.TextField()
|
||||
tls_verification = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="+",
|
||||
on_delete=models.SET_DEFAULT,
|
||||
help_text=_(
|
||||
(
|
||||
"CA which the endpoint's Certificate is verified against. "
|
||||
"Can be left empty for no validation."
|
||||
)
|
||||
),
|
||||
)
|
||||
tls_authentication = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="+",
|
||||
on_delete=models.SET_DEFAULT,
|
||||
help_text=_(
|
||||
"Certificate/Key used for authentication. Can be left empty for no authentication."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from authentik.outposts.forms import DockerServiceConnectionForm
|
||||
|
||||
return DockerServiceConnectionForm
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Docker Service-Connection {self.name}"
|
||||
|
||||
def client(self) -> DockerClient:
|
||||
"""Get DockerClient"""
|
||||
try:
|
||||
client = None
|
||||
if self.local:
|
||||
client = DockerClient.from_env()
|
||||
else:
|
||||
client = DockerClient(
|
||||
base_url=self.url,
|
||||
tls=DockerInlineTLS(
|
||||
verification_kp=self.tls_verification,
|
||||
authentication_kp=self.tls_authentication,
|
||||
).write(),
|
||||
)
|
||||
client.containers.list()
|
||||
except DockerException as exc:
|
||||
LOGGER.error(exc)
|
||||
raise ServiceConnectionInvalid from exc
|
||||
return client
|
||||
|
||||
def _get_state(self) -> OutpostServiceConnectionState:
|
||||
try:
|
||||
client = self.client()
|
||||
return OutpostServiceConnectionState(
|
||||
version=client.info()["ServerVersion"], healthy=True
|
||||
)
|
||||
except ServiceConnectionInvalid:
|
||||
return OutpostServiceConnectionState(version="", healthy=False)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Docker Service-Connection")
|
||||
verbose_name_plural = _("Docker Service-Connections")
|
||||
|
||||
|
||||
class KubernetesServiceConnection(OutpostServiceConnection):
|
||||
"""Service Connection to a Kubernetes cluster"""
|
||||
|
||||
kubeconfig = models.JSONField(
|
||||
help_text=_(
|
||||
(
|
||||
"Paste your kubeconfig here. authentik will automatically use "
|
||||
"the currently selected context."
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from authentik.outposts.forms import KubernetesServiceConnectionForm
|
||||
|
||||
return KubernetesServiceConnectionForm
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Kubernetes Service-Connection {self.name}"
|
||||
|
||||
def _get_state(self) -> OutpostServiceConnectionState:
|
||||
try:
|
||||
client = self.client()
|
||||
api_instance = VersionApi(client)
|
||||
version: VersionInfo = api_instance.get_code()
|
||||
return OutpostServiceConnectionState(
|
||||
version=version.git_version, healthy=True
|
||||
)
|
||||
except (OpenApiException, HTTPError):
|
||||
return OutpostServiceConnectionState(version="", healthy=False)
|
||||
|
||||
def client(self) -> ApiClient:
|
||||
"""Get Kubernetes client configured from kubeconfig"""
|
||||
config = Configuration()
|
||||
try:
|
||||
if self.local:
|
||||
load_incluster_config(client_configuration=config)
|
||||
else:
|
||||
load_kube_config_from_dict(self.kubeconfig, client_configuration=config)
|
||||
return ApiClient(config)
|
||||
except ConfigException as exc:
|
||||
raise ServiceConnectionInvalid from exc
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Kubernetes Service-Connection")
|
||||
verbose_name_plural = _("Kubernetes Service-Connections")
|
||||
|
||||
|
||||
class Outpost(models.Model):
|
||||
"""Outpost instance which manages a service user and token"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
|
||||
name = models.TextField()
|
||||
|
||||
type = models.TextField(choices=OutpostType.choices, default=OutpostType.PROXY)
|
||||
service_connection = InheritanceForeignKey(
|
||||
OutpostServiceConnection,
|
||||
default=None,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
(
|
||||
"Select Service-Connection authentik should use to manage this outpost. "
|
||||
"Leave empty if authentik should not handle the deployment."
|
||||
)
|
||||
),
|
||||
on_delete=models.SET_DEFAULT,
|
||||
)
|
||||
|
||||
_config = models.JSONField(default=default_outpost_config)
|
||||
|
||||
providers = models.ManyToManyField(Provider)
|
||||
|
||||
@property
|
||||
def config(self) -> OutpostConfig:
|
||||
"""Load config as OutpostConfig object"""
|
||||
return from_dict(OutpostConfig, self._config)
|
||||
|
||||
@config.setter
|
||||
def config(self, value):
|
||||
"""Dump config into json"""
|
||||
self._config = asdict(value)
|
||||
|
||||
@property
|
||||
def state_cache_prefix(self) -> str:
|
||||
"""Key by which the outposts status is saved"""
|
||||
return f"outpost_{self.uuid.hex}_state"
|
||||
|
||||
@property
|
||||
def state(self) -> List["OutpostState"]:
|
||||
"""Get outpost's health status"""
|
||||
return OutpostState.for_outpost(self)
|
||||
|
||||
@property
|
||||
def user_identifier(self):
|
||||
"""Username for service user"""
|
||||
return f"ak-outpost-{self.uuid.hex}"
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""Get/create user with access to all required objects"""
|
||||
users = User.objects.filter(username=self.user_identifier)
|
||||
if not users.exists():
|
||||
user: User = User.objects.create(username=self.user_identifier)
|
||||
user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
else:
|
||||
user = users.first()
|
||||
# To ensure the user only has the correct permissions, we delete all of them and re-add
|
||||
# the ones the user needs
|
||||
with transaction.atomic():
|
||||
UserObjectPermission.objects.filter(user=user).delete()
|
||||
for model in self.get_required_objects():
|
||||
code_name = f"{model._meta.app_label}.view_{model._meta.model_name}"
|
||||
assign_perm(code_name, user, model)
|
||||
return user
|
||||
|
||||
@property
|
||||
def token_identifier(self) -> str:
|
||||
"""Get Token identifier"""
|
||||
return f"ak-outpost-{self.pk}-api"
|
||||
|
||||
@property
|
||||
def token(self) -> Token:
|
||||
"""Get/create token for auto-generated user"""
|
||||
token = Token.filter_not_expired(user=self.user, intent=TokenIntents.INTENT_API)
|
||||
if token.exists():
|
||||
return token.first()
|
||||
return Token.objects.create(
|
||||
user=self.user,
|
||||
identifier=self.token_identifier,
|
||||
intent=TokenIntents.INTENT_API,
|
||||
description=f"Autogenerated by authentik for Outpost {self.name}",
|
||||
expiring=False,
|
||||
)
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model]:
|
||||
"""Get an iterator of all objects the user needs read access to"""
|
||||
objects = [self]
|
||||
for provider in (
|
||||
Provider.objects.filter(outpost=self).select_related().select_subclasses()
|
||||
):
|
||||
if isinstance(provider, OutpostModel):
|
||||
objects.extend(provider.get_required_objects())
|
||||
else:
|
||||
objects.append(provider)
|
||||
return objects
|
||||
|
||||
def html_deployment_view(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal to view token and other config info"""
|
||||
return render_to_string(
|
||||
"outposts/deployment_modal.html",
|
||||
{"outpost": self, "full_url": request.build_absolute_uri("/")},
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Outpost {self.name}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutpostState:
|
||||
"""Outpost instance state, last_seen and version"""
|
||||
|
||||
uid: str
|
||||
last_seen: Optional[datetime] = field(default=None)
|
||||
version: Optional[str] = field(default=None)
|
||||
version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION)
|
||||
|
||||
_outpost: Optional[Outpost] = field(default=None)
|
||||
|
||||
@property
|
||||
def version_outdated(self) -> bool:
|
||||
"""Check if outpost version matches our version"""
|
||||
if not self.version:
|
||||
return False
|
||||
return parse(self.version) < OUR_VERSION
|
||||
|
||||
@staticmethod
|
||||
def for_outpost(outpost: Outpost) -> List["OutpostState"]:
|
||||
"""Get all states for an outpost"""
|
||||
keys = cache.keys(f"{outpost.state_cache_prefix}_*")
|
||||
states = []
|
||||
for key in keys:
|
||||
channel = key.replace(f"{outpost.state_cache_prefix}_", "")
|
||||
states.append(OutpostState.for_channel(outpost, channel))
|
||||
return states
|
||||
|
||||
@staticmethod
|
||||
def for_channel(outpost: Outpost, channel: str) -> "OutpostState":
|
||||
"""Get state for a single channel"""
|
||||
key = f"{outpost.state_cache_prefix}_{channel}"
|
||||
default_data = {"uid": channel}
|
||||
data = cache.get(key, default_data)
|
||||
if isinstance(data, str):
|
||||
cache.delete(key)
|
||||
data = default_data
|
||||
state = from_dict(OutpostState, data)
|
||||
state.uid = channel
|
||||
# pylint: disable=protected-access
|
||||
state._outpost = outpost
|
||||
return state
|
||||
|
||||
def save(self, timeout=OUTPOST_HELLO_INTERVAL):
|
||||
"""Save current state to cache"""
|
||||
full_key = f"{self._outpost.state_cache_prefix}_{self.uid}"
|
||||
return cache.set(full_key, asdict(self), timeout=timeout)
|
||||
|
||||
def delete(self):
|
||||
"""Manually delete from cache, used on channel disconnect"""
|
||||
full_key = f"{self._outpost.state_cache_prefix}_{self.uid}"
|
||||
cache.delete(full_key)
|
||||
15
authentik/outposts/settings.py
Normal file
15
authentik/outposts/settings.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""Outposts Settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"outposts_controller": {
|
||||
"task": "authentik.outposts.tasks.outpost_controller_all",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
"outposts_service_connection_check": {
|
||||
"task": "authentik.outposts.tasks.outpost_service_connection_monitor",
|
||||
"schedule": crontab(minute=0, hour="*"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
||||
36
authentik/outposts/signals.py
Normal file
36
authentik/outposts/signals.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""authentik outpost signals"""
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.outposts.models import Outpost
|
||||
from authentik.outposts.tasks import outpost_post_save, outpost_pre_delete
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@receiver(post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def post_save_update(sender, instance: Model, **_):
|
||||
"""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
|
||||
outpost_post_save.delay(class_to_path(instance.__class__), instance.pk)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Outpost)
|
||||
# pylint: disable=unused-argument
|
||||
def pre_delete_cleanup(sender, instance: Outpost, **_):
|
||||
"""Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
|
||||
instance.user.delete()
|
||||
# To ensure that deployment is cleaned up *consistently* we call the controller, and wait
|
||||
# for it to finish. We don't want to call it in this thread, as we don't have the K8s
|
||||
# credentials here
|
||||
outpost_pre_delete.delay(instance.pk.hex).get()
|
||||
165
authentik/outposts/tasks.py
Normal file
165
authentik/outposts/tasks.py
Normal file
@ -0,0 +1,165 @@
|
||||
"""outpost tasks"""
|
||||
from typing import Any
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.cache import cache
|
||||
from django.db.models.base import Model
|
||||
from django.utils.text import slugify
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.lib.utils.reflection import path_to_class
|
||||
from authentik.outposts.controllers.base import ControllerException
|
||||
from authentik.outposts.models import (
|
||||
DockerServiceConnection,
|
||||
KubernetesServiceConnection,
|
||||
Outpost,
|
||||
OutpostModel,
|
||||
OutpostServiceConnection,
|
||||
OutpostState,
|
||||
OutpostType,
|
||||
)
|
||||
from authentik.providers.proxy.controllers.docker import ProxyDockerController
|
||||
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def outpost_controller_all():
|
||||
"""Launch Controller for all Outposts which support it"""
|
||||
for outpost in Outpost.objects.exclude(service_connection=None):
|
||||
outpost_controller.delay(outpost.pk.hex)
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def outpost_service_connection_state(state_pk: Any):
|
||||
"""Update cached state of a service connection"""
|
||||
connection: OutpostServiceConnection = (
|
||||
OutpostServiceConnection.objects.filter(pk=state_pk).select_subclasses().first()
|
||||
)
|
||||
cache.delete(f"outpost_service_connection_{connection.pk.hex}")
|
||||
_ = connection.state
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
def outpost_service_connection_monitor(self: MonitoredTask):
|
||||
"""Regularly check the state of Outpost Service Connections"""
|
||||
for connection in OutpostServiceConnection.objects.select_subclasses():
|
||||
cache.delete(f"outpost_service_connection_{connection.pk.hex}")
|
||||
_ = connection.state
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
def outpost_controller(self: MonitoredTask, outpost_pk: str):
|
||||
"""Create/update/monitor the deployment of an Outpost"""
|
||||
logs = []
|
||||
outpost: Outpost = Outpost.objects.get(pk=outpost_pk)
|
||||
self.set_uid(slugify(outpost.name))
|
||||
try:
|
||||
if outpost.type == OutpostType.PROXY:
|
||||
service_connection = outpost.service_connection
|
||||
if isinstance(service_connection, DockerServiceConnection):
|
||||
logs = ProxyDockerController(outpost, service_connection).up_with_logs()
|
||||
if isinstance(service_connection, KubernetesServiceConnection):
|
||||
logs = ProxyKubernetesController(
|
||||
outpost, service_connection
|
||||
).up_with_logs()
|
||||
LOGGER.debug("---------------Outpost Controller logs starting----------------")
|
||||
for log in logs:
|
||||
LOGGER.debug(log)
|
||||
LOGGER.debug("-----------------Outpost Controller logs end-------------------")
|
||||
except ControllerException as exc:
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
else:
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, logs))
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def outpost_pre_delete(outpost_pk: str):
|
||||
"""Delete outpost objects before deleting the DB Object"""
|
||||
outpost = Outpost.objects.get(pk=outpost_pk)
|
||||
if outpost.type == OutpostType.PROXY:
|
||||
service_connection = outpost.service_connection
|
||||
if isinstance(service_connection, DockerServiceConnection):
|
||||
ProxyDockerController(outpost, service_connection).down()
|
||||
if isinstance(service_connection, KubernetesServiceConnection):
|
||||
ProxyKubernetesController(outpost, service_connection).down()
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def outpost_post_save(model_class: str, model_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)
|
||||
except model.DoesNotExist:
|
||||
LOGGER.warning("Model does not exist", model=model, pk=model_pk)
|
||||
return
|
||||
|
||||
if isinstance(instance, Outpost):
|
||||
LOGGER.debug("Ensuring token for outpost", instance=instance)
|
||||
_ = instance.token
|
||||
LOGGER.debug("Trigger reconcile for outpost")
|
||||
outpost_controller.delay(instance.pk)
|
||||
return
|
||||
|
||||
if isinstance(instance, (OutpostModel, Outpost)):
|
||||
LOGGER.debug(
|
||||
"triggering outpost update from outpostmodel/outpost", instance=instance
|
||||
)
|
||||
outpost_send_update(instance)
|
||||
return
|
||||
|
||||
if isinstance(instance, OutpostServiceConnection):
|
||||
LOGGER.debug("triggering ServiceConnection state update", instance=instance)
|
||||
outpost_service_connection_state.delay(instance.pk)
|
||||
|
||||
for field in instance._meta.get_fields():
|
||||
# Each field is checked if it has a `related_model` attribute (when ForeginKeys or M2Ms)
|
||||
# are used, and if it has a value
|
||||
if not hasattr(field, "related_model"):
|
||||
continue
|
||||
if not field.related_model:
|
||||
continue
|
||||
if not issubclass(field.related_model, OutpostModel):
|
||||
continue
|
||||
|
||||
field_name = f"{field.name}_set"
|
||||
if not hasattr(instance, field_name):
|
||||
continue
|
||||
|
||||
LOGGER.debug("triggering outpost update from from field", field=field.name)
|
||||
# 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)
|
||||
|
||||
|
||||
def outpost_send_update(model_instace: Model):
|
||||
"""Send outpost update to all registered outposts, irregardless to which authentik
|
||||
instance they are connected"""
|
||||
channel_layer = get_channel_layer()
|
||||
if isinstance(model_instace, OutpostModel):
|
||||
for outpost in model_instace.outpost_set.all():
|
||||
_outpost_single_update(outpost, channel_layer)
|
||||
elif isinstance(model_instace, Outpost):
|
||||
_outpost_single_update(model_instace, channel_layer)
|
||||
|
||||
|
||||
def _outpost_single_update(outpost: Outpost, layer=None):
|
||||
"""Update outpost instances connected to a single outpost"""
|
||||
# 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
|
||||
if not layer: # pragma: no cover
|
||||
layer = get_channel_layer()
|
||||
for state in OutpostState.for_outpost(outpost):
|
||||
LOGGER.debug("sending update", channel=state.uid, outpost=outpost)
|
||||
async_to_sync(layer.send)(state.uid, {"type": "event.update"})
|
||||
43
authentik/outposts/templates/outposts/deployment_modal.html
Normal file
43
authentik/outposts/templates/outposts/deployment_modal.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ak-modal-button>
|
||||
<button slot="trigger" class="pf-c-button pf-m-tertiary">
|
||||
{% trans 'View Deployment Info' %}
|
||||
</button>
|
||||
<div slot="modal">
|
||||
<div class="pf-c-modal-box__header">
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Outpost Deployment Info' %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<p><a href="https://goauthentik.io/docs/outposts/outposts/#deploy">{% trans 'View deployment documentation' %}</a></p>
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">AUTHENTIK_HOST</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ full_url }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">AUTHENTIK_TOKEN</span>
|
||||
</label>
|
||||
<div>
|
||||
<ak-token-copy-button identifier="{{ outpost.token_identifier }}">
|
||||
{% trans 'Click to copy token' %}
|
||||
</ak-token-copy-button>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{% trans 'If your authentik Instance is using a self-signed certificate, set this value.' %}</h3>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">AUTHENTIK_INSECURE</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="true" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<a class="pf-c-button pf-m-primary">{% trans 'Close' %}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</ak-modal-button>
|
||||
59
authentik/outposts/tests.py
Normal file
59
authentik/outposts/tests.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""outpost tests"""
|
||||
from django.test import TestCase
|
||||
from guardian.models import UserObjectPermission
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
|
||||
|
||||
class OutpostTests(TestCase):
|
||||
"""Outpost Tests"""
|
||||
|
||||
def test_service_account_permissions(self):
|
||||
"""Test that the service account has correct permissions"""
|
||||
provider: ProxyProvider = ProxyProvider.objects.create(
|
||||
name="test",
|
||||
internal_host="http://localhost",
|
||||
external_host="http://localhost",
|
||||
authorization_flow=Flow.objects.first(),
|
||||
)
|
||||
outpost: Outpost = Outpost.objects.create(
|
||||
name="test",
|
||||
type=OutpostType.PROXY,
|
||||
)
|
||||
|
||||
# Before we add a provider, the user should only have access to the outpost
|
||||
permissions = UserObjectPermission.objects.filter(user=outpost.user)
|
||||
self.assertEqual(len(permissions), 1)
|
||||
self.assertEqual(permissions[0].object_pk, str(outpost.pk))
|
||||
|
||||
# We add a provider, user should only have access to outpost and provider
|
||||
outpost.providers.add(provider)
|
||||
outpost.save()
|
||||
permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
|
||||
"content_type__model"
|
||||
)
|
||||
self.assertEqual(len(permissions), 2)
|
||||
self.assertEqual(permissions[0].object_pk, str(outpost.pk))
|
||||
self.assertEqual(permissions[1].object_pk, str(provider.pk))
|
||||
|
||||
# Provider requires a certificate-key-pair, user should have permissions for it
|
||||
keypair = CertificateKeyPair.objects.first()
|
||||
provider.certificate = keypair
|
||||
provider.save()
|
||||
permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
|
||||
"content_type__model"
|
||||
)
|
||||
self.assertEqual(len(permissions), 3)
|
||||
self.assertEqual(permissions[0].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[1].object_pk, str(outpost.pk))
|
||||
self.assertEqual(permissions[2].object_pk, str(provider.pk))
|
||||
|
||||
# Remove provider from outpost, user should only have access to outpost
|
||||
outpost.providers.remove(provider)
|
||||
outpost.save()
|
||||
permissions = UserObjectPermission.objects.filter(user=outpost.user)
|
||||
self.assertEqual(len(permissions), 1)
|
||||
self.assertEqual(permissions[0].object_pk, str(outpost.pk))
|
||||
11
authentik/outposts/urls.py
Normal file
11
authentik/outposts/urls.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""authentik outposts urls"""
|
||||
from django.urls import path
|
||||
|
||||
from authentik.outposts.views import KubernetesManifestView, SetupView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"<uuid:outpost_pk>/k8s/", KubernetesManifestView.as_view(), name="k8s-manifest"
|
||||
),
|
||||
path("<uuid:outpost_pk>/", SetupView.as_view(), name="setup"),
|
||||
]
|
||||
89
authentik/outposts/views.py
Normal file
89
authentik/outposts/views.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""authentik outpost views"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Model
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
from django.views.generic import TemplateView
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from structlog import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.outposts.controllers.docker import DockerController
|
||||
from authentik.outposts.models import (
|
||||
DockerServiceConnection,
|
||||
KubernetesServiceConnection,
|
||||
Outpost,
|
||||
OutpostType,
|
||||
)
|
||||
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def get_object_for_user_or_404(user: User, perm: str, **filters) -> Model:
|
||||
"""Wrapper that combines get_objects_for_user and get_object_or_404"""
|
||||
return get_object_or_404(get_objects_for_user(user, perm), **filters)
|
||||
|
||||
|
||||
class DockerComposeView(LoginRequiredMixin, View):
|
||||
"""Generate docker-compose yaml"""
|
||||
|
||||
def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse:
|
||||
"""Render docker-compose file"""
|
||||
outpost: Outpost = get_object_for_user_or_404(
|
||||
request.user,
|
||||
"authentik_outposts.view_outpost",
|
||||
pk=outpost_pk,
|
||||
)
|
||||
manifest = ""
|
||||
if outpost.type == OutpostType.PROXY:
|
||||
controller = DockerController(outpost, DockerServiceConnection())
|
||||
manifest = controller.get_static_deployment()
|
||||
|
||||
return HttpResponse(manifest, content_type="text/vnd.yaml")
|
||||
|
||||
|
||||
class KubernetesManifestView(LoginRequiredMixin, View):
|
||||
"""Generate Kubernetes Deployment and SVC for proxy"""
|
||||
|
||||
def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse:
|
||||
"""Render deployment template"""
|
||||
outpost: Outpost = get_object_for_user_or_404(
|
||||
request.user,
|
||||
"authentik_outposts.view_outpost",
|
||||
pk=outpost_pk,
|
||||
)
|
||||
manifest = ""
|
||||
if outpost.type == OutpostType.PROXY:
|
||||
controller = ProxyKubernetesController(
|
||||
outpost, KubernetesServiceConnection()
|
||||
)
|
||||
manifest = controller.get_static_deployment()
|
||||
|
||||
return HttpResponse(manifest, content_type="text/vnd.yaml")
|
||||
|
||||
|
||||
class SetupView(LoginRequiredMixin, TemplateView):
|
||||
"""Setup view"""
|
||||
|
||||
def get_template_names(self) -> List[str]:
|
||||
allowed = ["dc", "custom", "k8s_manual", "k8s_integration"]
|
||||
setup_type = self.request.GET.get("type", "dc")
|
||||
if setup_type not in allowed:
|
||||
setup_type = allowed[0]
|
||||
return [f"outposts/setup_{setup_type}.html"]
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
outpost: Outpost = get_object_for_user_or_404(
|
||||
self.request.user,
|
||||
"authentik_outposts.view_outpost",
|
||||
pk=self.kwargs["outpost_pk"],
|
||||
)
|
||||
kwargs.update(
|
||||
{"host": self.request.build_absolute_uri("/"), "outpost": outpost}
|
||||
)
|
||||
return kwargs
|
||||
Reference in New Issue
Block a user