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,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
|