authentik.enterprise.endpoints.connectors.fleet.controller

  1import re
  2from plistlib import loads
  3from typing import Any
  4
  5from cryptography.hazmat.primitives import serialization
  6from cryptography.x509 import load_der_x509_certificate
  7from django.db import transaction
  8from requests import RequestException
  9from rest_framework.exceptions import ValidationError
 10
 11from authentik.core.models import User
 12from authentik.crypto.models import CertificateKeyPair
 13from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
 14from authentik.endpoints.facts import (
 15    DeviceFacts,
 16    OSFamily,
 17)
 18from authentik.endpoints.models import (
 19    Device,
 20    DeviceAccessGroup,
 21    DeviceConnection,
 22    DeviceUserBinding,
 23)
 24from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector as DBC
 25from authentik.events.utils import sanitize_item
 26from authentik.lib.utils.http import get_http_session
 27from authentik.policies.utils import delete_none_values
 28
 29
 30class FleetController(BaseController[DBC]):
 31    def __init__(self, *args, **kwargs):
 32        super().__init__(*args, **kwargs)
 33        self._session = get_http_session()
 34        self._session.headers["Authorization"] = f"Bearer {self.connector.token}"
 35        if self.connector.headers_mapping:
 36            self._session.headers.update(
 37                sanitize_item(
 38                    self.connector.headers_mapping.evaluate(
 39                        user=None,
 40                        request=None,
 41                        connector=self.connector,
 42                    )
 43                )
 44            )
 45
 46    @staticmethod
 47    def vendor_identifier() -> str:
 48        return "fleetdm.com"
 49
 50    def capabilities(self) -> list[Capabilities]:
 51        return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
 52
 53    def _url(self, path: str) -> str:
 54        return f"{self.connector.url}{path}"
 55
 56    def _paginate_hosts(self):
 57        try:
 58            page = 0
 59            while True:
 60                self.logger.info("Fetching page of hosts...", page=page)
 61                res = self._session.get(
 62                    self._url("/api/v1/fleet/hosts"),
 63                    params={
 64                        "order_key": "hardware_serial",
 65                        "page": page,
 66                        "per_page": 50,
 67                        "device_mapping": "true",
 68                        "populate_software": "true",
 69                        "populate_users": "true",
 70                    },
 71                )
 72                res.raise_for_status()
 73                hosts: list[dict[str, Any]] = res.json()["hosts"]
 74                if len(hosts) < 1:
 75                    self.logger.info("No more hosts, finished")
 76                    break
 77                self.logger.info("Got hosts", count=len(hosts))
 78                yield from hosts
 79                page += 1
 80        except RequestException as exc:
 81            raise ConnectorSyncException(exc) from exc
 82
 83    @property
 84    def mtls_ca_managed(self) -> str:
 85        return f"goauthentik.io/endpoints/connectors/fleet/{self.connector.pk}"
 86
 87    def _sync_mtls_ca(self):
 88        """Sync conditional access Root CA for mTLS"""
 89        try:
 90            # Fleet doesn't have an API to just get the Conditional Access Root CA Cert (yet),
 91            # hence we fetch the apple config profile and extract it
 92            res = self._session.get(self._url("/api/v1/fleet/conditional_access/idp/apple/profile"))
 93            res.raise_for_status()
 94            profile = loads(res.text).get("PayloadContent", [])
 95            raw_cert = None
 96            for payload in profile:
 97                if payload.get("PayloadIdentifier", "") != "com.fleetdm.conditional-access-ca":
 98                    continue
 99                raw_cert = payload.get("PayloadContent")
