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.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- 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
|