authentik.enterprise.endpoints.connectors.fleet.controller

  1import re
  2from typing import Any
  3
  4from django.db import transaction
  5from requests import RequestException
  6from rest_framework.exceptions import ValidationError
  7
  8from authentik.core.models import User
  9from authentik.endpoints.controller import BaseController, Capabilities, ConnectorSyncException
 10from authentik.endpoints.facts import (
 11    DeviceFacts,
 12    OSFamily,
 13)
 14from authentik.endpoints.models import (
 15    Device,
 16    DeviceAccessGroup,
 17    DeviceConnection,
 18    DeviceUserBinding,
 19)
 20from authentik.enterprise.endpoints.connectors.fleet.models import FleetConnector as DBC
 21from authentik.events.utils import sanitize_item
 22from authentik.lib.utils.http import get_http_session
 23from authentik.policies.utils import delete_none_values
 24
 25
 26class FleetController(BaseController[DBC]):
 27    def __init__(self, *args, **kwargs):
 28        super().__init__(*args, **kwargs)
 29        self._session = get_http_session()
 30        self._session.headers["Authorization"] = f"Bearer {self.connector.token}"
 31        if self.connector.headers_mapping:
 32            self._session.headers.update(
 33                sanitize_item(
 34                    self.connector.headers_mapping.evaluate(
 35                        user=None,
 36                        request=None,
 37                        connector=self.connector,
 38                    )
 39                )
 40            )
 41
 42    @staticmethod
 43    def vendor_identifier() -> str:
 44        return "fleetdm.com"
 45
 46    def capabilities(self) -> list[Capabilities]:
 47        return [Capabilities.ENROLL_AUTOMATIC_API]
 48
 49    def _url(self, path: str) -> str:
 50        return f"{self.connector.url}{path}"
 51
 52    def _paginate_hosts(self):
 53        try:
 54            page = 0
 55            while True:
 56                self.logger.info("Fetching page of hosts...", page=page)
 57                res = self._session.get(
 58                    self._url("/api/v1/fleet/hosts"),
 59                    params={
 60                        "order_key": "hardware_serial",
 61                        "page": page,
 62                        "per_page": 50,
 63                        "device_mapping": "true",
 64                        "populate_software": "true",
 65                        "populate_users": "true",
 66                    },
 67                )
 68                res.raise_for_status()
 69                hosts: list[dict[str, Any]] = res.json()["hosts"]
 70                if len(hosts) < 1:
 71                    self.logger.info("No more hosts, finished")
 72                    break
 73                self.logger.info("Got hosts", count=len(hosts))
 74                yield from hosts
 75                page += 1
 76        except RequestException as exc:
 77            raise ConnectorSyncException(exc) from exc
 78
 79    @transaction.atomic
 80    def sync_endpoints(self) -> None:
 81        for host in self._paginate_hosts():
 82            serial = host["hardware_serial"]
 83            device, _ = Device.objects.get_or_create(
 84                identifier=serial, defaults={"name": host["hostname"], "expiring": False}
 85            )
 86            connection, _ = DeviceConnection.objects.update_or_create(
 87                device=device,
 88                connector=self.connector,
 89            )
 90            if self.connector.map_users:
 91                self.map_users(host, device)
 92            if self.connector.map_teams_access_group:
 93                self.map_access_group(host, device)
 94            try:
 95                connection.create_snapshot(self.convert_host_data(host))
 96            except ValidationError as exc:
 97                self.logger.warning(
 98                    "failed to create snapshot for host", host=host["hostname"], exc=exc
 99                )
