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
class
FleetController(authentik.endpoints.controller.BaseController[authentik.enterprise.endpoints.connectors.fleet.models.FleetConnector]):
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 )
@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 )
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()
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
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