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,579 @@
1
+ """Bounded Entra registration/group bootstrap for a future protected executor.
2
+
3
+ The consumer MUST perform protected authorization before invoking apply, hold an
4
+ exclusive tenant/deployment lock, and supply the approved Console origin and
5
+ explicit initial owner USER. Plans and consent selection grant no authority.
6
+ The consumer persists returned IDs privately, never as public evidence. Partial
7
+ failure requires protected reconciliation; this adapter never retries or deletes.
8
+ GET verification proves only directory fields, not authenticated browser access
9
+ or complete installer readiness. No tokens, private IDs, or provider bodies log.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import re
17
+ from collections.abc import Callable
18
+ from dataclasses import asdict, dataclass, field
19
+ from typing import Literal
20
+ from urllib.parse import urlsplit
21
+ from uuid import NAMESPACE_URL, uuid5
22
+
23
+ from fdai_deployment_cli.entra_graph import (
24
+ EntraBootstrapError,
25
+ Graph,
26
+ GraphToken,
27
+ GraphTransport,
28
+ HttpsGraphTransport,
29
+ Json,
30
+ matches,
31
+ objects,
32
+ strings,
33
+ uuid,
34
+ )
35
+
36
+ # Human App Roles and the channel attachment workload role are pinned by the identity owners.
37
+ ROLES = ("Reader", "Contributor", "Approver", "Owner", "BreakGlass")
38
+ POLICY_ADMIN_ROLE = "policy-admin"
39
+ CHANNEL_ATTACHMENT_ROLE = "Document.ChannelAttachment.Submit"
40
+ _SLOTS = ("readers", "contributors", "approvers", "owners", "break_glass")
41
+
42
+
43
+ @dataclass(frozen=True, slots=True, repr=False)
44
+ class EntraDesired:
45
+ """Private target intent; consent is an operation selection, not authorization."""
46
+
47
+ tenant_id: str
48
+ deployment_binding: str
49
+ initial_owner_user_id: str
50
+ console_origin: str
51
+ name_prefix: str
52
+ consent: Literal["none", "initial-owner"] = "none"
53
+
54
+ def __post_init__(self) -> None:
55
+ for value in (self.tenant_id, self.initial_owner_user_id):
56
+ if uuid(value) != value:
57
+ raise EntraBootstrapError("noncanonical-uuid")
58
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", self.deployment_binding):
59
+ raise EntraBootstrapError("invalid-binding")
60
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", self.name_prefix):
61
+ raise EntraBootstrapError("invalid-prefix")
62
+ try:
63
+ origin = urlsplit(self.console_origin)
64
+ valid = (
65
+ self.console_origin.isascii()
66
+ and origin.scheme == "https"
67
+ and origin.hostname
68
+ and origin.username is None
69
+ and origin.password is None
70
+ and origin.path == ""
71
+ and not origin.query
72
+ and not origin.fragment
73
+ and origin.port in (None, 443)
74
+ and not any(char in self.console_origin for char in "\\?#")
75
+ and all(32 < ord(char) < 127 for char in self.console_origin)
76
+ and re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", origin.hostname)
77
+ )
78
+ except ValueError:
79
+ valid = False
80
+ if not valid:
81
+ raise EntraBootstrapError("invalid-console-origin")
82
+ if self.consent not in {"none", "initial-owner"}:
83
+ raise EntraBootstrapError("invalid-consent-selection")
84
+
85
+
86
+ @dataclass(frozen=True, slots=True, repr=False)
87
+ class EntraPlan:
88
+ """Immutable, deterministic desired state; contains no observed success claim."""
89
+
90
+ desired: EntraDesired
91
+ marker: str
92
+ role_ids: tuple[str, ...]
93
+ policy_admin_role_id: str
94
+ channel_attachment_role_id: str
95
+ scope_id: str
96
+ digest: str
97
+
98
+
99
+ def plan_entra_bootstrap(desired: EntraDesired) -> EntraPlan:
100
+ """Compile private desired state without network calls or implicit caller selection."""
101
+ desired.__post_init__()
102
+ binding = hashlib.sha256(
103
+ f"{desired.tenant_id}/{desired.deployment_binding}".encode()
104
+ ).hexdigest()
105
+ marker = f"fdai-bootstrap:v1:{binding}"
106
+ digest = hashlib.sha256(json.dumps(asdict(desired), sort_keys=True).encode()).hexdigest()
107
+ return EntraPlan(
108
+ desired,
109
+ marker,
110
+ tuple(str(uuid5(NAMESPACE_URL, f"{marker}/{role}")) for role in ROLES),
111
+ str(uuid5(NAMESPACE_URL, f"{marker}/{POLICY_ADMIN_ROLE}")),
112
+ str(uuid5(NAMESPACE_URL, f"{marker}/{CHANNEL_ATTACHMENT_ROLE}")),
113
+ str(uuid5(NAMESPACE_URL, f"{marker}/access")),
114
+ digest,
115
+ )
116
+
117
+
118
+ @dataclass(frozen=True, slots=True, repr=False)
119
+ class EntraReferences:
120
+ """Private configuration references; group slots match the documented RBAC schema."""
121
+
122
+ tenant_id: str
123
+ api_object_id: str
124
+ api_client_id: str
125
+ spa_object_id: str
126
+ spa_client_id: str
127
+ approval_bot_object_id: str
128
+ approval_bot_client_id: str
129
+ api_service_principal_id: str
130
+ spa_service_principal_id: str
131
+ approval_bot_service_principal_id: str
132
+ groups: tuple[tuple[str, str], ...]
133
+
134
+ @property
135
+ def api_scope(self) -> str:
136
+ """Return the sole requested custom delegated scope, not Graph permissions."""
137
+ return f"api://{self.api_client_id}/access"
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class EntraBootstrapResult:
142
+ """Directory-only postconditions, private references, and an opaque evidence digest."""
143
+
144
+ references: EntraReferences = field(repr=False)
145
+ verified_stages: tuple[str, ...]
146
+ evidence_digest: str
147
+
148
+
149
+ def _roles(plan: EntraPlan) -> list[Json]:
150
+ human_roles = [
151
+ {
152
+ "id": role_id,
153
+ "value": role,
154
+ "displayName": role,
155
+ "description": f"FDAI {role} human role",
156
+ "allowedMemberTypes": ["User"],
157
+ "isEnabled": True,
158
+ }
159
+ for role, role_id in zip(ROLES, plan.role_ids, strict=True)
160
+ ]
161
+ return [
162
+ *human_roles,
163
+ {
164
+ "id": plan.policy_admin_role_id,
165
+ "value": POLICY_ADMIN_ROLE,
166
+ "displayName": "Policy administrator",
167
+ "description": "Submit bounded FDAI policy revisions to the Operator API",
168
+ "allowedMemberTypes": ["User"],
169
+ "isEnabled": True,
170
+ },
171
+ {
172
+ "id": plan.channel_attachment_role_id,
173
+ "value": CHANNEL_ATTACHMENT_ROLE,
174
+ "displayName": "Channel attachment submitter",
175
+ "description": "Submit bounded channel attachments to the internal ingestion intake",
176
+ "allowedMemberTypes": ["Application"],
177
+ "isEnabled": True,
178
+ },
179
+ ]
180
+
181
+
182
+ def _scope(plan: EntraPlan) -> Json:
183
+ return {
184
+ "id": plan.scope_id,
185
+ "value": "access",
186
+ "type": "User",
187
+ "isEnabled": True,
188
+ "adminConsentDisplayName": "Access the fdai Operator API",
189
+ "adminConsentDescription": "Allow the console to call the fdai Operator API on behalf of the signed-in operator",
190
+ "userConsentDisplayName": "Access the fdai Operator API",
191
+ "userConsentDescription": "Allow the console to call the fdai Operator API on your behalf",
192
+ }
193
+
194
+
195
+ def _optional_claims(existing: object) -> Json:
196
+ old = existing if isinstance(existing, dict) else {}
197
+ access_token = [
198
+ row for row in objects(old.get("accessToken", [])) if row.get("name") != "auth_time"
199
+ ]
200
+ access_token.append(
201
+ {
202
+ "name": "auth_time",
203
+ "source": None,
204
+ "essential": True,
205
+ "additionalProperties": [],
206
+ }
207
+ )
208
+ return {**old, "accessToken": access_token}
209
+
210
+
211
+ def _merge_definitions(existing: object, desired: list[Json]) -> list[Json]:
212
+ """Add missing definitions, refusing any collision in ID, value, or role flags."""
213
+ merged = [
214
+ {key: value for key, value in row.items() if key != "origin"} for row in objects(existing)
215
+ ]
216
+ for item in desired:
217
+ collisions = [
218
+ row
219
+ for row in merged
220
+ if row.get("id") == item["id"] or row.get("value") == item["value"]
221
+ ]
222
+ if collisions:
223
+ if len(collisions) != 1 or not matches(collisions[0], item):
224
+ raise EntraBootstrapError("role-or-scope-conflict")
225
+ if (
226
+ "allowedMemberTypes" in item
227
+ and collisions[0]["allowedMemberTypes"] != item["allowedMemberTypes"]
228
+ ):
229
+ raise EntraBootstrapError("role-or-scope-conflict")
230
+ else:
231
+ merged.append(item)
232
+ return merged
233
+
234
+
235
+ def _app_payload(plan: EntraPlan, label: str, existing: Json | None, api_id: str = "") -> Json:
236
+ old = existing or {}
237
+ if existing and old.get("signInAudience") != "AzureADMyOrg":
238
+ raise EntraBootstrapError("application-contract-conflict")
239
+ if existing:
240
+ required = {
241
+ "api": ("appRoles", "api", "identifierUris"),
242
+ "console-spa": ("spa", "requiredResourceAccess"),
243
+ "approval-bot": ("requiredResourceAccess",),
244
+ }[label]
245
+ nested = old.get("api" if label == "api" else "spa")
246
+ nested_required = {
247
+ "api": ("oauth2PermissionScopes", "requestedAccessTokenVersion"),
248
+ "console-spa": ("redirectUris",),
249
+ "approval-bot": (),
250
+ }[label]
251
+ if any(key not in old for key in required):
252
+ raise EntraBootstrapError("incomplete-application")
253
+ if nested_required and (
254
+ not isinstance(nested, dict) or any(key not in nested for key in nested_required)
255
+ ):
256
+ raise EntraBootstrapError("incomplete-application")
257
+ payload: Json = {
258
+ "displayName": f"{plan.desired.name_prefix}-{label}",
259
+ "signInAudience": "AzureADMyOrg",
260
+ "tags": list(dict.fromkeys([*strings(old.get("tags", [])), f"{plan.marker}:{label}"])),
261
+ }
262
+ if label == "api":
263
+ api = old.get("api", {})
264
+ if not isinstance(api, dict) or api.get("requestedAccessTokenVersion") not in (None, 2):
265
+ raise EntraBootstrapError("application-contract-conflict")
266
+ payload["appRoles"] = _merge_definitions(old.get("appRoles", []), _roles(plan))
267
+ payload["api"] = {
268
+ **api,
269
+ "requestedAccessTokenVersion": 2,
270
+ "oauth2PermissionScopes": _merge_definitions(
271
+ api.get("oauth2PermissionScopes", []),
272
+ [_scope(plan)],
273
+ ),
274
+ }
275
+ payload["optionalClaims"] = _optional_claims(old.get("optionalClaims", {}))
276
+ if api_id:
277
+ uris = strings(old.get("identifierUris", []))
278
+ if uris and f"api://{api_id}" not in uris:
279
+ raise EntraBootstrapError("application-uri-conflict")
280
+ payload["identifierUris"] = list(dict.fromkeys([*uris, f"api://{api_id}"]))
281
+ else:
282
+ if label == "console-spa":
283
+ spa = old.get("spa", {})
284
+ if not isinstance(spa, dict):
285
+ raise EntraBootstrapError("application-contract-conflict")
286
+ payload["spa"] = {
287
+ "redirectUris": list(
288
+ dict.fromkeys(
289
+ [
290
+ *strings(spa.get("redirectUris", [])),
291
+ plan.desired.console_origin,
292
+ ]
293
+ )
294
+ )
295
+ }
296
+ permissions = [dict(row) for row in objects(old.get("requiredResourceAccess", []))]
297
+ matching = [row for row in permissions if row.get("resourceAppId") == api_id]
298
+ if len(matching) > 1:
299
+ raise EntraBootstrapError("scope-permission-conflict")
300
+ if not matching:
301
+ permissions.append({"resourceAppId": api_id, "resourceAccess": []})
302
+ matching = [permissions[-1]]
303
+ access = objects(matching[0].get("resourceAccess"))
304
+ wanted = {"id": plan.scope_id, "type": "Scope"}
305
+ if any(row.get("id") == plan.scope_id and row != wanted for row in access):
306
+ raise EntraBootstrapError("scope-permission-conflict")
307
+ matching[0]["resourceAccess"] = access if wanted in access else [*access, wanted]
308
+ payload["requiredResourceAccess"] = permissions
309
+ return payload
310
+
311
+
312
+ def _run(plan: EntraPlan, graph: Graph, *, mutate: bool) -> EntraBootstrapResult:
313
+ desired = plan.desired
314
+ owner = graph.call("GET", f"users/{desired.initial_owner_user_id}?$select=id,accountEnabled")
315
+ if owner.get("id") != desired.initial_owner_user_id or owner.get("accountEnabled") is not True:
316
+ raise EntraBootstrapError("initial-owner-unavailable")
317
+ labels = ("api", "console-spa", "approval-bot", *_SLOTS)
318
+ records: dict[str, Json | None] = {}
319
+ for label in labels:
320
+ records[label] = graph.owned(
321
+ "applications" if label in labels[:3] else "groups",
322
+ f"{plan.marker}:{label}",
323
+ f"{desired.name_prefix}-{label}",
324
+ f"fdai-{plan.marker.split(':')[-1][:32]}-{label}",
325
+ )
326
+ api_old = records["api"]
327
+ api_id = uuid(api_old["appId"]) if api_old else ""
328
+ _app_payload(plan, "api", api_old, api_id)
329
+ if api_id:
330
+ _app_payload(plan, "console-spa", records["console-spa"], api_id)
331
+ _app_payload(plan, "approval-bot", records["approval-bot"], api_id)
332
+ group_payloads: dict[str, Json] = {}
333
+ for slot in _SLOTS:
334
+ payload = {
335
+ "displayName": f"{desired.name_prefix}-{slot}",
336
+ "description": f"{plan.marker}:{slot}",
337
+ "mailNickname": f"fdai-{plan.marker.split(':')[-1][:32]}-{slot}",
338
+ "mailEnabled": False,
339
+ "securityEnabled": True,
340
+ "groupTypes": [],
341
+ }
342
+ record = records[slot]
343
+ if record and (
344
+ not matches(record, payload)
345
+ or record.get("groupTypes") != []
346
+ or record.get("onPremisesSyncEnabled") is True
347
+ or "onPremisesSyncEnabled" not in record
348
+ or record.get("isAssignableToRole") is not False
349
+ ):
350
+ raise EntraBootstrapError("group-contract-conflict")
351
+ group_payloads[slot] = payload
352
+
353
+ def ensure(kind: str, existing: Json | None, payload: Json) -> Json:
354
+ if not mutate and (existing is None or not matches(existing, payload)):
355
+ raise EntraBootstrapError("readback-mismatch")
356
+ return graph.ensure(kind, existing, payload)
357
+
358
+ api = ensure("applications", api_old, _app_payload(plan, "api", api_old, api_id))
359
+ api_id = uuid(api.get("appId"))
360
+ api = ensure("applications", api, _app_payload(plan, "api", api, api_id))
361
+ spa = ensure(
362
+ "applications",
363
+ records["console-spa"],
364
+ _app_payload(plan, "console-spa", records["console-spa"], api_id),
365
+ )
366
+ approval_bot = ensure(
367
+ "applications",
368
+ records["approval-bot"],
369
+ _app_payload(plan, "approval-bot", records["approval-bot"], api_id),
370
+ )
371
+ principals: list[Json] = []
372
+ for label, app in (
373
+ ("api", api),
374
+ ("console-spa", spa),
375
+ ("approval-bot", approval_bot),
376
+ ):
377
+ app_id = uuid(app.get("appId"))
378
+ rows = graph.list("servicePrincipals", {"$filter": f"appId eq '{app_id}'"})
379
+ if len(rows) > 1:
380
+ raise EntraBootstrapError("ambiguous-ownership")
381
+ old = graph.call("GET", f"servicePrincipals/{uuid(rows[0].get('id'))}") if rows else None
382
+ marker = f"{plan.marker}:{label}"
383
+ if old and (
384
+ [tag for tag in strings(old.get("tags")) if tag.startswith("fdai-bootstrap:")]
385
+ != [marker]
386
+ or old.get("appOwnerOrganizationId") != desired.tenant_id
387
+ or old.get("servicePrincipalType") != "Application"
388
+ or old.get("appId") != app_id
389
+ or old.get("accountEnabled") is not True
390
+ ):
391
+ raise EntraBootstrapError("foreign-ownership")
392
+ principal = ensure(
393
+ "servicePrincipals",
394
+ old,
395
+ {
396
+ "appId": app_id,
397
+ "tags": list(
398
+ dict.fromkeys(
399
+ [
400
+ *strings((old or {}).get("tags", [])),
401
+ marker,
402
+ ]
403
+ )
404
+ ),
405
+ "accountEnabled": True,
406
+ },
407
+ )
408
+ if (
409
+ principal.get("appOwnerOrganizationId") != desired.tenant_id
410
+ or principal.get("servicePrincipalType") != "Application"
411
+ ):
412
+ raise EntraBootstrapError("readback-mismatch")
413
+ principals.append(principal)
414
+ api_sp, spa_sp, approval_bot_sp = (uuid(row.get("id")) for row in principals)
415
+ if not matches(
416
+ principals[0],
417
+ {
418
+ "appRoles": _roles(plan),
419
+ "oauth2PermissionScopes": [_scope(plan)],
420
+ },
421
+ ):
422
+ raise EntraBootstrapError("role-readback-mismatch")
423
+ _merge_definitions(principals[0]["appRoles"], _roles(plan))
424
+ groups = {slot: ensure("groups", records[slot], group_payloads[slot]) for slot in _SLOTS}
425
+ if any(
426
+ row.get("isAssignableToRole") is not False
427
+ or row.get("groupTypes") != []
428
+ or row.get("onPremisesSyncEnabled") is True
429
+ or "onPremisesSyncEnabled" not in row
430
+ for row in groups.values()
431
+ ):
432
+ raise EntraBootstrapError("group-readback-mismatch")
433
+ assignment_path = f"servicePrincipals/{api_sp}/appRoleAssignedTo"
434
+ assignments = graph.list(assignment_path)
435
+ if any(
436
+ not all(
437
+ isinstance(row.get(key), str)
438
+ for key in ("principalId", "resourceId", "appRoleId", "principalType")
439
+ )
440
+ or row["resourceId"] != api_sp
441
+ for row in assignments
442
+ ):
443
+ raise EntraBootstrapError("incomplete-assignment")
444
+ for slot, role_id in zip(_SLOTS, plan.role_ids, strict=True):
445
+ group_id = uuid(groups[slot].get("id"))
446
+ wanted = {"principalId": group_id, "resourceId": api_sp, "appRoleId": role_id}
447
+ own = [row for row in assignments if row.get("principalId") == group_id]
448
+ if len(own) > 1 or any(not matches(row, wanted) for row in own):
449
+ raise EntraBootstrapError("assignment-conflict")
450
+ if not own and mutate:
451
+ graph.call("POST", assignment_path, wanted)
452
+ verified = graph.list(assignment_path)
453
+ if any(
454
+ not any(
455
+ matches(
456
+ row,
457
+ {
458
+ "principalId": groups[slot]["id"],
459
+ "resourceId": api_sp,
460
+ "appRoleId": role_id,
461
+ "principalType": "Group",
462
+ },
463
+ )
464
+ for row in verified
465
+ )
466
+ for slot, role_id in zip(_SLOTS, plan.role_ids, strict=True)
467
+ ):
468
+ raise EntraBootstrapError("assignment-readback-mismatch")
469
+ member_path = f"groups/{uuid(groups['owners'].get('id'))}/members"
470
+ members = graph.list(member_path, {"$select": "id"})
471
+ for member in members:
472
+ uuid(member.get("id"))
473
+ if not any(row.get("id") == desired.initial_owner_user_id for row in members) and mutate:
474
+ graph.call(
475
+ "POST",
476
+ member_path + "/$ref",
477
+ {
478
+ "@odata.id": f"https://graph.microsoft.com/v1.0/users/{desired.initial_owner_user_id}",
479
+ },
480
+ )
481
+ if not any(
482
+ row.get("id") == desired.initial_owner_user_id
483
+ for row in graph.list(member_path, {"$select": "id"})
484
+ ):
485
+ raise EntraBootstrapError("owner-readback-mismatch")
486
+ stages: tuple[str, ...] = (
487
+ "applications",
488
+ "service-principals",
489
+ "role-groups",
490
+ "assignments",
491
+ "initial-owner",
492
+ )
493
+ if desired.consent == "initial-owner":
494
+ consent = {
495
+ "clientId": spa_sp,
496
+ "resourceId": api_sp,
497
+ "consentType": "Principal",
498
+ "principalId": desired.initial_owner_user_id,
499
+ "scope": "access",
500
+ }
501
+ grants = graph.list("oauth2PermissionGrants", {"$filter": f"clientId eq '{spa_sp}'"})
502
+ own = [
503
+ row
504
+ for row in grants
505
+ if row.get("resourceId") == api_sp
506
+ and row.get("principalId") == desired.initial_owner_user_id
507
+ ]
508
+ if len(own) > 1 or any(not matches(row, consent) for row in own):
509
+ raise EntraBootstrapError("consent-conflict")
510
+ if not own and mutate:
511
+ graph.call("POST", "oauth2PermissionGrants", consent)
512
+ grants = graph.list("oauth2PermissionGrants", {"$filter": f"clientId eq '{spa_sp}'"})
513
+ if not any(matches(row, consent) for row in grants):
514
+ raise EntraBootstrapError("consent-readback-mismatch")
515
+ stages += ("initial-owner-custom-scope-consent",)
516
+ refs = EntraReferences(
517
+ desired.tenant_id,
518
+ uuid(api["id"]),
519
+ api_id,
520
+ uuid(spa["id"]),
521
+ uuid(spa["appId"]),
522
+ uuid(approval_bot["id"]),
523
+ uuid(approval_bot["appId"]),
524
+ api_sp,
525
+ spa_sp,
526
+ approval_bot_sp,
527
+ tuple((slot, uuid(groups[slot]["id"])) for slot in _SLOTS),
528
+ )
529
+ evidence = {
530
+ "plan": plan.digest,
531
+ "references": asdict(refs),
532
+ "stages": stages,
533
+ "api": _app_payload(plan, "api", api, api_id),
534
+ "spa": _app_payload(plan, "console-spa", spa, api_id),
535
+ "approval_bot": _app_payload(plan, "approval-bot", approval_bot, api_id),
536
+ }
537
+ return EntraBootstrapResult(
538
+ refs,
539
+ stages,
540
+ hashlib.sha256(
541
+ json.dumps(evidence, sort_keys=True).encode(),
542
+ ).hexdigest(),
543
+ )
544
+
545
+
546
+ def apply_entra_bootstrap(
547
+ plan: EntraPlan,
548
+ *,
549
+ token_provider: Callable[[str], GraphToken],
550
+ transport: GraphTransport | None = None,
551
+ ) -> EntraBootstrapResult:
552
+ """Reconcile only owned objects after caller-provided protected authorization."""
553
+ if plan != plan_entra_bootstrap(plan.desired):
554
+ raise EntraBootstrapError("invalid-plan")
555
+ return _run(
556
+ plan,
557
+ Graph(plan.desired.tenant_id, token_provider, transport or HttpsGraphTransport()),
558
+ mutate=True,
559
+ )
560
+
561
+
562
+ def readback_entra_bootstrap(
563
+ plan: EntraPlan,
564
+ references: EntraReferences,
565
+ *,
566
+ token_provider: Callable[[str], GraphToken],
567
+ transport: GraphTransport | None = None,
568
+ ) -> EntraBootstrapResult:
569
+ """Use only fresh GETs and require the same privately retained object identities."""
570
+ if plan != plan_entra_bootstrap(plan.desired):
571
+ raise EntraBootstrapError("invalid-plan")
572
+ result = _run(
573
+ plan,
574
+ Graph(plan.desired.tenant_id, token_provider, transport or HttpsGraphTransport()),
575
+ mutate=False,
576
+ )
577
+ if result.references != references:
578
+ raise EntraBootstrapError("reference-mismatch")
579
+ return result