Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
35 changes: 35 additions & 0 deletions authentik/enterprise/endpoints/connectors/fleet/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""FleetConnector API Views"""

from rest_framework.viewsets import ModelViewSet

from authentik.core.api.used_by import UsedByMixin
from authentik.endpoints.api.connectors import ConnectorSerializer
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector


class FleetConnectorSerializer(EnterpriseRequiredMixin, ConnectorSerializer):
"""FleetConnector Serializer"""

class Meta(ConnectorSerializer.Meta):
model = FleetConnector
fields = ConnectorSerializer.Meta.fields + [
"url",
"token",
"headers_mapping",
]
extra_kwargs = {
"token": {"write_only": True},
}


class FleetConnectorViewSet(UsedByMixin, ModelViewSet):
"""FleetConnector Viewset"""

queryset = FleetConnector.objects.all()
serializer_class = FleetConnectorSerializer
filterset_fields = [
"name",
]
search_fields = ["name"]
ordering = ["name"]
12 changes: 12 additions & 0 deletions authentik/enterprise/endpoints/connectors/fleet/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""authentik endpoints app config"""

from authentik.enterprise.apps import EnterpriseConfig


class AuthentikEnterpriseEndpointsConnectorFleetAppConfig(EnterpriseConfig):
"""authentik endpoints app config"""

name = "authentik.enterprise.endpoints.connectors.fleet"
label = "authentik_endpoints_connectors_fleet"
verbose_name = "authentik Enterprise.Endpoints.Connectors.Fleet"
default = True
174 changes: 174 additions & 0 deletions authentik/enterprise/endpoints/connectors/fleet/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
from typing import Any

from django.db import transaction
from requests import RequestException
from rest_framework.exceptions import ValidationError

from authentik.core.models import User
from authentik.endpoints.controller import BaseController, ConnectorSyncException, EnrollmentMethods
from authentik.endpoints.facts import (
DeviceFacts,
OSFamily,
)
from authentik.endpoints.models import (
Device,
DeviceConnection,
DeviceUserBinding,
)
from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector as DBC
from authentik.events.utils import sanitize_item
from authentik.lib.utils.http import get_http_session
from authentik.policies.utils import delete_none_values


class FleetController(BaseController[DBC]):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._session = get_http_session()
self._session.headers["Authorization"] = f"Bearer {self.connector.token}"
if self.connector.headers_mapping:
self._session.headers.update(
sanitize_item(
self.connector.headers_mapping.evaluate(
user=None,
request=None,
connector=self.connector,
)
)
)

def supported_enrollment_methods(self) -> list[EnrollmentMethods]:
return [EnrollmentMethods.AUTOMATIC_API]

def _url(self, path: str) -> str:
return f"{self.connector.url}{path}"

def _paginate_hosts(self):
try:
page = 1
while True:
res = self._session.get(
self._url("/api/v1/fleet/hosts"),
params={
"order_key": "hardware_serial",
"page": page,
"per_page": 50,
"device_mapping": "true",
"populate_software": "true",
"populate_users": "true",
},
)
res.raise_for_status()
hosts: list[dict[str, Any]] = res.json()["hosts"]
if len(hosts) < 1:
break
yield from hosts
page += 1
except RequestException as exc:
raise ConnectorSyncException(exc) from exc

@transaction.atomic
def sync_endpoints(self) -> None:
for host in self._paginate_hosts():
serial = host["hardware_serial"]
device, _ = Device.objects.get_or_create(
identifier=serial, defaults={"name": host["hostname"], "expiring": False}
)
connection, _ = DeviceConnection.objects.update_or_create(
device=device,
connector=self.connector,
)
self.map_users(host, device)
try:
connection.create_snapshot(self.convert_host_data(host))
except ValidationError as exc:
self.logger.warning(
"failed to create snapshot for host", host=host["hostname"], exc=exc
)

def map_users(self, host: dict[str, Any], device: Device):
for raw_user in host.get("device_mapping", []) or []:
user = User.objects.filter(email=raw_user["email"]).first()
if not user:
continue
DeviceUserBinding.objects.update_or_create(
target=device,
user=user,
create_defaults={
"is_primary": True,
"order": 0,
},
)

