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:
Jens L
2020-12-05 22:08:42 +01:00
committed by GitHub
parent 810a7ab50b
commit 1cfe1aff13
989 changed files with 6425 additions and 4412 deletions

View File

66
authentik/outposts/api.py Normal file
View 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

View 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,
)

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

View 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

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

View 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,
)

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

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

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

View 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()

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

View 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,
}

View 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")),
],
),
]

View 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"),
),
]

View 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
),
),
]

View 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.",
),
),
]

View 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.",
),
),
]

View 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.",
),
),
]

View File

@ -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",
),
]

View 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.",
),
),
]

View File

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

View 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,
),
]

View 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",
),
),
]

View File

@ -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",
),
),
]

View 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),
]

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

View 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"},
},
}

View 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
View 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"})

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

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

View 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"),
]

View 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