fdai-deployment-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,214 @@
1
+ """Strict reviewed-control profile validation for bounded Entra convergence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ _GUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")
9
+ _SLOTS = frozenset({"readers", "contributors", "approvers", "owners", "break_glass"})
10
+ _METHODS = frozenset({"fido2", "windowsHelloForBusiness", "x509Certificate"})
11
+
12
+
13
+ def validate_control_profile(value: dict[str, Any]) -> None:
14
+ if (
15
+ set(value)
16
+ != {
17
+ "schema_version",
18
+ "premium_service_plan",
19
+ "role_groups",
20
+ "conditional_access_policies",
21
+ "access_reviews",
22
+ "authentication_methods",
23
+ "azure_policy_assignments",
24
+ }
25
+ or value.get("schema_version") != "fdai.entra-control-profile.v1"
26
+ ):
27
+ raise ValueError("Entra control profile fields are invalid")
28
+ if value.get("premium_service_plan") not in {"AAD_PREMIUM", "AAD_PREMIUM_P2"}:
29
+ raise ValueError("Entra premium service plan is invalid")
30
+ groups = value.get("role_groups")
31
+ if (
32
+ not isinstance(groups, dict)
33
+ or set(groups) != _SLOTS
34
+ or any(
35
+ not isinstance(item, str) or _GUID.fullmatch(item) is None for item in groups.values()
36
+ )
37
+ or len(set(groups.values())) != len(_SLOTS)
38
+ ):
39
+ raise ValueError("Entra role group profile is invalid")
40
+ _validate_ca(value.get("conditional_access_policies"))
41
+ _validate_reviews(value.get("access_reviews"))
42
+ _validate_methods(value.get("authentication_methods"))
43
+ _validate_azure_policy(value.get("azure_policy_assignments"))
44
+
45
+
46
+ def _validate_ca(value: object) -> None:
47
+ rows = _nonempty_dict_list(value, "Conditional Access profile")
48
+ for row in rows:
49
+ if set(row) != {
50
+ "policy_id",
51
+ "include_group_slots",
52
+ "exclude_group_slots",
53
+ "include_users",
54
+ "exclude_users",
55
+ "include_roles",
56
+ "exclude_roles",
57
+ "client_app_types",
58
+ "include_applications",
59
+ "exclude_applications",
60
+ "include_platforms",
61
+ "exclude_platforms",
62
+ "include_locations",
63
+ "exclude_locations",
64
+ "sign_in_risk_levels",
65
+ "user_risk_levels",
66
+ "device_filter_mode",
67
+ "device_filter_rule",
68
+ "grant_operator",
69
+ "grant_controls",
70
+ "authentication_strength_id",
71
+ }:
72
+ raise ValueError("Conditional Access profile fields are invalid")
73
+ include_slots = _sorted_strings(row["include_group_slots"])
74
+ exclude_slots = _sorted_strings(row["exclude_group_slots"])
75
+ controls = _sorted_strings(row["grant_controls"])
76
+ strength = row["authentication_strength_id"]
77
+ if (
78
+ _GUID.fullmatch(_text(row, "policy_id")) is None
79
+ or not include_slots
80
+ or not set(include_slots).issubset(_SLOTS)
81
+ or not set(exclude_slots).issubset(_SLOTS)
82
+ or set(include_slots) & set(exclude_slots)
83
+ or row["grant_operator"] not in {"AND", "OR"}
84
+ or not controls
85
+ or not set(controls).issubset({"mfa", "compliantDevice", "authenticationStrength"})
86
+ or (
87
+ ("authenticationStrength" in controls)
88
+ != (isinstance(strength, str) and _GUID.fullmatch(strength) is not None)
89
+ )
90
+ ):
91
+ raise ValueError("Conditional Access profile values are invalid")
92
+ for key in (
93
+ "include_users",
94
+ "exclude_users",
95
+ "include_roles",
96
+ "exclude_roles",
97
+ "client_app_types",
98
+ "include_applications",
99
+ "exclude_applications",
100
+ "include_platforms",
101
+ "exclude_platforms",
102
+ "include_locations",
103
+ "exclude_locations",
104
+ "sign_in_risk_levels",
105
+ "user_risk_levels",
106
+ ):
107
+ _sorted_strings(row[key])
108
+ if (
109
+ row["device_filter_mode"] not in {None, "include", "exclude"}
110
+ or (row["device_filter_mode"] is None and row["device_filter_rule"] is not None)
111
+ or (
112
+ row["device_filter_mode"] is not None
113
+ and not _bounded_ascii(row["device_filter_rule"])
114
+ )
115
+ ):
116
+ raise ValueError("Conditional Access device filter is invalid")
117
+
118
+
119
+ def _validate_reviews(value: object) -> None:
120
+ rows = _nonempty_dict_list(value, "Access Review profile")
121
+ for row in rows:
122
+ if set(row) != {
123
+ "definition_id",
124
+ "group_slot",
125
+ "scope_query_type",
126
+ "recurrence_pattern_type",
127
+ "recurrence_interval",
128
+ "recurrence_range_type",
129
+ "duration_days",
130
+ "allowed_statuses",
131
+ }:
132
+ raise ValueError("Access Review profile fields are invalid")
133
+ if (
134
+ _GUID.fullmatch(_text(row, "definition_id")) is None
135
+ or row["group_slot"] not in _SLOTS
136
+ or row["scope_query_type"] != "MicrosoftGraph"
137
+ or row["recurrence_pattern_type"] not in {"absoluteMonthly", "weekly"}
138
+ or type(row["recurrence_interval"]) is not int
139
+ or not 1 <= row["recurrence_interval"] <= 12
140
+ or row["recurrence_range_type"] not in {"noEnd", "numbered"}
141
+ or type(row["duration_days"]) is not int
142
+ or not 1 <= row["duration_days"] <= 30
143
+ or not _sorted_strings(row["allowed_statuses"])
144
+ or not set(row["allowed_statuses"]).issubset({"NotStarted", "InProgress"})
145
+ ):
146
+ raise ValueError("Access Review profile values are invalid")
147
+
148
+
149
+ def _validate_methods(value: object) -> None:
150
+ rows = _nonempty_dict_list(value, "authentication method profile")
151
+ ids = []
152
+ for row in rows:
153
+ if set(row) != {"method_id", "state", "include_group_slots"}:
154
+ raise ValueError("authentication method profile fields are invalid")
155
+ method_id = _text(row, "method_id")
156
+ slots = _sorted_strings(row["include_group_slots"])
157
+ if (
158
+ method_id not in _METHODS
159
+ or row["state"] != "enabled"
160
+ or not slots
161
+ or not set(slots).issubset(_SLOTS)
162
+ ):
163
+ raise ValueError("authentication method profile values are invalid")
164
+ ids.append(method_id)
165
+ if len(ids) != len(set(ids)):
166
+ raise ValueError("authentication method profile repeats a method")
167
+
168
+
169
+ def _validate_azure_policy(value: object) -> None:
170
+ rows = _nonempty_dict_list(value, "Azure Policy profile")
171
+ for row in rows:
172
+ if set(row) != {"assignment_id", "definition_id", "enforcement_mode", "scope"}:
173
+ raise ValueError("Azure Policy profile fields are invalid")
174
+ if (
175
+ not _bounded_ascii(row["assignment_id"])
176
+ or not _bounded_ascii(row["definition_id"])
177
+ or row["enforcement_mode"] != "Default"
178
+ or not _bounded_ascii(row["scope"])
179
+ ):
180
+ raise ValueError("Azure Policy profile values are invalid")
181
+
182
+
183
+ def _nonempty_dict_list(value: object, label: str) -> list[dict[str, Any]]:
184
+ if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
185
+ raise ValueError("Entra control profile collection is invalid")
186
+ if not value:
187
+ raise ValueError(f"{label} MUST contain at least one reviewed control")
188
+ return value
189
+
190
+
191
+ def _sorted_strings(value: object) -> list[str]:
192
+ if (
193
+ not isinstance(value, list)
194
+ or any(not isinstance(item, str) or not _bounded_ascii(item) for item in value)
195
+ or value != sorted(set(value))
196
+ ):
197
+ raise ValueError("Entra control profile string collection is invalid")
198
+ return value
199
+
200
+
201
+ def _text(value: dict[str, Any], key: str) -> str:
202
+ item = value.get(key)
203
+ if not isinstance(item, str):
204
+ raise ValueError(f"Entra profile {key} is invalid")
205
+ return item
206
+
207
+
208
+ def _bounded_ascii(value: object) -> bool:
209
+ return (
210
+ isinstance(value, str)
211
+ and 0 < len(value) <= 2048
212
+ and value.isascii()
213
+ and value.isprintable()
214
+ )
@@ -0,0 +1,349 @@
1
+ """Private, bounded Microsoft Graph I/O for the Entra bootstrap adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import json
7
+ import queue
8
+ import re
9
+ import socket
10
+ import ssl
11
+ import threading
12
+ import time
13
+ from collections.abc import Callable, Mapping
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Protocol
16
+ from urllib.parse import urlencode, urlsplit
17
+ from uuid import UUID
18
+
19
+ HOST = "graph.microsoft.com"
20
+ MAX_BYTES = 1_048_576
21
+ MAX_REQUESTS = 96
22
+ Json = dict[str, Any]
23
+
24
+
25
+ class EntraBootstrapError(RuntimeError):
26
+ """Expose only stable failure codes, never provider text or private identifiers."""
27
+
28
+ def __init__(self, code: str, http_status: int | None = None) -> None:
29
+ self.code, self.http_status = code, http_status
30
+ super().__init__(f"Entra bootstrap: {code}")
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class GraphToken:
35
+ """In-memory provider result; Graph independently confirms its tenant."""
36
+
37
+ tenant_id: str = field(repr=False)
38
+ access_token: str = field(repr=False)
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class GraphResponse:
43
+ """One bounded HTTP response with an intentionally non-represented payload."""
44
+
45
+ status: int
46
+ body: bytes = field(default=b"", repr=False)
47
+
48
+
49
+ class GraphTransport(Protocol):
50
+ """Trusted injection seam: fixed Graph host, no retries, persistence, or redirects."""
51
+
52
+ def request(
53
+ self,
54
+ method: str,
55
+ target: str,
56
+ headers: Mapping[str, str],
57
+ body: bytes,
58
+ *,
59
+ deadline: float,
60
+ ) -> GraphResponse:
61
+ """Complete one request before its monotonic deadline."""
62
+ ...
63
+
64
+
65
+ def _remaining(deadline: float) -> float:
66
+ remaining = deadline - time.monotonic()
67
+ if not 0 < remaining <= 180:
68
+ raise EntraBootstrapError("deadline")
69
+ return min(10.0, remaining)
70
+
71
+
72
+ def _shutdown(sock: socket.socket) -> None:
73
+ try:
74
+ sock.shutdown(socket.SHUT_RDWR)
75
+ except OSError:
76
+ return
77
+
78
+
79
+ class HttpsGraphTransport:
80
+ """Verified stdlib HTTPS; bounded DNS, socket work, headers, and response body.
81
+
82
+ DNS workers have no credential or HTTP access. A late DNS result cannot send a
83
+ request. Only the first resolved address is attempted, without fallback/retry.
84
+ """
85
+
86
+ def request(
87
+ self,
88
+ method: str,
89
+ target: str,
90
+ headers: Mapping[str, str],
91
+ body: bytes,
92
+ *,
93
+ deadline: float,
94
+ ) -> GraphResponse:
95
+ """Send once to the fixed public Graph endpoint, ignoring proxy environment."""
96
+ parsed = urlsplit(target)
97
+ if (
98
+ method not in {"GET", "POST", "PATCH"}
99
+ or parsed.netloc
100
+ or parsed.scheme
101
+ or not target.startswith("/v1.0/")
102
+ or not target.isascii()
103
+ or any(ord(char) <= 32 or ord(char) == 127 for char in target)
104
+ or "\\" in target
105
+ or "#" in target
106
+ or len(target) > 4096
107
+ or len(body) > 131_072
108
+ or set(headers) != {"Authorization", "Content-Type", "Accept"}
109
+ or not re.fullmatch(r"Bearer [A-Za-z0-9._~+/-]+=*", headers["Authorization"])
110
+ or headers["Content-Type"] != "application/json"
111
+ or headers["Accept"] != "application/json"
112
+ ):
113
+ raise EntraBootstrapError("invalid-request")
114
+ resolved: queue.Queue[Any] = queue.Queue(maxsize=1)
115
+
116
+ def resolve() -> None:
117
+ try:
118
+ resolved.put(socket.getaddrinfo(HOST, 443, type=socket.SOCK_STREAM))
119
+ except OSError:
120
+ resolved.put(None)
121
+
122
+ connection = http.client.HTTPSConnection(HOST, context=ssl.create_default_context())
123
+ raw: socket.socket | None = None
124
+ timer: threading.Timer | None = None
125
+ try:
126
+ threading.Thread(target=resolve, daemon=True).start()
127
+ addresses = resolved.get(timeout=_remaining(deadline))
128
+ if not addresses:
129
+ raise EntraBootstrapError("transport")
130
+ family, kind, protocol, _, address = addresses[0]
131
+ raw = socket.socket(family, kind, protocol)
132
+ raw.settimeout(_remaining(deadline))
133
+ raw.connect(address)
134
+ raw.settimeout(_remaining(deadline))
135
+ connection.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=HOST)
136
+ timer = threading.Timer(
137
+ _remaining(deadline),
138
+ _shutdown,
139
+ args=(connection.sock,),
140
+ )
141
+ timer.daemon = True
142
+ timer.start()
143
+ connection.sock.settimeout(_remaining(deadline))
144
+ connection.request(method, target, body=body, headers=dict(headers))
145
+ response = connection.getresponse()
146
+ payload = bytearray()
147
+ if response.status in {200, 201}:
148
+ while True:
149
+ connection.sock.settimeout(_remaining(deadline))
150
+ chunk = response.read1(min(65_536, MAX_BYTES + 1 - len(payload)))
151
+ if not chunk:
152
+ break
153
+ payload.extend(chunk)
154
+ if len(payload) > MAX_BYTES:
155
+ raise EntraBootstrapError("response-limit")
156
+ _remaining(deadline)
157
+ return GraphResponse(response.status, bytes(payload))
158
+ except (TimeoutError, queue.Empty):
159
+ raise EntraBootstrapError("timeout") from None
160
+ except (OSError, ValueError, http.client.HTTPException):
161
+ raise EntraBootstrapError("transport") from None
162
+ finally:
163
+ if timer is not None:
164
+ timer.cancel()
165
+ connection.close()
166
+ if raw is not None:
167
+ raw.close()
168
+
169
+
170
+ def uuid(value: object) -> str:
171
+ """Require a nonzero UUID without echoing rejected values."""
172
+ if not isinstance(value, str) or not re.fullmatch(
173
+ r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value
174
+ ):
175
+ raise EntraBootstrapError("invalid-uuid")
176
+ result = UUID(value)
177
+ if not result.int:
178
+ raise EntraBootstrapError("invalid-uuid")
179
+ return str(result)
180
+
181
+
182
+ def objects(value: object) -> list[Json]:
183
+ """Reject unknown or incomplete collections instead of interpreting absence."""
184
+ if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
185
+ raise EntraBootstrapError("invalid-response")
186
+ return value
187
+
188
+
189
+ def strings(value: object) -> list[str]:
190
+ """Require a complete string collection from Graph."""
191
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
192
+ raise EntraBootstrapError("invalid-response")
193
+ return value
194
+
195
+
196
+ def matches(actual: Json, expected: Json) -> bool:
197
+ """Compare projected fields recursively, retaining unrelated array entries."""
198
+ for key, value in expected.items():
199
+ observed = actual.get(key)
200
+ if isinstance(value, dict):
201
+ if not isinstance(observed, dict) or not matches(observed, value):
202
+ return False
203
+ elif isinstance(value, list):
204
+ if not isinstance(observed, list) or any(
205
+ not any(
206
+ matches(candidate, item)
207
+ if isinstance(item, dict) and isinstance(candidate, dict)
208
+ else candidate == item
209
+ for candidate in observed
210
+ )
211
+ for item in value
212
+ ):
213
+ return False
214
+ elif type(observed) is not type(value) or observed != value:
215
+ return False
216
+ return True
217
+
218
+
219
+ class Graph:
220
+ """One token-pinned attempt with request and total budgets; never retries."""
221
+
222
+ def __init__(
223
+ self,
224
+ tenant_id: str,
225
+ provider: Callable[[str], GraphToken],
226
+ transport: GraphTransport,
227
+ ) -> None:
228
+ self.deadline = time.monotonic() + 180
229
+ self.transport, self.requests = transport, 0
230
+ try:
231
+ token = provider(tenant_id)
232
+ except (OSError, RuntimeError, ValueError):
233
+ raise EntraBootstrapError("token-provider") from None
234
+ if not isinstance(token, GraphToken):
235
+ raise EntraBootstrapError("token-provider")
236
+ if uuid(token.tenant_id) != tenant_id:
237
+ raise EntraBootstrapError("tenant-mismatch")
238
+ if not isinstance(token.access_token, str) or not re.fullmatch(
239
+ r"[A-Za-z0-9._~+/-]{1,16384}={0,2}",
240
+ token.access_token,
241
+ ):
242
+ raise EntraBootstrapError("invalid-token")
243
+ self._headers = {
244
+ "Authorization": f"Bearer {token.access_token}",
245
+ "Content-Type": "application/json",
246
+ "Accept": "application/json",
247
+ }
248
+ organizations = self.list("organization", {"$select": "id"})
249
+ if len(organizations) != 1 or organizations[0].get("id") != tenant_id:
250
+ raise EntraBootstrapError("tenant-mismatch")
251
+
252
+ def call(self, method: str, path: str, body: Json | None = None) -> Json:
253
+ """Make one exact request, dropping all provider errors and error bodies."""
254
+ _remaining(self.deadline)
255
+ if self.requests >= MAX_REQUESTS:
256
+ raise EntraBootstrapError("request-limit")
257
+ encoded = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
258
+ if len(encoded) > 131_072:
259
+ raise EntraBootstrapError("request-limit")
260
+ self.requests += 1
261
+ try:
262
+ response = self.transport.request(
263
+ method,
264
+ "/v1.0/" + path,
265
+ self._headers,
266
+ encoded,
267
+ deadline=min(self.deadline, time.monotonic() + 10),
268
+ )
269
+ except EntraBootstrapError:
270
+ raise
271
+ except (OSError, ValueError, http.client.HTTPException, TimeoutError):
272
+ raise EntraBootstrapError("transport") from None
273
+ _remaining(self.deadline)
274
+ expected = {"GET": {200}, "POST": {201, 204}, "PATCH": {204}}[method]
275
+ if response.status not in expected:
276
+ raise EntraBootstrapError("http-failure", response.status)
277
+ if len(response.body) > MAX_BYTES:
278
+ raise EntraBootstrapError("response-limit")
279
+ if response.status == 204:
280
+ return {}
281
+ try:
282
+ result = json.loads(response.body)
283
+ except (ValueError, RecursionError):
284
+ raise EntraBootstrapError("invalid-response") from None
285
+ if not isinstance(result, dict):
286
+ raise EntraBootstrapError("invalid-response")
287
+ return result
288
+
289
+ def list(self, path: str, query: Mapping[str, str] | None = None) -> list[Json]:
290
+ """Require a complete single bounded page; never follow nextLink URLs."""
291
+ paging = {} if path == "organization" else {"$top": "100"}
292
+ result = self.call("GET", path + "?" + urlencode({**paging, **(query or {})}))
293
+ if "@odata.nextLink" in result:
294
+ raise EntraBootstrapError("incomplete-collection")
295
+ rows = objects(result.get("value"))
296
+ if len(rows) > 100:
297
+ raise EntraBootstrapError("response-limit")
298
+ return rows
299
+
300
+ def owned(self, kind: str, marker: str, name: str, nickname: str = "") -> Json | None:
301
+ """Require one exact marker and reject colliding names, including foreign ones."""
302
+ predicate = (
303
+ f"mailNickname eq '{nickname}'" if kind == "groups" else f"tags/any(t:t eq '{marker}')"
304
+ )
305
+ marked = self.list(kind, {"$filter": predicate})
306
+ named = self.list(kind, {"$filter": f"displayName eq '{name}'"})
307
+ records = {uuid(row.get("id")): row for row in [*marked, *named]}
308
+ if len(marked) > 1 or len(named) > 1 or len(records) > 1:
309
+ raise EntraBootstrapError("ambiguous-ownership")
310
+ if not records:
311
+ return None
312
+ object_id = next(iter(records))
313
+ record = self.get(kind, object_id)
314
+ valid = (
315
+ record.get("description") == marker
316
+ if kind == "groups"
317
+ else [tag for tag in strings(record.get("tags")) if tag.startswith("fdai-bootstrap:")]
318
+ == [marker]
319
+ )
320
+ if not valid or record.get("displayName") != name or record.get("id") != object_id:
321
+ raise EntraBootstrapError("foreign-ownership")
322
+ uuid(record.get("id"))
323
+ return record
324
+
325
+ def get(self, kind: str, object_id: str) -> Json:
326
+ """Select the complete group security contract, including nondefault fields."""
327
+ query = (
328
+ "?$select=id,displayName,description,mailNickname,mailEnabled,securityEnabled,"
329
+ "groupTypes,isAssignableToRole,onPremisesSyncEnabled"
330
+ if kind == "groups"
331
+ else ""
332
+ )
333
+ return self.call("GET", f"{kind}/{object_id}{query}")
334
+
335
+ def ensure(self, kind: str, existing: Json | None, expected: Json) -> Json:
336
+ """Create or patch, then independently GET every requested field."""
337
+ if existing is None:
338
+ created = self.call("POST", kind, expected)
339
+ object_id = uuid(created.get("id"))
340
+ else:
341
+ object_id = uuid(existing.get("id"))
342
+ if not matches(existing, expected):
343
+ self.call("PATCH", f"{kind}/{object_id}", expected)
344
+ observed = self.get(kind, object_id)
345
+ if observed.get("id") != object_id or not matches(observed, expected):
346
+ raise EntraBootstrapError("readback-mismatch")
347
+ if existing and "appId" in existing and observed.get("appId") != existing["appId"]:
348
+ raise EntraBootstrapError("readback-mismatch")
349
+ return observed