100            if not raw_cert:
101                raise ConnectorSyncException("Failed to get conditional acccess CA")
102        except RequestException as exc:
103            raise ConnectorSyncException(exc) from exc
104        cert = load_der_x509_certificate(raw_cert)
105        CertificateKeyPair.objects.update_or_create(
106            managed=self.mtls_ca_managed,
107            defaults={
108                "name": f"Fleet Endpoint connector {self.connector.name}",
109                "certificate_data": cert.public_bytes(
110                    encoding=serialization.Encoding.PEM,
111                ).decode("utf-8"),
112            },
113        )
114
115    @transaction.atomic
116    def sync_endpoints(self) -> None:
117        try:
118            self._sync_mtls_ca()
119        except ConnectorSyncException as exc:
120            self.logger.warning("Failed to sync conditional access CA", exc=exc)
121        for host in self._paginate_hosts():
122            serial = host["hardware_serial"]
123            device, _ = Device.objects.get_or_create(
124                identifier=serial, defaults={"name": host["hostname"], "expiring": False}
125            )
126            connection, _ = DeviceConnection.objects.update_or_create(
127                device=device,
128                connector=self.connector,
129            )
130            if self.connector.map_users:
131                self.map_users(host, device)
132            if self.connector.map_teams_access_group:
133                self.map_access_group(host, device)
134            try:
135                connection.create_snapshot(self.convert_host_data(host))
136            except ValidationError as exc:
137                self.logger.warning(
138                    "failed to create snapshot for host", host=host["hostname"], exc=exc
139                )
140
141    def map_users(self, host: dict[str, Any], device: Device):
142        for raw_user in host.get("device_mapping", []) or []:
143            user = User.objects.filter(email=raw_user["email"]).first()
144            if not user:
145                continue
146            DeviceUserBinding.objects.update_or_create(
147                target=device,
148                user=user,
149                create_defaults={
150                    "is_primary": True,
151                    "order": 0,
152                },
153            )
154
155    def map_access_group(self, host: dict[str, Any], device: Device):
156        team_name = host.get("team_name")
157        if not team_name:
158            return
159        group, _ = DeviceAccessGroup.objects.get_or_create(name=team_name)
160        group.attributes["io.goauthentik.endpoints.connectors.fleet.team_id"] = host["team_id"]
161        if device.access_group:
162            return
163        device.access_group = group
164        device.save()
165
166    @staticmethod
167    def os_family(host: dict[str, Any]) -> OSFamily:
168        if host["platform_like"] in ["debian", "rhel"]:
169            return OSFamily.linux
170        if host["platform_like"] == "windows":
171            return OSFamily.windows
172        if host["platform_like"] == "darwin":
173            return OSFamily.macOS
174        if host["platform"] == "android":
175            return OSFamily.android
176        if host["platform"] in ["ipados", "ios"]:
177            return OSFamily.iOS
178        return OSFamily.other
179
180    def map_os(self, host: dict[str, Any]) -> dict[str, str]:
181        family = FleetController.os_family(host)
182        os = {
183            "arch": self.or_none(host["cpu_type"]),
184            "family": family,
185            "name": self.or_none(host["platform_like"]),
186            "version": self.or_none(host["os_version"]),
187        }
188        if not host["os_version"]:
189            return delete_none_values(os)
190        version = re.search(r"(\d+\.(?:\d+\.?)+)", host["os_version"])
191        if not version:
192            return delete_none_values(os)
193        os["version"] = host["os_version"][version.start() :].strip()
194        os["name"] = host["os_version"][0 : version.start()].strip()
195        return delete_none_values(os)
196
197    def or_none(self, value) -> Any | None:
198        if value == "":
199            return None
200        return value
201
202    def convert_host_data(self, host: dict[str, Any]) -> dict[str, Any]:
203        """Convert host data from fleet to authentik"""
204        fleet_version = ""
205        for pkg in host.get("software") or []:
206            if pkg["name"] in ["fleet-osquery", "fleet-desktop"]:
207                fleet_version = pkg["version"]
208        data = {
209            "os": self.map_os(host),
210            "disks": [],
211            "network": delete_none_values(
212                {"hostname": self.or_none(host["hostname"]), "interfaces": []}
213            ),
214            "hardware": delete_none_values(
215                {
216                    "model": self.or_none(host["hardware_model"]),
217                    "manufacturer": self.or_none(host["hardware_vendor"]),
218                    "serial": self.or_none(host["hardware_serial"]),
219                    "cpu_name": self.or_none(host["cpu_brand"]),
220                    "cpu_count": self.or_none(host["cpu_logical_cores"]),
221                    "memory_bytes": self.or_none(host["memory"]),
222                }
223            ),
224            "software": [
225                delete_none_values(
226                    {
227                        "name": x["name"],
228                        "version": x["version"],
229                        "source": x["source"],
230                    }
231                )
232                for x in (host.get("software") or [])
233            ],
234            "vendor": {
235                "fleetdm.com": {
236                    "policies": [
237                        delete_none_values({"name": policy["name"], "status": policy["response"]})
238                        for policy in host.get("policies", [])
239                    ],
240                    "agent_version": fleet_version,
241                    # Host UUID is required for conditional access matching
242                    "uuid": host.get("uuid", "").lower(),
243                },
244            },
245        }
246        facts = DeviceFacts(data=data)
247        facts.is_valid(raise_exception=True)
248        return facts.validated_data
 31class FleetController(BaseController[DBC]):
 32    def __init__(self, *args, **kwargs):
 33        super().__init__(*args, **kwargs)
 34        self._session = get_http_session()
 35        self._session.headers["Authorization"] = f"Bearer {self.connector.token}"
 36        if self.connector.headers_mapping:
 37            self._session.headers.update(
 38                sanitize_item(
 39                    self.connector.headers_mapping.evaluate(
 40                        user=None,
 41                        request=None,
 42                        connector=self.connector,
 43                    )
 44                )
 45            )
 46
 47    @staticmethod
 48    def vendor_identifier() -> str:
 49        return "fleetdm.com"
 50
 51    def capabilities(self) -> list[Capabilities]:
 52        return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
 53
 54    def _url(self, path: str) -> str:
 55        return f"{self.connector.url}{path}"
 56
 57    def _paginate_hosts(self):
 58        try:
 59            page = 0
 60            while True:
 61                self.logger.info("Fetching page of hosts...", page=page)
 62                res = self._session.get(
 63                    self._url("/api/v1/fleet/hosts"),
 64                    params={
 65                        "order_key": "hardware_serial",
 66                        "page": page,
 67                        "per_page": 50,
 68                        "device_mapping": "true",
 69                        "populate_software": "true",
 70                        "populate_users": "true",
 71                    },
 72                )
 73                res.raise_for_status()
 74                hosts: list[dict[str, Any]] = res.json()["hosts"]
 75                if len(hosts) < 1:
 76                    self.logger.info("No more hosts, finished")
 77                    break
 78                self.logger.info("Got hosts", count=len(hosts))
 79                yield from hosts
 80                page += 1
 81        except RequestException as exc:
 82            raise ConnectorSyncException(exc) from exc
 83
 84    @property
 85    def mtls_ca_managed(self) -> str:
 86        return f"goauthentik.io/endpoints/connectors/fleet/{self.connector.pk}"
 87
 88    def _sync_mtls_ca(self):
 89        """Sync conditional access Root CA for mTLS"""
 90        try:
 91            # Fleet doesn't have an API to just get the Conditional Access Root CA Cert (yet),
 92            # hence we fetch the apple config profile and extract it
 93            res = self._session.get(self._url("/api/v1/fleet/conditional_access/idp/apple/profile"))
 94            res.raise_for_status()
 95            profile = loads(res.text).get("PayloadContent", [])
 96            raw_cert = None
 97            for payload in profile:
 98                if payload.get("PayloadIdentifier", "") != "com.fleetdm.conditional-access-ca":
 99                    continue
