dh-cli 0.8.6__tar.gz → 0.8.8__tar.gz
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.
- {dh_cli-0.8.6 → dh_cli-0.8.8}/PKG-INFO +1 -1
- {dh_cli-0.8.6 → dh_cli-0.8.8}/pyproject.toml +1 -1
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/bedrock/commands.py +141 -30
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_cost_command.py +30 -7
- dh_cli-0.8.8/tests/bedrock/test_cursor_bedrock_isolation.py +271 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_key_command.py +73 -20
- dh_cli-0.8.8/tests/bedrock/test_rotate.py +284 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/.gitignore +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/LICENSE +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/README.md +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/train.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/main.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/__init__.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/__init__.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/conftest.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_identity.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_login.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_logout.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_status.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/hz/test_init.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/hz/test_users.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/test_finalize_protmpnn.py +0 -0
|
@@ -17,13 +17,30 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import datetime as dt
|
|
19
19
|
import json
|
|
20
|
+
import re
|
|
20
21
|
import sys
|
|
21
|
-
from typing import Optional
|
|
22
|
+
from typing import Optional, TypedDict
|
|
23
|
+
from uuid import uuid4
|
|
22
24
|
|
|
23
25
|
import click
|
|
24
26
|
|
|
27
|
+
from dh_cli._identity import HandleResolutionError, resolve_handle_from_session
|
|
28
|
+
from dh_cli.github_commands import _scrub_token
|
|
29
|
+
|
|
25
30
|
from . import cost_report as cr
|
|
26
31
|
|
|
32
|
+
|
|
33
|
+
class BedrockKeyPayload(TypedDict):
|
|
34
|
+
"""The JSON shape stored in `cursor-bedrock/<handle>`."""
|
|
35
|
+
|
|
36
|
+
access_key_id: str
|
|
37
|
+
secret_access_key: str
|
|
38
|
+
region: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Matches the admin SSO role within an assumed-role ARN.
|
|
42
|
+
_ADMIN_ROLE_RE = re.compile(r"AWSReservedSSO_DevAdminAccess_")
|
|
43
|
+
|
|
27
44
|
# Hard-coded for the dev account. Kept here (not as user-facing flags)
|
|
28
45
|
# because they never change in practice and making every dev remember
|
|
29
46
|
# them is the whole reason this command exists.
|
|
@@ -58,37 +75,21 @@ def bedrock():
|
|
|
58
75
|
# --------------------------------------------------------------------------
|
|
59
76
|
|
|
60
77
|
|
|
61
|
-
def
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
A DeveloperAccess SSO caller identity looks like::
|
|
65
|
-
|
|
66
|
-
arn:aws:sts::074735440724:assumed-role/AWSReservedSSO_DeveloperAccess_<id>/<handle>
|
|
78
|
+
def _resolve_handle() -> str:
|
|
79
|
+
"""Resolve the developer handle from the current SSO session.
|
|
67
80
|
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
Thin wrapper around `_identity.resolve_handle_from_session` that
|
|
82
|
+
builds a boto3 Session and turns `HandleResolutionError` into a
|
|
83
|
+
`ClickException` so Click prints it cleanly. Shared with `dh gh`
|
|
84
|
+
via `_identity.py` so both commands behave identically when the
|
|
85
|
+
caller's session can't be classified.
|
|
70
86
|
"""
|
|
71
87
|
import boto3
|
|
72
88
|
|
|
73
89
|
try:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
raise click.ClickException(
|
|
78
|
-
f"Could not call sts:GetCallerIdentity to resolve your handle: {exc}\n"
|
|
79
|
-
"Run `awslogin dev-devaccess` (or pass --handle explicitly)."
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
principal = cr.classify_arn(arn)
|
|
83
|
-
# Accept any AWSReservedSSO_* session: DeveloperAccess,
|
|
84
|
-
# DevAdminAccess, SecurityAdministrator, etc. all carry the
|
|
85
|
-
# IdC username as the role-session-name, which is what we want
|
|
86
|
-
# as the developer handle.
|
|
87
|
-
if principal.principal_type in ("claude-code", "claude-code-other", "cursor"):
|
|
88
|
-
return principal.principal_name
|
|
89
|
-
raise click.ClickException(
|
|
90
|
-
f"Couldn't infer a developer handle from your identity ({arn}). Pass --handle explicitly."
|
|
91
|
-
)
|
|
90
|
+
return resolve_handle_from_session(boto3.Session())
|
|
91
|
+
except HandleResolutionError as exc:
|
|
92
|
+
raise click.ClickException(str(exc))
|
|
92
93
|
|
|
93
94
|
|
|
94
95
|
# --------------------------------------------------------------------------
|
|
@@ -135,12 +136,21 @@ def bedrock_key(handle: Optional[str], region: str, mode: str):
|
|
|
135
136
|
"""
|
|
136
137
|
import boto3
|
|
137
138
|
|
|
138
|
-
resolved = handle or
|
|
139
|
+
resolved = handle or _resolve_handle()
|
|
139
140
|
secret_id = f"{_SECRET_PREFIX}{resolved}"
|
|
140
141
|
|
|
142
|
+
sm = boto3.client("secretsmanager", region_name=region)
|
|
143
|
+
not_found = getattr(sm.exceptions, "ResourceNotFoundException", None)
|
|
144
|
+
if not (isinstance(not_found, type) and issubclass(not_found, BaseException)):
|
|
145
|
+
not_found = ()
|
|
141
146
|
try:
|
|
142
|
-
sm = boto3.client("secretsmanager", region_name=region)
|
|
143
147
|
resp = sm.get_secret_value(SecretId=secret_id)
|
|
148
|
+
except not_found:
|
|
149
|
+
raise click.ClickException(
|
|
150
|
+
f"No key yet for `{resolved}` (secret `{secret_id}` has no current "
|
|
151
|
+
f"version). Ask an admin to run `dh bedrock rotate --handle {resolved}` "
|
|
152
|
+
"to mint your first key."
|
|
153
|
+
)
|
|
144
154
|
except Exception as exc:
|
|
145
155
|
raise click.ClickException(
|
|
146
156
|
f"Could not read secret `{secret_id}`: {exc}\n"
|
|
@@ -165,6 +175,107 @@ def bedrock_key(handle: Optional[str], region: str, mode: str):
|
|
|
165
175
|
click.echo(json.dumps(payload, indent=2))
|
|
166
176
|
|
|
167
177
|
|
|
178
|
+
# --------------------------------------------------------------------------
|
|
179
|
+
# `dh bedrock rotate`
|
|
180
|
+
# --------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@bedrock.command("rotate")
|
|
184
|
+
@click.option(
|
|
185
|
+
"--handle",
|
|
186
|
+
"-u",
|
|
187
|
+
required=True,
|
|
188
|
+
help="Developer handle (e.g. 'dma'). Mints a key for `dev-cursor-<handle>`.",
|
|
189
|
+
)
|
|
190
|
+
@click.option(
|
|
191
|
+
"--region",
|
|
192
|
+
default=_DEFAULT_REGION,
|
|
193
|
+
show_default=True,
|
|
194
|
+
help="AWS region for the Secrets Manager secret.",
|
|
195
|
+
)
|
|
196
|
+
def bedrock_rotate(handle: str, region: str):
|
|
197
|
+
"""Admin: mint a fresh Cursor Bedrock key for a developer.
|
|
198
|
+
|
|
199
|
+
Runs as `dev-admin`. Creates a new IAM access key for
|
|
200
|
+
`dev-cursor-<handle>`, writes it to `cursor-bedrock/<handle>`, then
|
|
201
|
+
deletes the prior key (delete-now).
|
|
202
|
+
|
|
203
|
+
\b
|
|
204
|
+
Examples:
|
|
205
|
+
dh bedrock rotate --handle dma
|
|
206
|
+
"""
|
|
207
|
+
import boto3
|
|
208
|
+
|
|
209
|
+
user_name = f"dev-cursor-{handle}"
|
|
210
|
+
secret_id = f"{_SECRET_PREFIX}{handle}"
|
|
211
|
+
|
|
212
|
+
# Admin pre-check (defense-in-depth) — before any AWS mutation.
|
|
213
|
+
sts = boto3.Session().client("sts")
|
|
214
|
+
caller_arn = sts.get_caller_identity()["Arn"]
|
|
215
|
+
if not _ADMIN_ROLE_RE.search(caller_arn):
|
|
216
|
+
raise click.ClickException(
|
|
217
|
+
f"This command must be run as dev-admin (`awslogin dev-admin`). "
|
|
218
|
+
f"Current caller is not a DevAdminAccess role: {caller_arn}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
iam = boto3.client("iam")
|
|
222
|
+
sm = boto3.client("secretsmanager", region_name=region)
|
|
223
|
+
|
|
224
|
+
# Preflight — before create_access_key.
|
|
225
|
+
try:
|
|
226
|
+
resp = iam.list_access_keys(UserName=user_name)
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
raise click.ClickException(
|
|
229
|
+
f"Could not list access keys for `{user_name}`: {exc}\n"
|
|
230
|
+
f"The IAM user may not exist yet — run `tfa` to provision it first."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
prior_active_ids = [
|
|
234
|
+
meta["AccessKeyId"]
|
|
235
|
+
for meta in resp.get("AccessKeyMetadata", [])
|
|
236
|
+
if meta.get("Status") == "Active"
|
|
237
|
+
]
|
|
238
|
+
if len(prior_active_ids) >= 2:
|
|
239
|
+
raise click.ClickException(
|
|
240
|
+
f"`{user_name}` already has 2 active keys (the IAM per-user limit). "
|
|
241
|
+
f"Delete one before rotating."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Transaction: create → put → delete prior.
|
|
245
|
+
create_resp = iam.create_access_key(UserName=user_name)
|
|
246
|
+
new_akid = create_resp["AccessKey"]["AccessKeyId"]
|
|
247
|
+
new_secret = create_resp["AccessKey"]["SecretAccessKey"]
|
|
248
|
+
|
|
249
|
+
payload: BedrockKeyPayload = {
|
|
250
|
+
"access_key_id": new_akid,
|
|
251
|
+
"secret_access_key": new_secret,
|
|
252
|
+
"region": region,
|
|
253
|
+
}
|
|
254
|
+
try:
|
|
255
|
+
sm.put_secret_value(
|
|
256
|
+
SecretId=secret_id,
|
|
257
|
+
SecretString=json.dumps(payload),
|
|
258
|
+
ClientRequestToken=uuid4().hex,
|
|
259
|
+
)
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
# Roll back the just-created key. Scrub the secret BEFORE building
|
|
262
|
+
# the message so the ClickException output can never leak it.
|
|
263
|
+
iam.delete_access_key(UserName=user_name, AccessKeyId=new_akid)
|
|
264
|
+
scrubbed = _scrub_token(str(exc), new_secret)
|
|
265
|
+
raise click.ClickException(
|
|
266
|
+
f"PutSecretValue failed for `{secret_id}`; rolled back new key "
|
|
267
|
+
f"{new_akid}. No prior key was deleted. Error: {scrubbed}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Only after put succeeds: delete prior active keys (not the new one).
|
|
271
|
+
for old_id in prior_active_ids:
|
|
272
|
+
if old_id == new_akid:
|
|
273
|
+
continue
|
|
274
|
+
iam.delete_access_key(UserName=user_name, AccessKeyId=old_id)
|
|
275
|
+
|
|
276
|
+
click.echo(f"Rotated `{secret_id}`. New access_key_id: {new_akid}")
|
|
277
|
+
|
|
278
|
+
|
|
168
279
|
# --------------------------------------------------------------------------
|
|
169
280
|
# `dh bedrock cost`
|
|
170
281
|
# --------------------------------------------------------------------------
|
|
@@ -316,7 +427,7 @@ def bedrock_cost(
|
|
|
316
427
|
|
|
317
428
|
my_handle: Optional[str] = None
|
|
318
429
|
if me:
|
|
319
|
-
my_handle =
|
|
430
|
+
my_handle = _resolve_handle()
|
|
320
431
|
|
|
321
432
|
try:
|
|
322
433
|
records = cr.walk_logs(
|
|
@@ -23,15 +23,32 @@ def _sts_stub(arn: str = _DEV_ARN):
|
|
|
23
23
|
return sts
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _session_stub(sts: MagicMock) -> MagicMock:
|
|
27
|
+
"""boto3.Session() stub whose .client('sts') returns `sts`.
|
|
28
|
+
|
|
29
|
+
`_resolve_handle` (used by `bedrock_cost --me`) goes through
|
|
30
|
+
`_identity.resolve_handle_from_session(boto3.Session())`, which
|
|
31
|
+
calls `session.client('sts')`. The S3 client used by walk_logs
|
|
32
|
+
still goes through `boto3.client('s3', ...)` directly, which is
|
|
33
|
+
patched separately.
|
|
34
|
+
"""
|
|
35
|
+
session = MagicMock()
|
|
36
|
+
session.client.return_value = sts
|
|
37
|
+
return session
|
|
38
|
+
|
|
39
|
+
|
|
26
40
|
def _patch_boto3(sts=None):
|
|
41
|
+
sts_stub = sts or _sts_stub()
|
|
42
|
+
|
|
27
43
|
def _client(name, **_kwargs):
|
|
28
44
|
if name == "s3":
|
|
29
45
|
return MagicMock()
|
|
30
|
-
|
|
31
|
-
return sts or _sts_stub()
|
|
32
|
-
raise AssertionError(f"unexpected boto3 client: {name}")
|
|
46
|
+
raise AssertionError(f"unexpected boto3.client call: {name}")
|
|
33
47
|
|
|
34
|
-
return
|
|
48
|
+
return (
|
|
49
|
+
patch("boto3.Session", return_value=_session_stub(sts_stub)),
|
|
50
|
+
patch("boto3.client", side_effect=_client),
|
|
51
|
+
)
|
|
35
52
|
|
|
36
53
|
|
|
37
54
|
def _invoke(args):
|
|
@@ -55,8 +72,10 @@ def test_cost_default_window_is_last_seven_days_ending_today_utc(load_fixture):
|
|
|
55
72
|
captured_kwargs.update(kwargs)
|
|
56
73
|
return iter([load_fixture("A_cache_write.json")])
|
|
57
74
|
|
|
75
|
+
p_sess, p_cli = _patch_boto3()
|
|
58
76
|
with (
|
|
59
|
-
|
|
77
|
+
p_sess,
|
|
78
|
+
p_cli,
|
|
60
79
|
patch("dh_cli.bedrock.cost_report.walk_logs", side_effect=_walk_stub),
|
|
61
80
|
patch(
|
|
62
81
|
"dh_cli.bedrock.cost_report.fetch_cost_explorer_total",
|
|
@@ -80,8 +99,10 @@ def test_cost_default_window_is_last_seven_days_ending_today_utc(load_fixture):
|
|
|
80
99
|
|
|
81
100
|
def test_cost_explicit_past_end_runs_ce_reconcile(load_fixture):
|
|
82
101
|
"""When --end is in the past, CE reconcile runs as before."""
|
|
102
|
+
p_sess, p_cli = _patch_boto3()
|
|
83
103
|
with (
|
|
84
|
-
|
|
104
|
+
p_sess,
|
|
105
|
+
p_cli,
|
|
85
106
|
patch("dh_cli.bedrock.cost_report.walk_logs") as walk,
|
|
86
107
|
patch(
|
|
87
108
|
"dh_cli.bedrock.cost_report.fetch_cost_explorer_total",
|
|
@@ -118,8 +139,10 @@ def test_cost_me_filters_by_caller_handle_for_principal_type_grouping(load_fixtu
|
|
|
118
139
|
]
|
|
119
140
|
)
|
|
120
141
|
|
|
142
|
+
p_sess, p_cli = _patch_boto3(sts=_sts_stub(_DEV_ARN))
|
|
121
143
|
with (
|
|
122
|
-
|
|
144
|
+
p_sess,
|
|
145
|
+
p_cli,
|
|
123
146
|
patch("dh_cli.bedrock.cost_report.walk_logs", side_effect=_walk_stub),
|
|
124
147
|
patch(
|
|
125
148
|
"dh_cli.bedrock.cost_report.fetch_cost_explorer_total",
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Invariant test: the per-user Cursor Bedrock secret policy isolates
|
|
2
|
+
devs on BOTH the read and write planes.
|
|
3
|
+
|
|
4
|
+
Runs nightly against dev via `iam:SimulatePrincipalPolicy`. A future
|
|
5
|
+
Terraform change that drops the `DenyNonOwnerDataAccess` write actions,
|
|
6
|
+
the `ArnNotLike` admin carve-out, or the `AllowAdminRotate` statement
|
|
7
|
+
will fail this within 24 hours.
|
|
8
|
+
|
|
9
|
+
This is the behavioral counterpart to `verify_cursor_keys.sh`, which
|
|
10
|
+
only checks the policy *shape* (and runs as admin, whose identity policy
|
|
11
|
+
bypasses the resource policy, so it cannot prove the deny actually
|
|
12
|
+
fires). Mirrors `tests/github/test_engine_role_cannot_read_github_pat.py`,
|
|
13
|
+
with one important difference:
|
|
14
|
+
|
|
15
|
+
- The github engine-role test simulates an *identity* policy only and
|
|
16
|
+
asserts `implicitDeny` (the role simply has no grant). THIS test
|
|
17
|
+
exercises the secret's *resource-based* policy and asserts
|
|
18
|
+
`explicitDeny` for the cross-user data actions (the resource-policy
|
|
19
|
+
deny actively fires) and `allowed` for owner-read / admin-write.
|
|
20
|
+
|
|
21
|
+
SimulatePrincipalPolicy + resource-policy nuance (confirmed against the
|
|
22
|
+
boto3 docs while implementing):
|
|
23
|
+
|
|
24
|
+
- The simulator does NOT auto-fetch a resource's policy; it must be
|
|
25
|
+
passed as the `ResourcePolicy` string. We fetch the live policy via
|
|
26
|
+
`secretsmanager:GetResourcePolicy` so the test tracks whatever
|
|
27
|
+
Terraform actually applied (not a hard-coded copy).
|
|
28
|
+
- Resource-policy evaluation in the simulator is only wired up cleanly
|
|
29
|
+
for IAM *users*, not roles. Our principals are SSO permission-set
|
|
30
|
+
*roles*, so we drive the decision purely from the resource policy +
|
|
31
|
+
`ContextEntries` (`aws:userid`, `aws:PrincipalArn`,
|
|
32
|
+
`aws:MultiFactorAuthPresent`) and pass a real principal ARN as
|
|
33
|
+
`CallerArn`. The identity-policy side (DeveloperAccess's broad
|
|
34
|
+
`cursor-bedrock/*` grant) is what the resource-policy deny is the
|
|
35
|
+
belt-and-suspenders for; here we assert the resource policy in
|
|
36
|
+
isolation, which is the statement under test.
|
|
37
|
+
- `ResourceOwner` is the dev account, since the simulated secret ARN
|
|
38
|
+
carries the account already but the resource policy is supplied
|
|
39
|
+
out-of-band.
|
|
40
|
+
|
|
41
|
+
Fallback if the simulator ever stops honoring `ResourcePolicy` for
|
|
42
|
+
`secretsmanager:*` (as it does for `sts:Assume*`): the operator's live
|
|
43
|
+
owner/non-owner `aws secretsmanager get-secret-value` /
|
|
44
|
+
`put-secret-value` probes (run under the respective SSO sessions) are
|
|
45
|
+
the ground-truth backstop; see `blueprints/scripts/bedrock/README.md`.
|
|
46
|
+
|
|
47
|
+
Marked `integration` so it doesn't run in the default `pytest` unit
|
|
48
|
+
suite (no AWS creds in CI runners).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import os
|
|
54
|
+
|
|
55
|
+
import pytest
|
|
56
|
+
|
|
57
|
+
ACCOUNT_ID = "074735440724"
|
|
58
|
+
REGION = "us-east-1"
|
|
59
|
+
|
|
60
|
+
# A non-owner DeveloperAccess role ARN (the principal whose cross-user
|
|
61
|
+
# access must be denied) and a DevAdminAccess role ARN (the rotate
|
|
62
|
+
# principal whose write must be allowed). Plus the owner handle for the
|
|
63
|
+
# allowed-read case. Set these to run the guard; unset => skip.
|
|
64
|
+
#
|
|
65
|
+
# IMPORTANT: `simulate_principal_policy` honors a `ResourcePolicy` /
|
|
66
|
+
# accepts a `CallerArn` only for IAM *user* sources, not assumed-role
|
|
67
|
+
# ARNs. The deny/allow conditions key on `aws:userid` / `aws:PrincipalArn`
|
|
68
|
+
# which we supply via ContextEntries regardless, so point these at IAM
|
|
69
|
+
# user ARNs that the simulator will accept (the decision is driven by the
|
|
70
|
+
# resource policy + context, not by the source identity's own policies).
|
|
71
|
+
# If only SSO role ARNs are available and the simulator rejects them,
|
|
72
|
+
# fall back to the live owner/non-owner probes documented in the runbook.
|
|
73
|
+
NONOWNER_DEV_ROLE_ARN_ENV = "DH_BEDROCK_NONOWNER_DEV_ROLE_ARN"
|
|
74
|
+
ADMIN_ROLE_ARN_ENV = "DH_BEDROCK_ADMIN_ROLE_ARN"
|
|
75
|
+
OWNER_HANDLE_ENV = "DH_BEDROCK_OWNER_HANDLE"
|
|
76
|
+
|
|
77
|
+
# The handle whose secret the non-owner principal tries to touch. Any
|
|
78
|
+
# handle that is NOT the non-owner's own session name works; default to a
|
|
79
|
+
# real per-user handle from cursor_bedrock_users.
|
|
80
|
+
OTHER_HANDLE = os.environ.get("DH_BEDROCK_OTHER_HANDLE", "toni")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _secret_arn(handle: str) -> str:
|
|
84
|
+
return f"arn:aws:secretsmanager:{REGION}:{ACCOUNT_ID}:secret:cursor-bedrock/{handle}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _role_unique_id(role_arn: str) -> str:
|
|
88
|
+
"""`aws:userid` for an assumed SSO role is `<role-unique-id>:<session>`.
|
|
89
|
+
|
|
90
|
+
We don't have the role's unique id from the ARN alone, so we resolve
|
|
91
|
+
it from IAM. Tests that only need `aws:PrincipalArn` (the
|
|
92
|
+
cross-user/admin cases keyed on the role ARN) don't call this.
|
|
93
|
+
"""
|
|
94
|
+
import boto3
|
|
95
|
+
|
|
96
|
+
role_name = role_arn.split("/")[-1]
|
|
97
|
+
iam = boto3.client("iam")
|
|
98
|
+
return iam.get_role(RoleName=role_name)["Role"]["RoleId"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fetch_resource_policy(handle: str) -> str:
|
|
102
|
+
import boto3
|
|
103
|
+
|
|
104
|
+
sm = boto3.client("secretsmanager", region_name=REGION)
|
|
105
|
+
resp = sm.get_resource_policy(SecretId=f"cursor-bedrock/{handle}")
|
|
106
|
+
policy = resp.get("ResourcePolicy")
|
|
107
|
+
if not policy:
|
|
108
|
+
pytest.skip(f"cursor-bedrock/{handle} has no resource policy attached")
|
|
109
|
+
return policy
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _simulate(
|
|
113
|
+
*,
|
|
114
|
+
caller_arn: str,
|
|
115
|
+
action: str,
|
|
116
|
+
resource_arn: str,
|
|
117
|
+
resource_policy: str,
|
|
118
|
+
context_entries: list[dict],
|
|
119
|
+
) -> str:
|
|
120
|
+
import boto3
|
|
121
|
+
|
|
122
|
+
iam = boto3.client("iam")
|
|
123
|
+
result = iam.simulate_principal_policy(
|
|
124
|
+
PolicySourceArn=caller_arn,
|
|
125
|
+
ActionNames=[action],
|
|
126
|
+
ResourceArns=[resource_arn],
|
|
127
|
+
ResourcePolicy=resource_policy,
|
|
128
|
+
ResourceOwner=ACCOUNT_ID,
|
|
129
|
+
CallerArn=caller_arn,
|
|
130
|
+
ContextEntries=context_entries,
|
|
131
|
+
)
|
|
132
|
+
return result["EvaluationResults"][0]["EvalDecision"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ctx(principal_arn: str, userid: str) -> list[dict]:
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
"ContextKeyName": "aws:PrincipalArn",
|
|
139
|
+
"ContextKeyValues": [principal_arn],
|
|
140
|
+
"ContextKeyType": "string",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"ContextKeyName": "aws:userid",
|
|
144
|
+
"ContextKeyValues": [userid],
|
|
145
|
+
"ContextKeyType": "string",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"ContextKeyName": "aws:MultiFactorAuthPresent",
|
|
149
|
+
"ContextKeyValues": ["true"],
|
|
150
|
+
"ContextKeyType": "boolean",
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _require(env_var: str) -> str:
|
|
156
|
+
value = os.environ.get(env_var)
|
|
157
|
+
if not value:
|
|
158
|
+
pytest.skip(
|
|
159
|
+
f"Set {env_var} (and the other DH_BEDROCK_* role ARNs / owner "
|
|
160
|
+
f"handle) to run the Cursor Bedrock isolation guard."
|
|
161
|
+
)
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.integration
|
|
166
|
+
def test_non_owner_developer_denied_get():
|
|
167
|
+
"""A DeveloperAccess non-owner is explicitly denied GetSecretValue on
|
|
168
|
+
another user's cursor-bedrock secret (DenyNonOwnerDataAccess fires)."""
|
|
169
|
+
dev_arn = _require(NONOWNER_DEV_ROLE_ARN_ENV)
|
|
170
|
+
policy = _fetch_resource_policy(OTHER_HANDLE)
|
|
171
|
+
# Session name deliberately != OTHER_HANDLE so the userid doesn't match.
|
|
172
|
+
decision = _simulate(
|
|
173
|
+
caller_arn=dev_arn,
|
|
174
|
+
action="secretsmanager:GetSecretValue",
|
|
175
|
+
resource_arn=_secret_arn(OTHER_HANDLE),
|
|
176
|
+
resource_policy=policy,
|
|
177
|
+
context_entries=_ctx(dev_arn, "AROANONOWNERDEV:intruder"),
|
|
178
|
+
)
|
|
179
|
+
assert decision == "explicitDeny", (
|
|
180
|
+
f"A non-owner DeveloperAccess principal must be explicitly denied "
|
|
181
|
+
f"GetSecretValue on cursor-bedrock/{OTHER_HANDLE}; got {decision}. "
|
|
182
|
+
f"DenyNonOwnerDataAccess (read plane) regressed."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.mark.integration
|
|
187
|
+
def test_non_owner_developer_denied_put():
|
|
188
|
+
"""Write-plane isolation: a non-owner non-admin is explicitly denied
|
|
189
|
+
PutSecretValue (cannot overwrite another dev's key)."""
|
|
190
|
+
dev_arn = _require(NONOWNER_DEV_ROLE_ARN_ENV)
|
|
191
|
+
policy = _fetch_resource_policy(OTHER_HANDLE)
|
|
192
|
+
decision = _simulate(
|
|
193
|
+
caller_arn=dev_arn,
|
|
194
|
+
action="secretsmanager:PutSecretValue",
|
|
195
|
+
resource_arn=_secret_arn(OTHER_HANDLE),
|
|
196
|
+
resource_policy=policy,
|
|
197
|
+
context_entries=_ctx(dev_arn, "AROANONOWNERDEV:intruder"),
|
|
198
|
+
)
|
|
199
|
+
assert decision == "explicitDeny", (
|
|
200
|
+
f"A non-owner DeveloperAccess principal must be explicitly denied "
|
|
201
|
+
f"PutSecretValue on cursor-bedrock/{OTHER_HANDLE}; got {decision}. "
|
|
202
|
+
f"The write-hole (Council C1) reopened."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.integration
|
|
207
|
+
def test_non_owner_developer_denied_update_stage():
|
|
208
|
+
"""Write-plane isolation: a non-owner non-admin is explicitly denied
|
|
209
|
+
UpdateSecretVersionStage."""
|
|
210
|
+
dev_arn = _require(NONOWNER_DEV_ROLE_ARN_ENV)
|
|
211
|
+
policy = _fetch_resource_policy(OTHER_HANDLE)
|
|
212
|
+
decision = _simulate(
|
|
213
|
+
caller_arn=dev_arn,
|
|
214
|
+
action="secretsmanager:UpdateSecretVersionStage",
|
|
215
|
+
resource_arn=_secret_arn(OTHER_HANDLE),
|
|
216
|
+
resource_policy=policy,
|
|
217
|
+
context_entries=_ctx(dev_arn, "AROANONOWNERDEV:intruder"),
|
|
218
|
+
)
|
|
219
|
+
assert decision == "explicitDeny", (
|
|
220
|
+
f"A non-owner DeveloperAccess principal must be explicitly denied "
|
|
221
|
+
f"UpdateSecretVersionStage on cursor-bedrock/{OTHER_HANDLE}; got "
|
|
222
|
+
f"{decision}. DenyNonOwnerDataAccess (write plane) regressed."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.integration
|
|
227
|
+
def test_owner_developer_allowed_get():
|
|
228
|
+
"""The owner reading their OWN secret is allowed. The match is on
|
|
229
|
+
aws:userid StringLike "*:<owner>", so the simulated session name must
|
|
230
|
+
equal the owner handle and the resolved role unique-id is its prefix."""
|
|
231
|
+
dev_arn = _require(NONOWNER_DEV_ROLE_ARN_ENV)
|
|
232
|
+
owner = _require(OWNER_HANDLE_ENV)
|
|
233
|
+
policy = _fetch_resource_policy(owner)
|
|
234
|
+
role_id = _role_unique_id(dev_arn)
|
|
235
|
+
decision = _simulate(
|
|
236
|
+
caller_arn=dev_arn,
|
|
237
|
+
action="secretsmanager:GetSecretValue",
|
|
238
|
+
resource_arn=_secret_arn(owner),
|
|
239
|
+
resource_policy=policy,
|
|
240
|
+
# userid ends in ":<owner>" (matches AllowOwnerSSORead StringLike).
|
|
241
|
+
context_entries=_ctx(dev_arn, f"{role_id}:{owner}"),
|
|
242
|
+
)
|
|
243
|
+
assert decision == "allowed", (
|
|
244
|
+
f"The owner reading cursor-bedrock/{owner} under their own SSO "
|
|
245
|
+
f"session must be allowed; got {decision}. AllowOwnerSSORead "
|
|
246
|
+
f"regressed (or the deny over-fires on the owner)."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.integration
|
|
251
|
+
def test_admin_allowed_put():
|
|
252
|
+
"""The DevAdminAccess rotate role writing another user's secret is
|
|
253
|
+
allowed: AllowAdminRotate fires and the deny's ArnNotLike carve-out
|
|
254
|
+
lets it through."""
|
|
255
|
+
admin_arn = _require(ADMIN_ROLE_ARN_ENV)
|
|
256
|
+
policy = _fetch_resource_policy(OTHER_HANDLE)
|
|
257
|
+
decision = _simulate(
|
|
258
|
+
caller_arn=admin_arn,
|
|
259
|
+
action="secretsmanager:PutSecretValue",
|
|
260
|
+
resource_arn=_secret_arn(OTHER_HANDLE),
|
|
261
|
+
resource_policy=policy,
|
|
262
|
+
# Admin session name != OTHER_HANDLE; the allow is keyed on the
|
|
263
|
+
# admin's aws:PrincipalArn, not userid.
|
|
264
|
+
context_entries=_ctx(admin_arn, "AROADMIN:dma"),
|
|
265
|
+
)
|
|
266
|
+
assert decision == "allowed", (
|
|
267
|
+
f"The DevAdminAccess rotate role must be allowed PutSecretValue on "
|
|
268
|
+
f"cursor-bedrock/{OTHER_HANDLE}; got {decision}. Either "
|
|
269
|
+
f"AllowAdminRotate or the DenyNonOwnerDataAccess ArnNotLike "
|
|
270
|
+
f"carve-out regressed — `dh bedrock rotate` would break."
|
|
271
|
+
)
|