@staticmethod
def os_family(host: dict[str, Any]) -> OSFamily:
if host["platform_like"] == "debian":
return OSFamily.linux
if host["platform_like"] == "windows":
return OSFamily.windows
if host["platform_like"] == "darwin":
return OSFamily.macOS
if host["platform"] == "android":
return OSFamily.android
if host["platform"] == "ipados" or host["platform"] == "ios":
return OSFamily.iOS
return OSFamily.other

def or_none(self, value) -> Any | None:
if value == "":
return None
return value

def convert_host_data(self, host: dict[str, Any]) -> dict[str, Any]:
"""Convert host data from fleet to authentik"""
fleet_version = ""
for pkg in host.get("software") or []:
if pkg["name"] in ["fleet-osquery", "fleet-desktop"]:
fleet_version = pkg["version"]
data = {
"os": delete_none_values(
{
"arch": self.or_none(host["cpu_type"]),
"family": FleetController.os_family(host),
"name": self.or_none(host["platform_like"]),
"version": self.or_none(host["os_version"]),
}
),
"disks": [],
"network": delete_none_values(
{"hostname": self.or_none(host["hostname"]), "interfaces": []}
),
"hardware": delete_none_values(
{
"model": self.or_none(host["hardware_model"]),
"manufacturer": self.or_none(host["hardware_vendor"]),
"serial": self.or_none(host["hardware_serial"]),
"cpu_name": self.or_none(host["cpu_brand"]),
"cpu_count": self.or_none(host["cpu_logical_cores"]),
"memory_bytes": self.or_none(host["memory"]),
}
),
"software": [
delete_none_values(
{
"name": x["name"],
"version": x["version"],
"source": x["source"],
}
)
for x in (host.get("software") or [])
],
"vendor": {
"fleetdm.com": {
"policies": [
delete_none_values({"name": policy["name"], "status": policy["response"]})
for policy in host.get("policies", [])
],
"agent_version": fleet_version,
},
},
}
facts = DeviceFacts(data=data)
facts.is_valid(raise_exception=True)
return facts.validated_data
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 5.2.8 on 2025-12-05 17:25

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
("authentik_endpoints", "0003_alter_endpointstage_options_endpointstage_mode"),
("authentik_events", "0013_delete_systemtask"),
]

operations = [
migrations.CreateModel(
name="FleetConnector",
fields=[
(
"connector_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_endpoints.connector",
),
),
("url", models.URLField()),
("token", models.TextField()),
(
"headers_mapping",
models.ForeignKey(
default=None,
help_text="Configure additional headers to be sent. Mapping should return a dictionary of key-value pairs",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="+",
to="authentik_events.notificationwebhookmapping",
),
),
],
options={
"verbose_name": "Fleet Connector",
"verbose_name_plural": "Fleet Connectors",
},
bases=("authentik_endpoints.connector",),
),
]
Empty file.
48 changes: 48 additions & 0 deletions authentik/enterprise/endpoints/connectors/fleet/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from typing import TYPE_CHECKING

from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer

from authentik.endpoints.models import Connector

if TYPE_CHECKING:
from authentik.enterprise.endpoints.connectors.fleet.controller import FleetController


class FleetConnector(Connector):
"""Ingest device data and policy compliance from a Fleet instance."""

url = models.URLField()
token = models.TextField()
headers_mapping = models.ForeignKey(
"authentik_events.NotificationWebhookMapping",
on_delete=models.SET_DEFAULT,
null=True,
default=None,
related_name="+",
help_text=_(
"Configure additional headers to be sent. "
"Mapping should return a dictionary of key-value pairs"
),
)

@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.endpoints.connectors.fleet.api import FleetConnectorSerializer

return FleetConnectorSerializer

@property
def controller(self) -> type["FleetController"]:
from authentik.enterprise.endpoints.connectors.fleet.controller import FleetController

return FleetController

@property
def component(self) -> str:
return "ak-endpoints-connector-fleet-form"

class Meta:
verbose_name = _("Fleet Connector")
verbose_name_plural = _("Fleet Connectors")
Loading
Loading