standstill 0.2.0__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.
- standstill/__init__.py +0 -0
- standstill/audit.py +53 -0
- standstill/aws/__init__.py +0 -0
- standstill/aws/account_factory.py +428 -0
- standstill/aws/blueprint.py +350 -0
- standstill/aws/config_recorder.py +339 -0
- standstill/aws/controltower.py +524 -0
- standstill/aws/lake.py +622 -0
- standstill/aws/landing_zone.py +261 -0
- standstill/aws/notifications.py +316 -0
- standstill/aws/organizations.py +139 -0
- standstill/aws/scp.py +191 -0
- standstill/aws/security_services.py +1281 -0
- standstill/aws/session.py +111 -0
- standstill/aws/sso.py +390 -0
- standstill/commands/__init__.py +0 -0
- standstill/commands/_engine.py +462 -0
- standstill/commands/accounts.py +743 -0
- standstill/commands/apply.py +174 -0
- standstill/commands/blueprint.py +400 -0
- standstill/commands/catalog.py +253 -0
- standstill/commands/check.py +42 -0
- standstill/commands/config.py +80 -0
- standstill/commands/disable.py +221 -0
- standstill/commands/lake.py +335 -0
- standstill/commands/lz.py +515 -0
- standstill/commands/notifications.py +223 -0
- standstill/commands/operations.py +125 -0
- standstill/commands/ou.py +231 -0
- standstill/commands/recorder.py +257 -0
- standstill/commands/scp.py +259 -0
- standstill/commands/security.py +767 -0
- standstill/commands/sso.py +350 -0
- standstill/commands/view.py +58 -0
- standstill/config.py +107 -0
- standstill/data/controls_catalog.yaml +193 -0
- standstill/data/security_services_default.yaml +195 -0
- standstill/data/securityhub_resource_types.yaml +132 -0
- standstill/display/__init__.py +0 -0
- standstill/display/_notifications.py +102 -0
- standstill/display/_scp.py +115 -0
- standstill/display/_security.py +350 -0
- standstill/display/_sso.py +133 -0
- standstill/display/renderer.py +467 -0
- standstill/main.py +144 -0
- standstill/models/__init__.py +0 -0
- standstill/models/blueprint_config.py +121 -0
- standstill/models/schemas.py +35 -0
- standstill/models/security_config.py +278 -0
- standstill/state.py +82 -0
- standstill-0.2.0.dist-info/METADATA +885 -0
- standstill-0.2.0.dist-info/RECORD +55 -0
- standstill-0.2.0.dist-info/WHEEL +4 -0
- standstill-0.2.0.dist-info/entry_points.txt +2 -0
- standstill-0.2.0.dist-info/licenses/LICENSE +373 -0
standstill/__init__.py
ADDED
|
File without changes
|
standstill/audit.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Lightweight, append-only audit log of CLI invocations.
|
|
2
|
+
|
|
3
|
+
A security tool that mutates org-wide controls needs an auditable record of what
|
|
4
|
+
was run. Every invocation is written as one JSON line to ``~/.standstill/audit.log``
|
|
5
|
+
(override with ``STANDSTILL_AUDIT_LOG``). The log is centralized at the CLI
|
|
6
|
+
entry point so it is comprehensive by construction — it cannot silently omit a
|
|
7
|
+
mutation the way per-command wiring could.
|
|
8
|
+
|
|
9
|
+
Writing is best-effort: an auditing failure must never break the command.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_DEFAULT_AUDIT_FILE = Path.home() / ".standstill" / "audit.log"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def audit_path() -> Path:
|
|
22
|
+
"""Resolve the audit-log path, honouring the STANDSTILL_AUDIT_LOG override."""
|
|
23
|
+
override = os.environ.get("STANDSTILL_AUDIT_LOG")
|
|
24
|
+
return Path(override) if override else _DEFAULT_AUDIT_FILE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def record_invocation(
|
|
28
|
+
args: list[str],
|
|
29
|
+
exit_code: int,
|
|
30
|
+
profile: str | None = None,
|
|
31
|
+
region: str | None = None,
|
|
32
|
+
timestamp: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Append one JSON-lines audit record for a CLI invocation.
|
|
35
|
+
|
|
36
|
+
Best-effort: any error (unwritable path, serialization issue) is swallowed so
|
|
37
|
+
auditing never affects the command's outcome.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
record = {
|
|
41
|
+
"ts": timestamp or datetime.now(timezone.utc).isoformat(),
|
|
42
|
+
"args": list(args),
|
|
43
|
+
"exit_code": exit_code,
|
|
44
|
+
"profile": profile,
|
|
45
|
+
"region": region,
|
|
46
|
+
"pid": os.getpid(),
|
|
47
|
+
}
|
|
48
|
+
path = audit_path()
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
51
|
+
fh.write(json.dumps(record) + "\n")
|
|
52
|
+
except Exception:
|
|
53
|
+
pass # auditing must never break the command
|
|
File without changes
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from botocore.exceptions import ClientError
|
|
8
|
+
|
|
9
|
+
from standstill import state as _state
|
|
10
|
+
|
|
11
|
+
_TERMINAL_STATUSES = {"SUCCEEDED", "FAILED"}
|
|
12
|
+
_THROTTLE_CODES = {"ThrottlingException", "Throttling", "RequestThrottled"}
|
|
13
|
+
|
|
14
|
+
# Control Tower does NOT expose account create/enroll/deregister as first-class
|
|
15
|
+
# boto3 APIs. Account Factory is published as a Service Catalog product named
|
|
16
|
+
# "AWS Control Tower Account Factory"; provisioning, enrolling, and unmanaging
|
|
17
|
+
# accounts are Service Catalog provision/terminate operations, each tracked by a
|
|
18
|
+
# RecordId and polled via describe_record.
|
|
19
|
+
_ACCOUNT_FACTORY_PRODUCT_NAME = "AWS Control Tower Account Factory"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Internal helpers
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def _get_org_root_id() -> str:
|
|
27
|
+
"""Return the organization root ID."""
|
|
28
|
+
org = _state.state.get_client("organizations")
|
|
29
|
+
roots = org.list_roots().get("Roots", [])
|
|
30
|
+
if not roots:
|
|
31
|
+
raise RuntimeError("No AWS Organizations root found.")
|
|
32
|
+
return roots[0]["Id"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_parent_id(child_id: str) -> str:
|
|
36
|
+
"""Return the parent OU or root ID for an account or OU."""
|
|
37
|
+
org = _state.state.get_client("organizations")
|
|
38
|
+
resp = org.list_parents(ChildId=child_id)
|
|
39
|
+
parents = resp.get("Parents", [])
|
|
40
|
+
if not parents:
|
|
41
|
+
raise RuntimeError(f"No parent found for {child_id}.")
|
|
42
|
+
return parents[0]["Id"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Account operation polling
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def normalize_record(detail: dict) -> dict:
|
|
50
|
+
"""Normalize a Service Catalog RecordDetail to the shape the command layer
|
|
51
|
+
expects: {status, statusMessage, operationType}."""
|
|
52
|
+
errors = detail.get("RecordErrors", []) or []
|
|
53
|
+
message = "; ".join(
|
|
54
|
+
e.get("Description", "") for e in errors if e.get("Description")
|
|
55
|
+
)
|
|
56
|
+
return {
|
|
57
|
+
"status": detail.get("Status", ""),
|
|
58
|
+
"statusMessage": message or detail.get("Status", ""),
|
|
59
|
+
"operationType": detail.get("RecordType", "PROVISION_PRODUCT"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def poll_account_operation(
|
|
64
|
+
record_id: str,
|
|
65
|
+
timeout: int = 1800,
|
|
66
|
+
poll_interval: int = 15,
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""
|
|
69
|
+
Block until an Account Factory (Service Catalog) record reaches a terminal
|
|
70
|
+
state, then return its normalized details.
|
|
71
|
+
|
|
72
|
+
Account operations (create / enroll / deregister) are Service Catalog
|
|
73
|
+
provision/terminate records polled via describe_record. They typically
|
|
74
|
+
complete in 10–30 minutes.
|
|
75
|
+
|
|
76
|
+
Raises TimeoutError if not complete within `timeout` seconds.
|
|
77
|
+
"""
|
|
78
|
+
deadline = time.monotonic() + timeout
|
|
79
|
+
time.sleep(random.uniform(5, poll_interval * 0.5))
|
|
80
|
+
|
|
81
|
+
throttle_count = 0
|
|
82
|
+
_MAX_BACKOFF = 120
|
|
83
|
+
|
|
84
|
+
while time.monotonic() < deadline:
|
|
85
|
+
try:
|
|
86
|
+
sc = _sc_client()
|
|
87
|
+
resp = sc.describe_record(Id=record_id)
|
|
88
|
+
detail = resp.get("RecordDetail", {})
|
|
89
|
+
throttle_count = 0
|
|
90
|
+
if detail.get("Status") in _TERMINAL_STATUSES:
|
|
91
|
+
return normalize_record(detail)
|
|
92
|
+
except ClientError as e:
|
|
93
|
+
code = e.response["Error"]["Code"]
|
|
94
|
+
if code in _THROTTLE_CODES:
|
|
95
|
+
throttle_count += 1
|
|
96
|
+
backoff = min(poll_interval * (2 ** throttle_count), _MAX_BACKOFF)
|
|
97
|
+
time.sleep(backoff + random.uniform(0, backoff * 0.25))
|
|
98
|
+
continue
|
|
99
|
+
raise
|
|
100
|
+
time.sleep(poll_interval)
|
|
101
|
+
|
|
102
|
+
raise TimeoutError(
|
|
103
|
+
f"Account operation {record_id} did not complete within {timeout}s. "
|
|
104
|
+
"Account factory operations can take 10–30 minutes."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# CT Account Factory (via AWS Service Catalog)
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _sc_client():
|
|
113
|
+
return _state.state.get_client("servicecatalog")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _find_account_factory() -> dict:
|
|
117
|
+
"""Resolve the Account Factory product, an active provisioning artifact, and
|
|
118
|
+
a launch path. Returns {product_id, artifact_id, path_id}."""
|
|
119
|
+
sc = _sc_client()
|
|
120
|
+
|
|
121
|
+
products = sc.search_products(
|
|
122
|
+
Filters={"FullTextSearch": [_ACCOUNT_FACTORY_PRODUCT_NAME]}
|
|
123
|
+
).get("ProductViewSummaries", [])
|
|
124
|
+
product_id = next(
|
|
125
|
+
(p["ProductId"] for p in products if p.get("Name") == _ACCOUNT_FACTORY_PRODUCT_NAME),
|
|
126
|
+
None,
|
|
127
|
+
)
|
|
128
|
+
if not product_id:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
"Could not find the 'AWS Control Tower Account Factory' Service "
|
|
131
|
+
"Catalog product. Ensure Control Tower is deployed and the calling "
|
|
132
|
+
"principal has access to the Account Factory portfolio."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Prefer the DEFAULT-guidance active artifact; fall back to the newest active.
|
|
136
|
+
artifacts = sc.describe_product(Id=product_id).get("ProvisioningArtifacts", [])
|
|
137
|
+
active = [a for a in artifacts if a.get("Active", True)]
|
|
138
|
+
artifact_id = next(
|
|
139
|
+
(a["Id"] for a in active if a.get("Guidance") == "DEFAULT"),
|
|
140
|
+
active[-1]["Id"] if active else None,
|
|
141
|
+
)
|
|
142
|
+
if artifact_id is None:
|
|
143
|
+
raise RuntimeError("Account Factory product has no active provisioning artifact.")
|
|
144
|
+
|
|
145
|
+
paths = sc.list_launch_paths(ProductId=product_id).get("LaunchPathSummaries", [])
|
|
146
|
+
if not paths:
|
|
147
|
+
raise RuntimeError("No launch path available for the Account Factory product.")
|
|
148
|
+
|
|
149
|
+
return {"product_id": product_id, "artifact_id": artifact_id, "path_id": paths[0]["Id"]}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _managed_ou_parameter(ou_id: str) -> str:
|
|
153
|
+
"""Account Factory's ManagedOrganizationalUnit parameter expects
|
|
154
|
+
'OUName (ou-id)'. Resolve the OU name from its id."""
|
|
155
|
+
org = _state.state.get_client("organizations")
|
|
156
|
+
name = org.describe_organizational_unit(
|
|
157
|
+
OrganizationalUnitId=ou_id
|
|
158
|
+
)["OrganizationalUnit"]["Name"]
|
|
159
|
+
return f"{name} ({ou_id})"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _sanitize_provisioned_name(name: str) -> str:
|
|
163
|
+
cleaned = re.sub(r"[^A-Za-z0-9_-]", "-", name).strip("-")
|
|
164
|
+
return cleaned or "account"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _provision_account(
|
|
168
|
+
account_name: str,
|
|
169
|
+
account_email: str,
|
|
170
|
+
ou_id: str,
|
|
171
|
+
sso_email: str,
|
|
172
|
+
sso_first_name: str,
|
|
173
|
+
sso_last_name: str,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Provision (create or enroll) an account through Account Factory.
|
|
176
|
+
Returns the Service Catalog RecordId."""
|
|
177
|
+
af = _find_account_factory()
|
|
178
|
+
managed_ou = _managed_ou_parameter(ou_id)
|
|
179
|
+
sc = _sc_client()
|
|
180
|
+
resp = sc.provision_product(
|
|
181
|
+
ProductId=af["product_id"],
|
|
182
|
+
ProvisioningArtifactId=af["artifact_id"],
|
|
183
|
+
PathId=af["path_id"],
|
|
184
|
+
ProvisionedProductName=_sanitize_provisioned_name(account_name),
|
|
185
|
+
ProvisioningParameters=[
|
|
186
|
+
{"Key": "AccountName", "Value": account_name},
|
|
187
|
+
{"Key": "AccountEmail", "Value": account_email},
|
|
188
|
+
{"Key": "ManagedOrganizationalUnit", "Value": managed_ou},
|
|
189
|
+
{"Key": "SSOUserEmail", "Value": sso_email},
|
|
190
|
+
{"Key": "SSOUserFirstName", "Value": sso_first_name},
|
|
191
|
+
{"Key": "SSOUserLastName", "Value": sso_last_name},
|
|
192
|
+
],
|
|
193
|
+
)
|
|
194
|
+
return resp["RecordDetail"]["RecordId"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def create_managed_account(
|
|
198
|
+
name: str,
|
|
199
|
+
email: str,
|
|
200
|
+
ou_id: str,
|
|
201
|
+
sso_email: str | None = None,
|
|
202
|
+
sso_first_name: str = "Account",
|
|
203
|
+
sso_last_name: str = "Admin",
|
|
204
|
+
) -> str:
|
|
205
|
+
"""
|
|
206
|
+
Create a new account via the Control Tower Account Factory.
|
|
207
|
+
Returns the Service Catalog RecordId for async polling.
|
|
208
|
+
|
|
209
|
+
The account is provisioned, baselined, and placed in the target OU.
|
|
210
|
+
SSO parameters default to the root email and generic admin names.
|
|
211
|
+
"""
|
|
212
|
+
return _provision_account(
|
|
213
|
+
account_name=name,
|
|
214
|
+
account_email=email,
|
|
215
|
+
ou_id=ou_id,
|
|
216
|
+
sso_email=sso_email or email,
|
|
217
|
+
sso_first_name=sso_first_name,
|
|
218
|
+
sso_last_name=sso_last_name,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def register_managed_account(
|
|
223
|
+
account_id: str,
|
|
224
|
+
ou_id: str,
|
|
225
|
+
sso_email: str | None = None,
|
|
226
|
+
sso_first_name: str = "Account",
|
|
227
|
+
sso_last_name: str = "Admin",
|
|
228
|
+
) -> str:
|
|
229
|
+
"""
|
|
230
|
+
Enroll an existing organization account into Control Tower.
|
|
231
|
+
Returns the Service Catalog RecordId for async polling.
|
|
232
|
+
|
|
233
|
+
Account Factory enrolls (rather than creates) when the AccountEmail matches
|
|
234
|
+
an account that already exists in the organization, so the account's email
|
|
235
|
+
and name are resolved from Organizations first.
|
|
236
|
+
"""
|
|
237
|
+
org = _state.state.get_client("organizations")
|
|
238
|
+
acct = org.describe_account(AccountId=account_id)["Account"]
|
|
239
|
+
return _provision_account(
|
|
240
|
+
account_name=acct.get("Name") or account_id,
|
|
241
|
+
account_email=acct["Email"],
|
|
242
|
+
ou_id=ou_id,
|
|
243
|
+
sso_email=sso_email or acct["Email"],
|
|
244
|
+
sso_first_name=sso_first_name,
|
|
245
|
+
sso_last_name=sso_last_name,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _find_provisioned_product_for_account(account_id: str) -> str:
|
|
250
|
+
"""Locate the Service Catalog provisioned product managing account_id by
|
|
251
|
+
matching the AccountId output of each Account Factory provisioned product.
|
|
252
|
+
Returns the ProvisionedProductId; raises RuntimeError if not found."""
|
|
253
|
+
sc = _sc_client()
|
|
254
|
+
kwargs: dict = {"AccessLevelFilter": {"Key": "Account", "Value": "self"}}
|
|
255
|
+
while True:
|
|
256
|
+
resp = sc.search_provisioned_products(**kwargs)
|
|
257
|
+
for pp in resp.get("ProvisionedProducts", []):
|
|
258
|
+
try:
|
|
259
|
+
outputs = sc.get_provisioned_product_outputs(
|
|
260
|
+
ProvisionedProductId=pp["Id"]
|
|
261
|
+
).get("Outputs", [])
|
|
262
|
+
except ClientError:
|
|
263
|
+
continue
|
|
264
|
+
for o in outputs:
|
|
265
|
+
if o.get("OutputKey") == "AccountId" and o.get("OutputValue") == account_id:
|
|
266
|
+
return pp["Id"]
|
|
267
|
+
token = resp.get("NextPageToken")
|
|
268
|
+
if not token:
|
|
269
|
+
break
|
|
270
|
+
kwargs["PageToken"] = token
|
|
271
|
+
raise RuntimeError(
|
|
272
|
+
f"No Account Factory provisioned product found for account {account_id}. "
|
|
273
|
+
"It may have been created outside Account Factory and cannot be "
|
|
274
|
+
"unmanaged this way."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def deregister_managed_account(account_id: str) -> str:
|
|
279
|
+
"""
|
|
280
|
+
Unmanage an account by terminating its Account Factory provisioned product.
|
|
281
|
+
Returns the Service Catalog RecordId for async polling.
|
|
282
|
+
|
|
283
|
+
The account remains in the organization but leaves Control Tower governance.
|
|
284
|
+
"""
|
|
285
|
+
pp_id = _find_provisioned_product_for_account(account_id)
|
|
286
|
+
sc = _sc_client()
|
|
287
|
+
resp = sc.terminate_provisioned_product(ProvisionedProductId=pp_id)
|
|
288
|
+
return resp["RecordDetail"]["RecordId"]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# Organizations account operations
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def get_org_root_id() -> str:
|
|
296
|
+
"""Return the organization root ID."""
|
|
297
|
+
return _get_org_root_id()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def move_account(account_id: str, dest_ou_id: str) -> str:
|
|
301
|
+
"""
|
|
302
|
+
Move an account to a different OU or root.
|
|
303
|
+
Resolves the current parent automatically and calls move_account.
|
|
304
|
+
Returns the source parent ID.
|
|
305
|
+
|
|
306
|
+
Raises ValueError if the account is already in the destination.
|
|
307
|
+
Raises ClientError if the destination OU does not exist.
|
|
308
|
+
"""
|
|
309
|
+
source_id = _get_parent_id(account_id)
|
|
310
|
+
if source_id == dest_ou_id:
|
|
311
|
+
raise ValueError(f"Account {account_id} is already in {dest_ou_id}.")
|
|
312
|
+
org = _state.state.get_client("organizations")
|
|
313
|
+
org.move_account(
|
|
314
|
+
AccountId=account_id,
|
|
315
|
+
SourceParentId=source_id,
|
|
316
|
+
DestinationParentId=dest_ou_id,
|
|
317
|
+
)
|
|
318
|
+
return source_id
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def describe_account(account_id: str) -> dict:
|
|
322
|
+
"""
|
|
323
|
+
Return detailed account information from the Organizations API.
|
|
324
|
+
Adds a ParentId key with the current parent OU (or root) ID.
|
|
325
|
+
"""
|
|
326
|
+
org = _state.state.get_client("organizations")
|
|
327
|
+
resp = org.describe_account(AccountId=account_id)
|
|
328
|
+
account = resp["Account"]
|
|
329
|
+
try:
|
|
330
|
+
account["ParentId"] = _get_parent_id(account_id)
|
|
331
|
+
except Exception:
|
|
332
|
+
account["ParentId"] = "unknown"
|
|
333
|
+
return account
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# Organizations OU operations
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def create_ou(parent_id: str, name: str) -> dict:
|
|
341
|
+
"""
|
|
342
|
+
Create a new OU under the given parent (root ID or OU ID).
|
|
343
|
+
Returns the new OU dict: {Id, Arn, Name}.
|
|
344
|
+
"""
|
|
345
|
+
org = _state.state.get_client("organizations")
|
|
346
|
+
resp = org.create_organizational_unit(ParentId=parent_id, Name=name)
|
|
347
|
+
return resp["OrganizationalUnit"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def delete_ou(ou_id: str) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Delete an OU. The OU must be empty (no child OUs or accounts).
|
|
353
|
+
Raises ClientError(OrganizationalUnitNotEmptyException) if not empty.
|
|
354
|
+
"""
|
|
355
|
+
org = _state.state.get_client("organizations")
|
|
356
|
+
org.delete_organizational_unit(OrganizationalUnitId=ou_id)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def rename_ou(ou_id: str, new_name: str) -> dict:
|
|
360
|
+
"""
|
|
361
|
+
Rename an OU. Returns the updated OU dict: {Id, Arn, Name}.
|
|
362
|
+
"""
|
|
363
|
+
org = _state.state.get_client("organizations")
|
|
364
|
+
resp = org.update_organizational_unit(
|
|
365
|
+
OrganizationalUnitId=ou_id,
|
|
366
|
+
Name=new_name,
|
|
367
|
+
)
|
|
368
|
+
return resp["OrganizationalUnit"]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def find_account_by_email(email: str, ou_id: str) -> str | None:
|
|
372
|
+
"""
|
|
373
|
+
Search for an account in the given OU by email address (case-insensitive, paginated).
|
|
374
|
+
Returns the account ID if found, None otherwise.
|
|
375
|
+
Only searches direct members of ou_id — does not recurse into child OUs.
|
|
376
|
+
"""
|
|
377
|
+
org = _state.state.get_client("organizations")
|
|
378
|
+
kwargs: dict = {"ParentId": ou_id}
|
|
379
|
+
while True:
|
|
380
|
+
resp = org.list_accounts_for_parent(**kwargs)
|
|
381
|
+
for acct in resp.get("Accounts", []):
|
|
382
|
+
if acct.get("Email", "").lower() == email.lower():
|
|
383
|
+
return acct["Id"]
|
|
384
|
+
if "NextToken" not in resp:
|
|
385
|
+
break
|
|
386
|
+
kwargs["NextToken"] = resp["NextToken"]
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def describe_ou(ou_id: str) -> dict:
|
|
391
|
+
"""
|
|
392
|
+
Return detailed information for an OU.
|
|
393
|
+
|
|
394
|
+
Adds:
|
|
395
|
+
ParentId — direct parent (OU or root ID)
|
|
396
|
+
ChildOUs — list of direct child OU dicts
|
|
397
|
+
Accounts — list of direct member account dicts
|
|
398
|
+
"""
|
|
399
|
+
org = _state.state.get_client("organizations")
|
|
400
|
+
resp = org.describe_organizational_unit(OrganizationalUnitId=ou_id)
|
|
401
|
+
ou = resp["OrganizationalUnit"]
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
ou["ParentId"] = _get_parent_id(ou_id)
|
|
405
|
+
except Exception:
|
|
406
|
+
ou["ParentId"] = "unknown"
|
|
407
|
+
|
|
408
|
+
child_ous: list[dict] = []
|
|
409
|
+
kwargs: dict = {"ParentId": ou_id}
|
|
410
|
+
while True:
|
|
411
|
+
r = org.list_organizational_units_for_parent(**kwargs)
|
|
412
|
+
child_ous.extend(r.get("OrganizationalUnits", []))
|
|
413
|
+
if "NextToken" not in r:
|
|
414
|
+
break
|
|
415
|
+
kwargs["NextToken"] = r["NextToken"]
|
|
416
|
+
ou["ChildOUs"] = child_ous
|
|
417
|
+
|
|
418
|
+
child_accounts: list[dict] = []
|
|
419
|
+
kwargs = {"ParentId": ou_id}
|
|
420
|
+
while True:
|
|
421
|
+
r = org.list_accounts_for_parent(**kwargs)
|
|
422
|
+
child_accounts.extend(r.get("Accounts", []))
|
|
423
|
+
if "NextToken" not in r:
|
|
424
|
+
break
|
|
425
|
+
kwargs["NextToken"] = r["NextToken"]
|
|
426
|
+
ou["Accounts"] = child_accounts
|
|
427
|
+
|
|
428
|
+
return ou
|