service.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. """Fail-closed application service for enterprise edge gateways."""
  2. from __future__ import annotations
  3. import hashlib
  4. import hmac
  5. import re
  6. import secrets
  7. import stat
  8. import uuid
  9. from collections.abc import Mapping, Sequence
  10. from contextlib import suppress
  11. from datetime import UTC, datetime, timedelta
  12. from pathlib import Path
  13. from cryptography.hazmat.primitives import serialization
  14. from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
  15. from app.core.edge_gateway.contracts import (
  16. EdgeContractError,
  17. EdgeEventContract,
  18. EdgeTaskContract,
  19. SignedTaskEnvelope,
  20. canonical_json_bytes,
  21. canonical_sha256,
  22. canonical_timestamp,
  23. )
  24. from app.core.edge_gateway.policy import EdgeEgressPolicy, EdgePolicyError
  25. _SHA256 = re.compile(r"^[0-9a-f]{64}$")
  26. _VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,79}$")
  27. _RELEASE_VERSION = re.compile(
  28. r"^(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})\.(0|[1-9][0-9]{0,9})$"
  29. )
  30. _HOST = re.compile(r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
  31. class EdgeGatewayError(Exception):
  32. status_code = 400
  33. code = "EDGE_GATEWAY_ERROR"
  34. class EdgeGatewayValidationError(EdgeGatewayError):
  35. code = "EDGE_GATEWAY_INVALID"
  36. class EdgeGatewayAuthenticationError(EdgeGatewayError):
  37. status_code = 401
  38. code = "EDGE_GATEWAY_AUTHENTICATION_FAILED"
  39. class EdgeGatewayConflictError(EdgeGatewayError):
  40. status_code = 409
  41. code = "EDGE_GATEWAY_CONFLICT"
  42. class EdgeGatewayPayloadTooLargeError(EdgeGatewayValidationError):
  43. status_code = 413
  44. code = "EDGE_GATEWAY_PAYLOAD_TOO_LARGE"
  45. class EdgeGatewayRateLimitError(EdgeGatewayError):
  46. status_code = 429
  47. code = "EDGE_GATEWAY_RATE_LIMITED"
  48. class EdgeGatewayNotFoundError(EdgeGatewayError):
  49. status_code = 404
  50. code = "EDGE_GATEWAY_NOT_FOUND"
  51. class EdgeGatewayConfigurationError(EdgeGatewayError):
  52. status_code = 503
  53. code = "EDGE_GATEWAY_SIGNING_UNAVAILABLE"
  54. def _digest(secret: str) -> str:
  55. return hashlib.sha256(secret.encode("utf-8")).hexdigest()
  56. def _uuid(value: object, label: str) -> str:
  57. try:
  58. return str(uuid.UUID(str(value)))
  59. except (ValueError, TypeError, AttributeError) as exc:
  60. raise EdgeGatewayValidationError(f"{label} is invalid") from exc
  61. def _sha(value: object, label: str) -> str:
  62. if not isinstance(value, str) or not _SHA256.fullmatch(value):
  63. raise EdgeGatewayValidationError(f"{label} is invalid")
  64. return value
  65. def _text(value: object, label: str, maximum: int) -> str:
  66. if not isinstance(value, str) or not value or value.strip() != value or len(value.encode()) > maximum or "\x00" in value:
  67. raise EdgeGatewayValidationError(f"{label} is invalid")
  68. return value
  69. def _version(value: object) -> str:
  70. value = _text(value, "version", 80)
  71. if not _VERSION.fullmatch(value):
  72. raise EdgeGatewayValidationError("version is invalid")
  73. return value
  74. def _release_version(value: object, label: str) -> str:
  75. value = _text(value, label, 80)
  76. if (
  77. not _RELEASE_VERSION.fullmatch(value)
  78. or any(int(part) > 2_147_483_647 for part in value.split("."))
  79. ):
  80. raise EdgeGatewayValidationError(f"{label} is invalid")
  81. return value
  82. def _release_version_tuple(value: str) -> tuple[int, ...]:
  83. return tuple(int(item) for item in value.split("."))
  84. def _artifact_name(value: object) -> str:
  85. value = _text(value, "artifact_name", 255)
  86. if (
  87. value in {".", ".."}
  88. or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,254}", value)
  89. or "/" in value
  90. or "\\" in value
  91. ):
  92. raise EdgeGatewayValidationError("artifact_name is invalid")
  93. return value
  94. def _future_deadline(value: object, *, now: datetime) -> str:
  95. try:
  96. canonical = canonical_timestamp(value, "deadline_at")
  97. deadline = datetime.fromisoformat(canonical.replace("Z", "+00:00"))
  98. except (EdgeContractError, TypeError, ValueError) as exc:
  99. raise EdgeGatewayValidationError("deadline_at is invalid") from exc
  100. if deadline <= now or deadline > now + timedelta(days=30):
  101. raise EdgeGatewayValidationError("deadline_at is invalid")
  102. return canonical
  103. def _signing_private_key(value: object) -> Ed25519PrivateKey | None:
  104. if value is None or value == "":
  105. return None
  106. if isinstance(value, Ed25519PrivateKey):
  107. return value
  108. if isinstance(value, str):
  109. encoded = value.encode("utf-8")
  110. if re.fullmatch(r"[0-9a-fA-F]{64}", value):
  111. encoded = bytes.fromhex(value)
  112. elif isinstance(value, bytes):
  113. encoded = value
  114. else:
  115. raise EdgeGatewayConfigurationError("edge signing authority is invalid")
  116. try:
  117. if isinstance(encoded, bytes) and len(encoded) == 32:
  118. return Ed25519PrivateKey.from_private_bytes(encoded)
  119. key = serialization.load_pem_private_key(encoded, password=None)
  120. except (TypeError, ValueError) as exc:
  121. raise EdgeGatewayConfigurationError("edge signing authority is invalid") from exc
  122. if not isinstance(key, Ed25519PrivateKey):
  123. raise EdgeGatewayConfigurationError("edge signing authority is invalid")
  124. return key
  125. def _hosts(values: object, label: str, *, required: bool) -> list[str]:
  126. if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
  127. raise EdgeGatewayValidationError(f"{label} is invalid")
  128. result = []
  129. for item in values:
  130. if not isinstance(item, str) or item != item.lower() or not _HOST.fullmatch(item) or ".." in item:
  131. raise EdgeGatewayValidationError(f"{label} is invalid")
  132. result.append(item)
  133. if required and not result:
  134. raise EdgeGatewayValidationError(f"{label} is required")
  135. if len(result) != len(set(result)) or len(result) > 32:
  136. raise EdgeGatewayValidationError(f"{label} is invalid")
  137. return sorted(result)
  138. def _environment(value: object) -> str:
  139. if value not in {"development", "staging", "production"}:
  140. raise EdgeGatewayValidationError("environment is invalid")
  141. return str(value)
  142. def _page(limit: object = 50, offset: object = 0) -> tuple[int, int]:
  143. try:
  144. limit, offset = int(limit), int(offset)
  145. except (TypeError, ValueError) as exc:
  146. raise EdgeGatewayValidationError("pagination is invalid") from exc
  147. if not 1 <= limit <= 100 or not 0 <= offset <= 10_000:
  148. raise EdgeGatewayValidationError("pagination is invalid")
  149. return limit, offset
  150. def _safe_summary(value: object, classification: str = "diagnostic_summary") -> dict:
  151. if not isinstance(value, Mapping):
  152. raise EdgeGatewayValidationError("safe_summary is invalid")
  153. candidate = dict(value)
  154. try:
  155. EdgeEgressPolicy(allowed_control_hosts={"validation.invalid"}).validate_approved_payload(classification, candidate)
  156. except (EdgePolicyError, ValueError) as exc:
  157. raise EdgeGatewayValidationError("safe_summary contains unapproved content") from exc
  158. return candidate
  159. class EdgeGatewayService:
  160. def __init__(
  161. self, repository, *, signing_private_key=None,
  162. signing_private_key_file=None, signing_key_provider=None,
  163. signing_public_key=None, signing_key_id=None, production=False, clock=None,
  164. ):
  165. self.repository = repository
  166. sources = sum(
  167. item not in {None, ""}
  168. for item in (signing_private_key, signing_private_key_file)
  169. ) + int(signing_key_provider is not None)
  170. if sources > 1:
  171. raise EdgeGatewayConfigurationError("edge signing authority source is ambiguous")
  172. if production and signing_private_key not in {None, ""}:
  173. raise EdgeGatewayConfigurationError(
  174. "production inline edge signing private key is forbidden"
  175. )
  176. key_material = signing_private_key
  177. if signing_private_key_file not in {None, ""}:
  178. if not isinstance(signing_private_key_file, str):
  179. raise EdgeGatewayConfigurationError("edge signing key file is invalid")
  180. path = Path(signing_private_key_file)
  181. try:
  182. if (
  183. not path.is_absolute()
  184. or not path.is_file()
  185. or path.is_symlink()
  186. or stat.S_IMODE(path.stat().st_mode) != 0o600
  187. ):
  188. raise EdgeGatewayConfigurationError(
  189. "edge signing key file permissions must be 0600"
  190. )
  191. key_material = path.read_bytes()
  192. except OSError as exc:
  193. raise EdgeGatewayConfigurationError(
  194. "edge signing key file is unavailable"
  195. ) from exc
  196. elif signing_key_provider is not None:
  197. if not callable(signing_key_provider):
  198. raise EdgeGatewayConfigurationError("edge signing key provider is invalid")
  199. try:
  200. key_material = signing_key_provider()
  201. except Exception as exc:
  202. raise EdgeGatewayConfigurationError(
  203. "edge signing key provider is unavailable"
  204. ) from exc
  205. self._signing_private_key = _signing_private_key(key_material)
  206. if self._signing_private_key is not None and signing_public_key not in {None, ""}:
  207. if (
  208. not isinstance(signing_public_key, str)
  209. or not re.fullmatch(r"[0-9a-f]{64}", signing_public_key)
  210. ):
  211. raise EdgeGatewayConfigurationError("edge signing public key is invalid")
  212. actual_public = self._signing_private_key.public_key().public_bytes(
  213. serialization.Encoding.Raw,
  214. serialization.PublicFormat.Raw,
  215. ).hex()
  216. if not hmac.compare_digest(actual_public, signing_public_key):
  217. raise EdgeGatewayConfigurationError(
  218. "edge signing public key does not match private key"
  219. )
  220. elif production and self._signing_private_key is not None:
  221. raise EdgeGatewayConfigurationError(
  222. "production edge signing public key is required"
  223. )
  224. self._signing_key_id = signing_key_id
  225. self._clock = clock or (lambda: datetime.now(UTC))
  226. def _require_signer(self) -> tuple[Ed25519PrivateKey, str]:
  227. key_id = self._signing_key_id
  228. if (
  229. self._signing_private_key is None
  230. or not isinstance(key_id, str)
  231. or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", key_id)
  232. ):
  233. self.repository.rollback()
  234. raise EdgeGatewayConfigurationError(
  235. "edge signing authority is not configured"
  236. )
  237. return self._signing_private_key, key_id
  238. def _signed_task_envelope(self, contract: EdgeTaskContract) -> dict:
  239. private_key, key_id = self._require_signer()
  240. issued_at = canonical_timestamp(
  241. self._clock().astimezone(UTC).isoformat().replace("+00:00", "Z")
  242. )
  243. unsigned = {
  244. "task": contract.to_mapping(),
  245. "authority_key_id": key_id,
  246. "signature_algorithm": "Ed25519",
  247. "contract_digest": contract.digest,
  248. "gateway_id": contract.gateway_id,
  249. "environment": contract.environment,
  250. "network_zone": contract.network_zone,
  251. "policy_digest": contract.policy_digest,
  252. "purpose": contract.purpose,
  253. "issued_at": issued_at,
  254. "expires_at": contract.deadline_at,
  255. }
  256. try:
  257. signed_bytes = SignedTaskEnvelope.canonical_unsigned_bytes(unsigned)
  258. except EdgeContractError as exc:
  259. self.repository.rollback()
  260. raise EdgeGatewayValidationError(
  261. "edge task authority validity is invalid"
  262. ) from exc
  263. return {**unsigned, "signature": private_key.sign(signed_bytes).hex()}
  264. def _signed_release_manifest(self, unsigned: Mapping[str, object]) -> dict:
  265. private_key, key_id = self._require_signer()
  266. values = dict(unsigned)
  267. values["signature_algorithm"] = "Ed25519"
  268. values["key_id"] = key_id
  269. digest = canonical_sha256(values)
  270. return {
  271. **values,
  272. "manifest_digest": digest,
  273. "signature": private_key.sign(canonical_json_bytes(values)).hex(),
  274. }
  275. def _failure(
  276. self, gateway_id, event_type: str, reason_code: str, *, trusted: bool = False,
  277. ) -> int:
  278. count = self.repository.audit_failure(
  279. gateway_id, event_type, reason_code, trusted=trusted,
  280. )
  281. if count >= 10:
  282. raise EdgeGatewayRateLimitError("edge failure rate limit exceeded")
  283. return count
  284. def create_enrollment(
  285. self, *, gateway_name, environment, network_zone, policy_digest,
  286. allowed_control_hosts, allowed_proxy_hosts, ttl_seconds, actor_uid,
  287. expected_certificate_sha256,
  288. ) -> dict:
  289. ttl = int(ttl_seconds)
  290. if ttl < 60 or ttl > 86_400:
  291. raise EdgeGatewayValidationError("enrollment TTL is invalid")
  292. gateway_id, uid = str(uuid.uuid4()), str(uuid.uuid4())
  293. token = "dope_" + secrets.token_urlsafe(32)
  294. values = {
  295. "uid": uid, "gateway_id": gateway_id,
  296. "gateway_name": _text(gateway_name, "gateway_name", 200),
  297. "environment": _environment(environment),
  298. "network_zone": _text(network_zone, "network_zone", 120),
  299. "policy_digest": _sha(policy_digest, "policy_digest"),
  300. "expected_certificate_sha256": _sha(expected_certificate_sha256, "expected_certificate_sha256"),
  301. "control_hosts": _hosts(allowed_control_hosts, "allowed_control_hosts", required=True),
  302. "proxy_hosts": _hosts(allowed_proxy_hosts, "allowed_proxy_hosts", required=False),
  303. "token_hash": _digest(token), "ttl": ttl, "actor_uid": _uuid(actor_uid, "actor_uid"),
  304. }
  305. self.repository.create_enrollment(values)
  306. return {"enrollment_id": uid, "gateway_id": gateway_id, "enrollment_token": token, "expires_in": ttl, "returned_once": True}
  307. def register(
  308. self, *, enrollment_token, certificate_sha256, gateway_id, environment,
  309. network_zone, policy_digest, allowed_control_hosts, allowed_proxy_hosts, version,
  310. ) -> dict:
  311. gateway_id = _uuid(gateway_id, "gateway_id")
  312. if not isinstance(enrollment_token, str) or not enrollment_token.startswith("dope_") or len(enrollment_token) > 128:
  313. self._failure(gateway_id, "registration_rejected", "enrollment_invalid")
  314. raise EdgeGatewayAuthenticationError("edge enrollment is invalid or expired")
  315. try:
  316. certificate = _sha(certificate_sha256, "certificate_sha256")
  317. supplied = {
  318. "gateway_id": gateway_id, "environment": _environment(environment),
  319. "network_zone": _text(network_zone, "network_zone", 120),
  320. "policy_digest": _sha(policy_digest, "policy_digest"),
  321. "allowed_control_hosts": _hosts(allowed_control_hosts, "allowed_control_hosts", required=True),
  322. "allowed_proxy_hosts": _hosts(allowed_proxy_hosts, "allowed_proxy_hosts", required=False),
  323. }
  324. except EdgeGatewayValidationError as exc:
  325. self._failure(gateway_id, "registration_rejected", "binding_invalid")
  326. raise EdgeGatewayAuthenticationError("edge enrollment binding was rejected") from exc
  327. enrollment = self.repository.lock_enrollment(_digest(enrollment_token))
  328. now = datetime.now(UTC)
  329. if enrollment is None or enrollment["status"] != "pending" or enrollment["expires_at"] <= now:
  330. self._failure(supplied["gateway_id"], "registration_rejected", "enrollment_invalid")
  331. raise EdgeGatewayAuthenticationError("edge enrollment is invalid or expired")
  332. expected = {key: enrollment[key] for key in supplied}
  333. expected["allowed_control_hosts"] = sorted(expected["allowed_control_hosts"])
  334. expected["allowed_proxy_hosts"] = sorted(expected["allowed_proxy_hosts"])
  335. if any(not hmac.compare_digest(str(expected[key]), str(supplied[key])) for key in supplied) or not hmac.compare_digest(enrollment["expected_certificate_sha256"], certificate):
  336. self._failure(supplied["gateway_id"], "registration_rejected", "binding_mismatch")
  337. raise EdgeGatewayAuthenticationError("edge enrollment binding was rejected")
  338. credential = "dopg_" + secrets.token_urlsafe(40)
  339. try:
  340. result = self.repository.complete_registration(enrollment, _digest(credential), certificate, _version(version))
  341. except Exception:
  342. self.repository.rollback()
  343. raise
  344. return {**result, "credential": credential, "credential_returned_once": True, "certificate_sha256": certificate}
  345. def authenticate(self, *, credential, certificate_sha256, gateway_id, environment, network_zone, generation, lock=False) -> dict:
  346. try:
  347. gateway_id = _uuid(gateway_id, "gateway_id")
  348. generation = int(generation)
  349. supplied = (
  350. gateway_id, _environment(environment),
  351. _text(network_zone, "network_zone", 120), generation,
  352. _sha(certificate_sha256, "certificate_sha256"),
  353. )
  354. except EdgeGatewayValidationError as exc:
  355. with suppress(EdgeGatewayValidationError):
  356. self._failure(_uuid(gateway_id, "gateway_id"), "authentication_rejected", "binding_invalid")
  357. raise EdgeGatewayAuthenticationError("edge credential binding was rejected") from exc
  358. except (TypeError, ValueError) as exc:
  359. self._failure(gateway_id, "authentication_rejected", "binding_invalid")
  360. raise EdgeGatewayAuthenticationError("edge credential binding was rejected") from exc
  361. if not isinstance(credential, str) or not credential.startswith("dopg_") or len(credential) > 160:
  362. self._failure(gateway_id, "authentication_rejected", "credential_invalid")
  363. raise EdgeGatewayAuthenticationError("edge credential binding was rejected")
  364. row = self.repository.authenticate(_digest(credential), lock=lock)
  365. if row is None:
  366. self._failure(supplied[0], "authentication_rejected", "credential_invalid")
  367. raise EdgeGatewayAuthenticationError("edge credential binding was rejected")
  368. expected = (row["gateway_id"], row["environment"], row["network_zone"], row["generation"], row["certificate_sha256"])
  369. if any(not hmac.compare_digest(str(a), str(b)) for a, b in zip(supplied, expected, strict=True)):
  370. self._failure(row["gateway_id"], "authentication_rejected", "binding_mismatch")
  371. raise EdgeGatewayAuthenticationError("edge credential binding was rejected")
  372. return dict(row)
  373. def list_gateways(self, *, limit=50, offset=0) -> list[dict]:
  374. limit, offset = _page(limit, offset)
  375. return self.repository.list_gateways(limit=limit, offset=offset)
  376. def heartbeat(self, *, version, safe_summary, **auth) -> dict:
  377. version = _version(version)
  378. safe_summary = _safe_summary(safe_summary, "health_summary")
  379. identity = self.authenticate(**auth, lock=True)
  380. result = self.repository.heartbeat(identity["gateway_id"], version, safe_summary)
  381. result["status"] = "online"
  382. return result
  383. def rotate(self, gateway_id, *, certificate_sha256, request_id, actor_uid) -> dict:
  384. request_id = _text(request_id, "request_id", 255)
  385. certificate_sha256 = _sha(certificate_sha256, "certificate_sha256")
  386. request_digest = canonical_sha256({"gateway_id": str(gateway_id), "certificate_sha256": certificate_sha256})
  387. token = "dopg_" + secrets.token_urlsafe(40)
  388. gateway_id = _uuid(gateway_id, "gateway_id")
  389. result = self.repository.rotate(gateway_id, _digest(token), certificate_sha256, _uuid(actor_uid, "actor_uid"), request_id, request_digest)
  390. if result is None:
  391. raise EdgeGatewayNotFoundError("edge gateway was not found")
  392. if result.get("conflict"):
  393. raise EdgeGatewayConflictError("rotation request conflicts with an existing request")
  394. if result.get("replayed"):
  395. return {"gateway_id": gateway_id, "generation": result["generation"], "credential": None, "credential_returned_once": False, "replayed": True}
  396. return {**result, "gateway_id": gateway_id, "credential": token, "credential_returned_once": True}
  397. def revoke(self, gateway_id, *, actor_uid) -> bool:
  398. return self.repository.revoke(_uuid(gateway_id, "gateway_id"), _uuid(actor_uid, "actor_uid"))
  399. def issue_task(self, value: Mapping[str, object], *, actor_uid) -> dict:
  400. try:
  401. contract = EdgeTaskContract.from_mapping(value)
  402. except EdgeContractError as exc:
  403. raise EdgeGatewayValidationError("edge task contract is invalid") from exc
  404. if _uuid(contract.gateway_id, "gateway_id") != contract.gateway_id:
  405. raise EdgeGatewayValidationError("gateway_id is invalid")
  406. gateway = self.repository.gateway_binding(contract.gateway_id)
  407. if gateway is None or gateway["status"] not in {"active", "offline"}:
  408. self.repository.rollback()
  409. raise EdgeGatewayNotFoundError("edge gateway was not found")
  410. if any(
  411. getattr(contract, field) != gateway[field]
  412. for field in ("environment", "network_zone", "policy_digest")
  413. ):
  414. self._failure(
  415. contract.gateway_id, "binding_rejected", "task_binding_mismatch",
  416. trusted=True,
  417. )
  418. raise EdgeGatewayValidationError("edge task gateway binding is invalid")
  419. authority = self._signed_task_envelope(contract)
  420. row = self.repository.insert_task(
  421. contract.to_mapping(), contract.digest, authority,
  422. _uuid(actor_uid, "actor_uid"),
  423. )
  424. if not row:
  425. raise EdgeGatewayNotFoundError("edge gateway was not found")
  426. if row["contract_digest"] != contract.digest:
  427. self._failure(
  428. contract.gateway_id, "idempotency_conflict", "task_contract_conflict",
  429. trusted=True,
  430. )
  431. raise EdgeGatewayConflictError("task idempotency key conflicts with an existing contract")
  432. return {"task_id": row["task_id"], "contract_digest": row["contract_digest"]}
  433. def pull_task(self, **auth) -> dict:
  434. identity = self.authenticate(**auth, lock=True)
  435. token = "dopl_" + secrets.token_urlsafe(32)
  436. row = self.repository.pull_task(identity["gateway_id"], _digest(token))
  437. if row is None:
  438. return {"task": None}
  439. envelope = dict(row["authority"])
  440. if envelope.get("task") != row["contract"]:
  441. self.repository.rollback()
  442. raise EdgeGatewayConfigurationError("stored task authority is invalid")
  443. return {
  444. "task": row["contract"],
  445. "signed_task_envelope": envelope,
  446. "lease_token": token,
  447. "lease_expires_at": row["lease_expires_at"].isoformat(),
  448. }
  449. def cancel_task(self, task_id, *, actor_uid) -> bool:
  450. return self.repository.cancel_task(_text(task_id, "task_id", 255), _uuid(actor_uid, "actor_uid"))
  451. def task_outcome(self, task_id, *, outcome, lease_token, safe_summary, **auth) -> dict:
  452. if outcome not in {"completed", "failed", "cancelled"}:
  453. raise EdgeGatewayValidationError("task outcome is invalid")
  454. if not isinstance(lease_token, str) or not lease_token.startswith("dopl_") or len(lease_token) > 128:
  455. raise EdgeGatewayAuthenticationError("edge task lease binding was rejected")
  456. summary = _safe_summary(safe_summary)
  457. identity = self.authenticate(**auth, lock=True)
  458. result = self.repository.task_outcome(
  459. identity["gateway_id"], _text(task_id, "task_id", 255),
  460. _digest(lease_token), outcome, summary,
  461. )
  462. if result is None:
  463. self._failure(
  464. identity["gateway_id"], "binding_rejected", "task_outcome_rejected",
  465. trusted=True,
  466. )
  467. raise EdgeGatewayConflictError("task outcome was rejected")
  468. return result
  469. def reconcile(
  470. self, *, limit=50, cancel_cursor=None, release_cursor=None, **auth
  471. ) -> dict:
  472. limit, _ = _page(limit, 0)
  473. if cancel_cursor is not None:
  474. cancel_cursor = _text(cancel_cursor, "cancel_cursor", 255)
  475. if release_cursor is not None:
  476. release_cursor = _uuid(release_cursor, "release_cursor")
  477. identity = self.authenticate(**auth, lock=True)
  478. cancellations = self.repository.cancelled_tasks(
  479. identity["gateway_id"], limit=limit, cursor=cancel_cursor
  480. )
  481. releases = self.repository.unfinished_releases(
  482. identity["gateway_id"], limit=limit, cursor=release_cursor
  483. )
  484. result = {
  485. "cancelled_task_ids": cancellations["items"],
  486. "cancel_next_cursor": cancellations["next_cursor"],
  487. "release_offers": releases["items"],
  488. "release_next_cursor": releases["next_cursor"],
  489. "release_baseline": self.repository.latest_release_baseline(
  490. identity["gateway_id"]
  491. ),
  492. }
  493. self.repository.commit()
  494. return result
  495. def accept_event(self, *, event, lease_token=None, **auth) -> dict:
  496. identity = self.authenticate(**auth, lock=True)
  497. if not isinstance(event, Mapping):
  498. self.repository.rollback()
  499. raise EdgeGatewayValidationError("edge event contract is invalid")
  500. policy = EdgeEgressPolicy(allowed_control_hosts=set(identity["allowed_control_hosts"]), allowed_proxy_hosts=set(identity["allowed_proxy_hosts"]))
  501. try:
  502. approved = policy.approve_event(event)
  503. except EdgePolicyError as exc:
  504. self._failure(identity["gateway_id"], "policy_rejected", "egress_policy_rejected", trusted=True)
  505. raise EdgeGatewayValidationError("edge event is not approved for control-plane egress") from exc
  506. try:
  507. contract = EdgeEventContract.from_mapping(approved)
  508. except EdgeContractError as exc:
  509. self._failure(identity["gateway_id"], "binding_rejected", "event_contract_invalid", trusted=True)
  510. raise EdgeGatewayValidationError("edge event contract is invalid") from exc
  511. if contract.gateway_id != identity["gateway_id"] or contract.environment != identity["environment"] or contract.network_zone != identity["network_zone"] or contract.policy_digest != identity["policy_digest"]:
  512. self._failure(identity["gateway_id"], "binding_rejected", "event_gateway_mismatch", trusted=True)
  513. raise EdgeGatewayAuthenticationError("edge event binding was rejected")
  514. supplied_digest = contract.digest
  515. existing = self.repository.event_by_id(identity["gateway_id"], contract.event_id)
  516. if existing is not None:
  517. if not hmac.compare_digest(existing["contract_digest"], supplied_digest):
  518. self._failure(identity["gateway_id"], "idempotency_conflict", "event_contract_conflict", trusted=True)
  519. raise EdgeGatewayConflictError("event identifier conflicts with an existing contract")
  520. self.repository.rollback()
  521. return dict(existing["ack"])
  522. if not isinstance(lease_token, str) or not lease_token.startswith("dopl_") or len(lease_token) > 128:
  523. self._failure(identity["gateway_id"], "binding_rejected", "lease_invalid", trusted=True)
  524. raise EdgeGatewayAuthenticationError("edge task lease binding was rejected")
  525. remote_lease_digest = _digest(lease_token)
  526. task_contract = self.repository.task_lease_binding(
  527. contract.task_id, contract.gateway_id, remote_lease_digest
  528. )
  529. if task_contract is None:
  530. existing = self.repository.event_by_id(identity["gateway_id"], contract.event_id)
  531. if existing is not None and hmac.compare_digest(existing["contract_digest"], supplied_digest):
  532. self.repository.rollback()
  533. return dict(existing["ack"])
  534. self._failure(identity["gateway_id"], "binding_rejected", "lease_mismatch", trusted=True)
  535. raise EdgeGatewayAuthenticationError("edge task lease binding was rejected")
  536. if any(contract.to_mapping()[field] != task_contract[field] for field in (
  537. "gateway_id", "environment", "network_zone", "purpose", "policy_digest",
  538. "attempt",
  539. )):
  540. self._failure(identity["gateway_id"], "binding_rejected", "event_task_mismatch", trusted=True)
  541. raise EdgeGatewayValidationError("edge event task binding is invalid")
  542. ack = {
  543. "event_id": contract.event_id,
  544. "event_digest": contract.digest,
  545. "remote_lease_digest": remote_lease_digest,
  546. "received_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
  547. "status": "accepted",
  548. }
  549. accepted = self.repository.insert_event(contract.to_mapping(), contract.digest, ack)
  550. if accepted is None:
  551. self._failure(identity["gateway_id"], "idempotency_conflict", "event_contract_conflict", trusted=True)
  552. raise EdgeGatewayConflictError("event identifier conflicts with an existing contract")
  553. return accepted
  554. def offer_release(
  555. self, gateway_id, *, version, artifact_digest, rollback_version,
  556. request_id, actor_uid, artifact_name=None, deadline_at=None,
  557. ) -> dict:
  558. gateway_id = _uuid(gateway_id, "gateway_id")
  559. version = _release_version(version, "version")
  560. artifact_digest = _sha(artifact_digest, "artifact_digest")
  561. rollback_version = _release_version(rollback_version, "rollback_version")
  562. if _release_version_tuple(version) <= _release_version_tuple(rollback_version):
  563. raise EdgeGatewayValidationError(
  564. "release version must be newer than rollback_version"
  565. )
  566. request_id = _text(request_id, "request_id", 255)
  567. now = self._clock().astimezone(UTC)
  568. supplied_deadline = deadline_at
  569. artifact_name = _artifact_name(
  570. artifact_name or f"edge-agent-{version}.bin"
  571. )
  572. deadline_at = _future_deadline(
  573. deadline_at
  574. or (now + timedelta(hours=24)).isoformat().replace("+00:00", "Z"),
  575. now=now,
  576. )
  577. unsigned = {
  578. "release_id": str(uuid.uuid4()), "version": version,
  579. "rollback_version": rollback_version,
  580. "artifact_digest": artifact_digest, "artifact_name": artifact_name,
  581. "deadline_at": deadline_at, "status": "offered",
  582. }
  583. manifest = self._signed_release_manifest(unsigned)
  584. request_digest = canonical_sha256({
  585. "gateway_id": gateway_id, "version": version,
  586. "artifact_digest": artifact_digest, "rollback_version": rollback_version,
  587. "artifact_name": artifact_name,
  588. "deadline_at": deadline_at if supplied_deadline is not None else None,
  589. })
  590. result = self.repository.offer_release(
  591. gateway_id, manifest, _uuid(actor_uid, "actor_uid"), request_id,
  592. request_digest,
  593. )
  594. if result.get("conflict"):
  595. raise EdgeGatewayConflictError("release request conflicts with an existing request")
  596. if not result:
  597. raise EdgeGatewayNotFoundError("edge gateway was not found")
  598. return result
  599. def acknowledge_release(self, *, release_id, outcome, safe_summary, **auth) -> dict:
  600. statuses = {"accepted": "accepted", "installed": "installed", "failed": "failed", "rollback": "rolled_back"}
  601. if outcome not in statuses:
  602. raise EdgeGatewayValidationError("release outcome is invalid")
  603. release_id = _uuid(release_id, "release_id")
  604. safe_summary = _safe_summary(safe_summary)
  605. identity = self.authenticate(**auth, lock=True)
  606. current = self.repository.release_for_ack(identity["gateway_id"], release_id)
  607. if current is None:
  608. self.repository.rollback()
  609. self._failure(
  610. identity["gateway_id"], "release_rejected",
  611. "release_transition_invalid", trusted=True,
  612. )
  613. raise EdgeGatewayConflictError("release acknowledgement was rejected")
  614. stored = dict(current["signed_manifest"])
  615. unsigned = {
  616. key: value for key, value in stored.items()
  617. if key not in {
  618. "manifest_digest", "signature", "signature_algorithm", "key_id"
  619. }
  620. }
  621. unsigned["status"] = statuses[outcome]
  622. manifest = self._signed_release_manifest(unsigned)
  623. row = self.repository.acknowledge_release(
  624. identity["gateway_id"], release_id, statuses[outcome], safe_summary,
  625. manifest,
  626. )
  627. if row is None:
  628. self._failure(identity["gateway_id"], "release_rejected", "release_transition_invalid", trusted=True)
  629. raise EdgeGatewayConflictError("release acknowledgement was rejected")
  630. return row