100                raw_cert = payload.get("PayloadContent")
101            if not raw_cert:
102                raise ConnectorSyncException("Failed to get conditional acccess CA")
103        except RequestException as exc:
104            raise ConnectorSyncException(exc) from exc
105        cert = load_der_x509_certificate(raw_cert)
106        CertificateKeyPair.objects.update_or_create(
107            managed=self.mtls_ca_managed,
108            defaults={
109                "name": f"Fleet Endpoint connector {self.connector.name}",
110                "certificate_data": cert.public_bytes(
111                    encoding=serialization.Encoding.PEM,
112                ).decode("utf-8"),
113            },
114        )
115
116    @transaction.atomic
117    def sync_endpoints(self) -> None:
118        try:
119            self._sync_mtls_ca()
120        except ConnectorSyncException as exc:
121            self.logger.warning("Failed to sync conditional access CA", exc=exc)
122        for host in self._paginate_hosts():
123            serial = host["hardware_serial"]
124            device, _ = Device.objects.get_or_create(
125                identifier=serial, defaults={"name": host["hostname"], "expiring": False}
126            )
127            connection, _ = DeviceConnection.objects.update_or_create(
128                device=device,
129                connector=self.connector,
130            )
131            if self.connector.map_users:
132                self.map_users(host, device)
133            if self.connector.map_teams_access_group:
134                self.map_access_group(host, device)
135            try:
136                connection.create_snapshot(self.convert_host_data(host))
137            except ValidationError as exc:
138                self.logger.warning(
139                    "failed to create snapshot for host", host=host["hostname"], exc=exc
140                )
141
142    def map_users(self, host: dict[str, Any], device: Device):
143        for raw_user in host.get("device_mapping", []) or []:
144            user = User.objects.filter(email=raw_user["email"]).first()
145            if not user:
146                continue
147            DeviceUserBinding.objects.update_or_create(
148                target=device,
149                user=user,
150                create_defaults={
151                    "is_primary": True,
152                    "order": 0,
153                },
154            )
155
156    def map_access_group(self, host: dict[str, Any], device: Device):
157        team_name = host.get("team_name")
158        if not team_name:
159            return
160        group, _ = DeviceAccessGroup.objects.get_or_create(name=team_name)
161        group.attributes["io.goauthentik.endpoints.connectors.fleet.team_id"] = host["team_id"]
162        if device.access_group:
163            return
164        device.access_group = group
165        device.save()
166
167    @staticmethod
168    def os_family(host: dict[str, Any]) -> OSFamily:
169        if host["platform_like"] in ["debian", "rhel"]:
170            return OSFamily.linux
171        if host["platform_like"] == "windows":
172            return OSFamily.windows
173        if host["platform_like"] == "darwin":
174            return OSFamily.macOS
175        if host["platform"] == "android":
176            return OSFamily.android
177        if host["platform"] in ["ipados", "ios"]:
178            return OSFamily.iOS
179        return OSFamily.other
180
181    def map_os(self, host: dict[str, Any]) -> dict[str, str]:
182        family = FleetController.os_family(host)
183        os = {
184            "arch": self.or_none(host["cpu_type"]),
185            "family": family,
186            "name": self.or_none(host["platform_like"]),
187            "version": self.or_none(host["os_version"]),
188        }
189        if not host["os_version"]:
190            return delete_none_values(os)
191        version = re.search(r"(\d+\.(?:\d+\.?)+)", host["os_version"])
192        if not version:
193            return delete_none_values(os)
194        os["version"] = host["os_version"][version.start() :].strip()
195        os["name"] = host["os_version"][0 : version.start()].strip()
196        return delete_none_values(os)
197
198    def or_none(self, value) -> Any | None:
199        if value == "":
200            return None
201        return value
202
203    def convert_host_data(self, host: dict[str, Any]) -> dict[str, Any]:
204        """Convert host data from fleet to authentik"""
205        fleet_version = ""
206        for pkg in host.get("software") or []:
207            if pkg["name"] in ["fleet-osquery", "fleet-desktop"]:
208                fleet_version = pkg["version"]
209        data = {
210            "os": self.map_os(host),
211            "disks": [],
212            "network": delete_none_values(
213                {"hostname": self.or_none(host["hostname"]), "interfaces": []}
214            ),
215            "hardware": delete_none_values(
216                {
217                    "model": self.or_none(host["hardware_model"]),
218                    "manufacturer": self.or_none(host["hardware_vendor"]),
219                    "serial": self.or_none(host["hardware_serial"]),
220                    "cpu_name": self.or_none(host["cpu_brand"]),
221                    "cpu_count": self.or_none(host["cpu_logical_cores"]),
222                    "memory_bytes": self.or_none(host["memory"]),
223                }
224            ),
225            "software": [
226                delete_none_values(
227                    {
228                        "name": x["name"],
229                        "version": x["version"],
230                        "source": x["source"],
231                    }
232                )
233                for x in (host.get("software") or [])
234            ],
235            "vendor": {
236                "fleetdm.com": {
237                    "policies": [
238                        delete_none_values({"name": policy["name"], "status": policy["response"]})
239                        for policy in host.get("policies", [])
240                    ],
241                    "agent_version": fleet_version,
242                    # Host UUID is required for conditional access matching
243                    "uuid": host.get("uuid", "").lower(),
244                },
245            },
246        }
247        facts = DeviceFacts(data=data)
248        facts.is_valid(raise_exception=True)
249        return facts.validated_data