100
101    def map_users(self, host: dict[str, Any], device: Device):
102        for raw_user in host.get("device_mapping", []) or []:
103            user = User.objects.filter(email=raw_user["email"]).first()
104            if not user:
105                continue
106            DeviceUserBinding.objects.update_or_create(
107                target=device,
108                user=user,
109                create_defaults={
110                    "is_primary": True,
111                    "order": 0,
112                },
113            )
114
115    def map_access_group(self, host: dict[str, Any], device: Device):
116        team_name = host.get("team_name")
117        if not team_name:
118            return
119        group, _ = DeviceAccessGroup.objects.get_or_create(name=team_name)
120        group.attributes["io.goauthentik.endpoints.connectors.fleet.team_id"] = host["team_id"]
121        if device.access_group:
122            return
123        device.access_group = group
124        device.save()
125
126    @staticmethod
127    def os_family(host: dict[str, Any]) -> OSFamily:
128        if host["platform_like"] in ["debian", "rhel"]:
129            return OSFamily.linux
130        if host["platform_like"] == "windows":
131            return OSFamily.windows
132        if host["platform_like"] == "darwin":
133            return OSFamily.macOS
134        if host["platform"] == "android":
135            return OSFamily.android
136        if host["platform"] in ["ipados", "ios"]:
137            return OSFamily.iOS
138        return OSFamily.other
139
140    def map_os(self, host: dict[str, Any]) -> dict[str, str]:
141        family = FleetController.os_family(host)
142        os = {
143            "arch": self.or_none(host["cpu_type"]),
144            "family": family,
145            "name": self.or_none(host["platform_like"]),
146            "version": self.or_none(host["os_version"]),
147        }
148        if not host["os_version"]:
149            return delete_none_values(os)
150        version = re.search(r"(\d+\.(?:\d+\.?)+)", host["os_version"])
151        if not version:
152            return delete_none_values(os)
153        os["version"] = host["os_version"][version.start() :].strip()
154        os["name"] = host["os_version"][0 : version.start()].strip()
155        return delete_none_values(os)
156
157    def or_none(self, value) -> Any | None:
158        if value == "":
159            return None
160        return value
161
162    def convert_host_data(self, host: dict[str, Any]) -> dict[str, Any]:
163        """Convert host data from fleet to authentik"""
164        fleet_version = ""
165        for pkg in host.get("software") or []:
166            if pkg["name"] in ["fleet-osquery", "fleet-desktop"]:
167                fleet_version = pkg["version"]
168        data = {
169            "os": self.map_os(host),
170            "disks": [],
171            "network": delete_none_values(
172                {"hostname": self.or_none(host["hostname"]), "interfaces": []}
173            ),
174            "hardware": delete_none_values(
175                {
176                    "model": self.or_none(host["hardware_model"]),
177                    "manufacturer": self.or_none(host["hardware_vendor"]),
178                    "serial": self.or_none(host["hardware_serial"]),
179                    "cpu_name": self.or_none(host["cpu_brand"]),
180                    "cpu_count": self.or_none(host["cpu_logical_cores"]),
181                    "memory_bytes": self.or_none(host["memory"]),
182                }
183            ),
184            "software": [
185                delete_none_values(
186                    {
187                        "name": x["name"],
188                        "version": x["version"],
189                        "source": x["source"],
190                    }
191                )
192                for x in (host.get("software") or [])
193            ],
194            "vendor": {
195                "fleetdm.com": {
196                    "policies": [
197                        delete_none_values({"name": policy["name"], "status": policy["response"]})
198                        for policy in host.get("policies", [])
199                    ],
200                    "agent_version": fleet_version,
201                },
202            },
203        }
204        facts = DeviceFacts(data=data)
205        facts.is_valid(raise_exception=True)
206        return facts.validated_data
 27class FleetController(BaseController[DBC]):
 28    def __init__(self, *args, **kwargs):
 29        super().__init__(*args, **kwargs)
 30        self._session = get_http_session()
 31        self._session.headers["Authorization"] = f"Bearer {self.connector.token}"
 32        if self.connector.headers_mapping:
 33            self._session.headers.update(
 34                sanitize_item(
 35                    self.connector.headers_mapping.evaluate(
 36                        user=None,
 37                        request=None,
 38                        connector=self.connector,
 39                    )
 40                )
 41            )
 42
 43    @staticmethod
 44    def vendor_identifier() -> str:
 45        return "fleetdm.com"
 46
 47    def capabilities(self) -> list[Capabilities]:
 48        return [Capabilities.ENROLL_AUTOMATIC_API]
 49
 50    def _url(self, path: str) -> str:
 51        return f"{self.connector.url}{path}"
 52
 53    def _paginate_hosts(self):
 54        try:
 55            page = 0
 56            while True:
 57                self.logger.info("Fetching page of hosts...", page=page)
 58                res = self._session.get(
 59                    self._url("/api/v1/fleet/hosts"),
 60                    params={
 61                        "order_key": "hardware_serial",
 62                        "page": page,
 63                        "per_page": 50,
 64                        "device_mapping": "true",
 65                        "populate_software": "true",
 66                        "populate_users": "true",
 67                    },
 68                )
 69                res.raise_for_status()
 70                hosts: list[dict[str, Any]] = res.json()["hosts"]
 71                if len(hosts) < 1:
 72                    self.logger.info("No more hosts, finished")
 73                    break
 74                self.logger.info("Got hosts", count=len(hosts))
 75                yield from hosts
 76                page += 1
 77        except RequestException as exc:
 78            raise ConnectorSyncException(exc) from exc
 79
 80    @transaction.atomic
 81    def sync_endpoints(self) -> None:
 82        for host in self._paginate_hosts():
 83            serial = host["hardware_serial"]
 84            device, _ = Device.objects.get_or_create(
 85                identifier=serial, defaults={"name": host["hostname"], "expiring": False}
 86            )
 87            connection, _ = DeviceConnection.objects.update_or_create(
 88                device=device,
 89                connector=self.connector,
 90            )
 91            if self.connector.map_users:
 92                self.map_users(host, device)
 93            if self.connector.map_teams_access_group:
 94                self.map_access_group(host, device)
 95            try:
 96                connection.create_snapshot(self.convert_host_data(host))
 97            except ValidationError as exc:
 98                self.logger.warning(
 99                    "failed to create snapshot for host", host=host["hostname"], exc=exc
100                )
101
102    def map_users(self, host: dict[str, Any], device: Device):
103        for raw_user in host.get("device_mapping", []) or []:
104            user = User.objects.filter(email=raw_user["email"]).first()
105            if not user:
106                continue
107            DeviceUserBinding.objects.update_or_create(
108                target=device,
109                user=user,
110                create_defaults={
111                    "is_primary": True,
112                    "order": 0,
113                },
114            )
115
116    def map_access_group(self, host: dict[str, Any], device: Device):
117        team_name = host.get("team_name")
118        if not team_name:
119            return
120        group, _ = DeviceAccessGroup.objects.get_or_create(name=team_name)
121        group.attributes["io.goauthentik.endpoints.connectors.fleet.team_id"] = host["team_id"]
122        if device.access_group:
123            return
124        device.access_group = group
125        device.save()
126
127    @staticmethod
128    def os_family(host: dict[str, Any]) -> OSFamily:
129        if host["platform_like"] in ["debian", "rhel"]:
130            return OSFamily.linux
131        if host["platform_like"] == "windows":
132            return OSFamily.windows
133        if host["platform_like"] == "darwin":
134            return OSFamily.macOS
135        if host["platform"] == "android":
136            return OSFamily.android
137        if host["platform"] in ["ipados", "ios"]:
138            return OSFamily.iOS
139        return OSFamily.other
140
141    def map_os(self, host: dict[str, Any]) -> dict[str, str]:
142        family = FleetController.os_family(host)
143        os = {
144            "arch": self.or_none(host["cpu_type"]),
145            "family": family,
146            "name": self.or_none(host["platform_like"]),
147            "version": self.or_none(host["os_version"]),
148        }
149        if not host["os_version"]:
150            return delete_none_values(os)
151        version = re.search(r"(\d+\.(?:\d+\.?)+)", host["os_version"])
152        if not version:
153            return delete_none_values(os)
154        os["version"] = host["os_version"][version.start() :].strip()
155        os["name"] = host["os_version"][0 : version.start()].strip()
156        return delete_none_values(os)
157
158    def or_none(self, value) -> Any | None:
159        if value == "":
160            return None
161        return value
162
163    def convert_host_data(self, host: dict[str, Any]) -> dict[str, Any]:
164        """Convert host data from fleet to authentik"""
165        fleet_version = ""
166        for pkg in host.get("software") or []:
167            if pkg["name"] in ["fleet-osquery", "fleet-desktop"]:
168                fleet_version = pkg["version"]
169        data = {
170            "os": self.map_os(host),
171            "disks": [],
172            "network": delete_none_values(
173                {"hostname": self.or_none(host["hostname"]), "interfaces": []}
174            ),
175            "hardware": delete_none_values(
176                {
177                    "model": self.or_none(host["hardware_model"]),
178                    "manufacturer": self.or_none(host["hardware_vendor"]),
179                    "serial": self.or_none(host["hardware_serial"]),
180                    "cpu_name": self.or_none(host["cpu_brand"]),
181                    "cpu_count": self.or_none(host["cpu_logical_cores"]),
182                    "memory_bytes": self.or_none(host["memory"]),
183                }
184            ),
185            "software": [
186                delete_none_values(
187                    {
188                        "name": x["name"],
189                        "version": x["version"],
190                        "source": x["source"],
191                    }
192                )
193                for x in (host.get("software") or [])
194            ],
195            "vendor": {
196                "fleetdm.com": {
197                    "policies": [
198                        delete_none_values({"name": policy["name"], "status": policy["response"]})
199                        for policy in host.get("policies", [])
200                    ],
201                    "agent_version": fleet_version,
202                },
203            },
204        }
205        facts = DeviceFacts(data=data)
206        facts.is_valid(raise_exception=True)
207        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)
28    def __init__(self, *args, **kwargs):
29        super().__init__(*args, **kwargs)
30        self._session = get_http_session()
31        self._session.headers["Authorization"] = f"Bearer {self.connector.token}"
32        if self.connector.headers_mapping:
33            self._session.headers.update(
34                sanitize_item(
35                    self.connector.headers_mapping.evaluate(
36                        user=None,
37                        request=None,
38                        connector=self.connector,
39                    )
40                )
41            )
@staticmethod
def vendor_identifier() -> str:
43    @staticmethod
44    def vendor_identifier() -> str:
45        return "fleetdm.com"
def capabilities(self) -> list[authentik.endpoints.controller.Capabilities]:
47    def capabilities(self) -> list[Capabilities]:
48        return [Capabilities.ENROLL_AUTOMATIC_API]
@transaction.atomic
def sync_endpoints(self) -> None:
 80    @transaction.atomic
 81    def sync_endpoints(self) -> None:
 82        for host in self._paginate_hosts():
 83            serial = host["hardware_serial"]
 84            device, _ = Device.objects.get_or_create(
 85                identifier=serial, defaults={"name": host["hostname"], "expiring": False}
 86            )
 87            connection, _ = DeviceConnection.objects.update_or_create(
 88                device=device,
 89                connector=self.connector,
 90            )
 91            if self.connector.map_users:
 92                self.map_users(host, device)
 93            if self.connector.map_teams_access_group:
 94                self.map_access_group(host, device)
 95            try:
 96                connection.create_snapshot(self.convert_host_data(host))
 97            except ValidationError as exc:
 98                self.logger.warning(
 99                    "failed to create snapshot for host", host=host["hostname"], exc=exc
100                )
def map_users( self, host: dict[str, typing.Any], device: authentik.endpoints.models.Device):
102    def map_users(self, host: dict[str, Any], device: Device):
103        for raw_user in host.get("device_mapping", []) or []:
104            user = User.objects.filter(email=raw_user["email"]).first()
105            if not user:
106                continue
107            DeviceUserBinding.objects.update_or_create(
108                target=device,
109                user=user,
110                create_defaults={
111                    "is_primary": True,
112                    "order": 0,
113                },
114            )
def map_access_group( self, host: dict[str, typing.Any], device: authentik.endpoints.models.Device):
116    def map_access_group(self, host: dict[str, Any], device: Device):
117        team_name = host.get("team_name")
118        if not team_name:
119            return
120        group, _ = DeviceAccessGroup.objects.get_or_create(name=team_name)
121        group.attributes["io.goauthentik.endpoints.connectors.fleet.team_id"] = host["team_id"]
122        if device.access_group:
123            return
124        device.access_group = group
125        device.save()
@staticmethod
def os_family(host: dict[str, typing.Any]) -> authentik.endpoints.facts.OSFamily:
127    @staticmethod
128    def os_family(host: dict[str, Any]) -> OSFamily:
129        if host["platform_like"] in ["debian", "rhel"]:
130            return OSFamily.linux
131        if host["platform_like"] == "windows":
132            return OSFamily.windows
133        if host["platform_like"] == "darwin":
134            return OSFamily.macOS
135        if host["platform"] == "android":
136            return OSFamily.android
137        if host["platform"] in ["ipados", "ios"]:
138            return OSFamily.iOS
139        return OSFamily.other
def map_os(self, host: dict[str, typing.Any]) -> dict[str, str]:
141    def map_os(self, host: dict[str, Any]) -> dict[str, str]:
142        family = FleetController.os_family(host)
143        os = {
144            "arch": self.or_none(host["cpu_type"]),
145            "family": family,
146            "name": self.or_none(host["platform_like"]),
147            "version": self.or_none(host["os_version"]),
148        }
149        if not host["os_version"]:
150            return delete_none_values(os)
151        version = re.search(r"(\d+\.(?:\d+\.?)+)", host["os_version"])
152        if not version:
153            return delete_none_values(os)
154        os["version"] = host["os_version"][version.start() :].strip()
155        os["name"] = host["os_version"][0 : version.start()].strip()
156        return delete_none_values(os)
def or_none(self, value) -> Any | None:
158    def or_none(self, value) -> Any | None:
159        if value == "":
160            return None
161        return value
def convert_host_data(self, host: dict[str, typing.Any]) -> dict[str, typing.Any]:
163    def convert_host_data(self, host: dict[str, Any]) -> dict[str, Any]:
164        """Convert host data from fleet to authentik"""
165        fleet_version = ""
166        for pkg in host.get("software") or []:
167            if pkg["name"] in ["fleet-osquery", "fleet-desktop"]:
168                fleet_version = pkg["version"]
169        data = {
170            "os": self.map_os(host),
171            "disks": [],
172            "network": delete_none_values(
173                {"hostname": self.or_none(host["hostname"]), "interfaces": []}
174            ),
175            "hardware": delete_none_values(
176                {
177                    "model": self.or_none(host["hardware_model"]),
178                    "manufacturer": self.or_none(host["hardware_vendor"]),
179                    "serial": self.or_none(host["hardware_serial"]),
180                    "cpu_name": self.or_none(host["cpu_brand"]),
181                    "cpu_count": self.or_none(host["cpu_logical_cores"]),
182                    "memory_bytes": self.or_none(host["memory"]),
183                }
184            ),
185            "software": [
186                delete_none_values(
187                    {
188                        "name": x["name"],
189                        "version": x["version"],
190                        "source": x["source"],
191                    }
192                )
193                for x in (host.get("software") or [])
194            ],
195            "vendor": {
196                "fleetdm.com": {
197                    "policies": [
198                        delete_none_values({"name": policy["name"], "status": policy["response"]})
199                        for policy in host.get("policies", [])
200                    ],
201                    "agent_version": fleet_version,
202                },
203            },
204        }
205        facts = DeviceFacts(data=data)
206        facts.is_valid(raise_exception=True)
207        return facts.validated_data

Convert host data from fleet to authentik