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.
Files changed (96) hide show
  1. {dh_cli-0.8.6 → dh_cli-0.8.8}/PKG-INFO +1 -1
  2. {dh_cli-0.8.6 → dh_cli-0.8.8}/pyproject.toml +1 -1
  3. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/bedrock/commands.py +141 -30
  4. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_cost_command.py +30 -7
  5. dh_cli-0.8.8/tests/bedrock/test_cursor_bedrock_isolation.py +271 -0
  6. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_key_command.py +73 -20
  7. dh_cli-0.8.8/tests/bedrock/test_rotate.py +284 -0
  8. {dh_cli-0.8.6 → dh_cli-0.8.8}/.gitignore +0 -0
  9. {dh_cli-0.8.6 → dh_cli-0.8.8}/LICENSE +0 -0
  10. {dh_cli-0.8.6 → dh_cli-0.8.8}/README.md +0 -0
  11. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/__init__.py +0 -0
  12. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/_identity.py +0 -0
  13. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/__init__.py +0 -0
  14. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/aws_batch.py +0 -0
  15. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/__init__.py +0 -0
  16. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/boltz.py +0 -0
  17. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/cancel.py +0 -0
  18. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/clean.py +0 -0
  19. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  20. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/finalize.py +0 -0
  21. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  22. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/local.py +0 -0
  23. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/logs.py +0 -0
  24. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/orca.py +0 -0
  25. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  26. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  27. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/retry.py +0 -0
  28. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/status.py +0 -0
  29. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/submit.py +0 -0
  30. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/train.py +0 -0
  31. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/commands/wait_for.py +0 -0
  32. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/fasta_utils.py +0 -0
  33. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/h5_utils.py +0 -0
  34. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/job_id.py +0 -0
  35. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/manifest.py +0 -0
  36. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/batch/s3_transport.py +0 -0
  37. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/bedrock/__init__.py +0 -0
  38. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/bedrock/cost_report.py +0 -0
  39. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/bedrock/pricing.yaml +0 -0
  40. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/cloud_commands.py +0 -0
  41. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/codeartifact.py +0 -0
  42. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/__init__.py +0 -0
  43. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/api_client.py +0 -0
  44. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/auth.py +0 -0
  45. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/engine_commands.py +0 -0
  46. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/progress.py +0 -0
  47. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  48. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  49. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/github_commands.py +0 -0
  50. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/__init__.py +0 -0
  51. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/deploy.py +0 -0
  52. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/local.py +0 -0
  53. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/test.py +0 -0
  54. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/tf.py +0 -0
  55. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/hz/users.py +0 -0
  56. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/main.py +0 -0
  57. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/utility_commands.py +0 -0
  58. {dh_cli-0.8.6 → dh_cli-0.8.8}/src/dh_cli/warehouse.py +0 -0
  59. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/__init__.py +0 -0
  60. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_aws_batch_resources.py +0 -0
  61. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_image_override.py +0 -0
  62. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_submit_cpu_only.py +0 -0
  63. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_submit_image_validation.py +0 -0
  64. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/batch/test_submit_merge.py +0 -0
  65. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/conftest.py +0 -0
  66. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  67. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  68. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/C_plain.json +0 -0
  69. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  70. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/E_service_role.json +0 -0
  71. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  72. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  73. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_build_report.py +0 -0
  74. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_classify_arn.py +0 -0
  75. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_cli_exit_codes.py +0 -0
  76. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_cost_calc.py +0 -0
  77. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_cur_reconciliation.py +0 -0
  78. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_render_formats.py +0 -0
  79. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_resolve_base_model.py +0 -0
  80. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/bedrock/test_s3_walker.py +0 -0
  81. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/__init__.py +0 -0
  82. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/conftest.py +0 -0
  83. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  84. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_identity.py +0 -0
  85. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_login.py +0 -0
  86. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_login_error_paths.py +0 -0
  87. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_login_security.py +0 -0
  88. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_logout.py +0 -0
  89. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_rotate.py +0 -0
  90. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/github/test_status.py +0 -0
  91. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/hz/test_init.py +0 -0
  92. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/hz/test_suites.py +0 -0
  93. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/hz/test_users.py +0 -0
  94. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/test_cloud_gcp.py +0 -0
  95. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/test_finalize_boltz_tar.py +0 -0
  96. {dh_cli-0.8.6 → dh_cli-0.8.8}/tests/test_finalize_protmpnn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dh-cli
3
- Version: 0.8.6
3
+ Version: 0.8.8
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.8.6"
7
+ version = "0.8.8"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -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 _resolve_handle_from_sts() -> str:
62
- """Pull the developer handle from the current SSO session.
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
- We return `<handle>`. Raises ClickException if STS fails or the ARN
69
- shape isn't recognisable.
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
- sts = boto3.client("sts")
75
- arn = sts.get_caller_identity()["Arn"]
76
- except Exception as exc:
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 _resolve_handle_from_sts()
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 = _resolve_handle_from_sts()
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
- if name == "sts":
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 patch("boto3.client", side_effect=_client)
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
- _patch_boto3(),
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
- _patch_boto3(),
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
- _patch_boto3(sts=_sts_stub(_DEV_ARN)),
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
+ )