Abstract base class for generic types.

On Python 3.12 and newer, generic classes implicitly inherit from Generic when they declare a parameter list after the class's name::

class Mapping[KT, VT]:
    def __getitem__(self, key: KT) -> VT:
        ...
    # Etc.

On older versions of Python, however, generic classes have to explicitly inherit from Generic.

After a class has been declared to be generic, it can then be used as follows::

def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT:
    try:
        return mapping[key]
    except KeyError:
        return default
FleetController(*args, **kwargs)
32    def __init__(self, *args, **kwargs):
33        super().__init__(*args, **kwargs)
34        self._session = get_http_session()
35        self._session.headers["Authorization"] = f"Bearer {self.connector.token}"
36        if self.connector.headers_mapping:
37            self._session.headers.update(
38                sanitize_item(
39                    self.connector.headers_mapping.evaluate(
40                        user=None,
41                        request=None,
42                        connector=self.connector,
43                    )
44                )
45            )
@staticmethod
def vendor_identifier() -> str:
47    @staticmethod
48    def vendor_identifier() -> str:
49        return "fleetdm.com"
def capabilities(self) -> list[authentik.endpoints.controller.Capabilities]:
51    def capabilities(self) -> list[Capabilities]:
52        return [Capabilities.STAGE_ENDPOINTS, Capabilities.ENROLL_AUTOMATIC_API]
mtls_ca_managed: str
84    @property
85    def mtls_ca_managed(self) -> str:
86        return f"goauthentik.io/endpoints/connectors/fleet/{self.connector.pk}"
@transaction.atomic
def sync_endpoints(self) -> None:
116    @transaction.atomic
117    def sync_endpoints(self) -> None:
118        try:
119            self._sync_mtls_ca()
120        except ConnectorSyncException as exc:
121            self.logger.warning("Failed to sync conditional access CA", exc=exc)
122        for host in self._paginate_hosts():
123            serial = host["hardware_serial"]
124            device, _ = Device.objects.get_or_create(
125                identifier=serial, defaults={"name": host["hostname"], "expiring": False}
126            )
127            connection, _ = DeviceConnection.objects.update_or_create(
128                device=device,
129                connector=self.connector,
130            )
131            if self.connector.map_users:
132                self.map_users(host, device)
133            if self.connector.map_teams_access_group:
134                self.map_access_group(host, device)
135            try:
136                connection.create_snapshot(self.convert_host_data(host))
137            except ValidationError as exc:
138                self.logger.warning(
139                    "failed to create snapshot for host", host=host["hostname"], exc=exc
140                )
def map_users( self, host: dict[str, typing.Any], device: authentik.endpoints.models.Device):
142    def map_users(self, host: dict[str, Any], device: Device):
143        for raw_user in host.get("device_mapping", []) or []:
144            user = User.objects.filter(email=raw_user["email"]).first()
145            if not user:
146                continue
147            DeviceUserBinding.objects.update_or_create(
148                target=device,
149                user=user,
150                create_defaults={
151                    "is_primary": True,
152                    "order": 0,
153                },
154            )
def map_access_group( self, host: dict[str, typing.Any], device: authentik.endpoints.models.Device):
156    def map_access_group(self, host: dict[str, Any], device: Device):
157        team_name = host.get("team_name")
158        if not team_name:
159            return
160        group, _ = DeviceAccessGroup.objects.get_or_create(name=team_name)
161        group.attributes["io.goauthentik.endpoints.connectors.fleet.team_id"] = host["team_id"]
162        if device.access_group:
163            return
164        device.access_group = group
165        device.save()
@staticmethod
def os_family(host: dict[str, typing.Any]) -> authentik.endpoints.facts.OSFamily:
167    @staticmethod
168    def os_family(host: dict[str, Any]) -> OSFamily:
169        if host["platform_like"] in ["debian", "rhel"]:
170            return OSFamily.linux
171        if host["platform_like"] == "windows":
172            return OSFamily.windows
173        if host["platform_like"] == "darwin":
174            return OSFamily.macOS
175        if host["platform"] == "android":
176            return OSFamily.android
177        if host["platform"] in ["ipados", "ios"]:
178            return OSFamily.iOS
179        return OSFamily.other
def map_os(self, host: dict[str, typing.Any]) -> dict[str, str]:
181    def map_os(self, host: dict[str, Any]) -> dict[str, str]:
182        family = FleetController.os_family(host)
183        os = {
184            "arch": self.or_none(host["cpu_type"]),
185            "family": family,
186            "name": self.or_none(host["platform_like"]),
187            "version": self.or_none(host["os_version"]),
188        }
189        if not host["os_version"]:
190            return delete_none_values(os)
191        version = re.search(r"(\d+\.(?:\d+\.?)+)", host["os_version"])
192        if not version:
193            return delete_none_values(os)
194        os["version"] = host["os_version"][version.start() :].strip()
195        os["name"] = host["os_version"][0 : version.start()].strip()
196        return delete_none_values(os)
def or_none(self, value) -> Any | None:
198    def or_none(self, value) -> Any | None:
199        if value == "":
200            return None
201        return value
def convert_host_data(self, host: dict[str, typing.Any]) -> dict[str, typing.Any]:
203    def convert_host_data(self, host: dict[str, Any]) -> dict[str, Any]:
204        """Convert host data from fleet to authentik"""
205        fleet_version = ""
206        for pkg in host.get("software") or []:
207            if pkg["name"] in ["fleet-osquery", "fleet-desktop"]:
208                fleet_version = pkg["version"]
209        data = {
210            "os": self.map_os(host),
211            "disks": [],
212            "network": delete_none_values(
213                {"hostname": self.or_none(host["hostname"]), "interfaces": []}
214            ),
215            "hardware": delete_none_values(
216                {
217                    "model": self.or_none(host["hardware_model"]),
218                    "manufacturer": self.or_none(host["hardware_vendor"]),
219                    "serial": self.or_none(host["hardware_serial"]),
220                    "cpu_name": self.or_none(host["cpu_brand"]),
221                    "cpu_count": self.or_none(host["cpu_logical_cores"]),
222                    "memory_bytes": self.or_none(host["memory"]),
223                }
224            ),
225            "software": [
226                delete_none_values(
227                    {
228                        "name": x["name"],
229                        "version": x["version"],
230                        "source": x["source"],
231                    }
232                )
233                for x in (host.get("software") or [])
234            ],
235            "vendor": {
236                "fleetdm.com": {
237                    "policies": [
238                        delete_none_values({"name": policy["name"], "status": policy["response"]})
239                        for policy in host.get("policies", [])
240                    ],
241                    "agent_version": fleet_version,
242                    # Host UUID is required for conditional access matching
243                    "uuid": host.get("uuid", "").lower(),
244                },
245            },
246        }
247        facts = DeviceFacts(data=data)
248        facts.is_valid(raise_exception=True)
249        return facts.validated_data

Convert host data from fleet to authentik