dh-cli 0.8.7__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.7 → dh_cli-0.8.8}/PKG-INFO +1 -1
- {dh_cli-0.8.7 → dh_cli-0.8.8}/pyproject.toml +1 -1
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/bedrock/commands.py +127 -2
- {dh_cli-0.8.7 → 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.7 → dh_cli-0.8.8}/tests/bedrock/test_key_command.py +26 -0
- dh_cli-0.8.8/tests/bedrock/test_rotate.py +284 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/.gitignore +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/LICENSE +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/README.md +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/_identity.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/aws_batch.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/boltz.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/cancel.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/clean.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/embed_t5.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/finalize.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/list_jobs.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/local.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/logs.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/orca.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/protmpnn.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/retry.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/status.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/submit.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/train.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/commands/wait_for.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/fasta_utils.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/h5_utils.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/job_id.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/manifest.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/batch/s3_transport.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/bedrock/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/bedrock/cost_report.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/bedrock/pricing.yaml +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/cloud_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/codeartifact.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/engines_studios/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/engines_studios/api_client.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/engines_studios/auth.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/engines_studios/engine_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/engines_studios/progress.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/engines_studios/ssh_config.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/engines_studios/studio_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/github_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/hz/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/hz/deploy.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/hz/local.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/hz/test.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/hz/tf.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/hz/users.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/main.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/utility_commands.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/src/dh_cli/warehouse.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/batch/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/batch/test_aws_batch_resources.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/batch/test_image_override.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/batch/test_submit_cpu_only.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/batch/test_submit_image_validation.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/batch/test_submit_merge.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/conftest.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/fixtures/A_cache_write.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/fixtures/B_cache_read.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/fixtures/C_plain.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/fixtures/E_service_role.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/test_build_report.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/test_classify_arn.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/test_cli_exit_codes.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/test_cost_calc.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/test_cur_reconciliation.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/test_render_formats.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/test_resolve_base_model.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/bedrock/test_s3_walker.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/__init__.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/conftest.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/test_identity.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/test_login.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/test_login_error_paths.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/test_login_security.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/test_logout.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/test_rotate.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/github/test_status.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/hz/test_init.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/hz/test_suites.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/hz/test_users.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/test_cloud_gcp.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/test_finalize_boltz_tar.py +0 -0
- {dh_cli-0.8.7 → dh_cli-0.8.8}/tests/test_finalize_protmpnn.py +0 -0
|
@@ -17,15 +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
|
|
|
25
27
|
from dh_cli._identity import HandleResolutionError, resolve_handle_from_session
|
|
28
|
+
from dh_cli.github_commands import _scrub_token
|
|
26
29
|
|
|
27
30
|
from . import cost_report as cr
|
|
28
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
|
+
|
|
29
44
|
# Hard-coded for the dev account. Kept here (not as user-facing flags)
|
|
30
45
|
# because they never change in practice and making every dev remember
|
|
31
46
|
# them is the whole reason this command exists.
|
|
@@ -124,9 +139,18 @@ def bedrock_key(handle: Optional[str], region: str, mode: str):
|
|
|
124
139
|
resolved = handle or _resolve_handle()
|
|
125
140
|
secret_id = f"{_SECRET_PREFIX}{resolved}"
|
|
126
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 = ()
|
|
127
146
|
try:
|
|
128
|
-
sm = boto3.client("secretsmanager", region_name=region)
|
|
129
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
|
+
)
|
|
130
154
|
except Exception as exc:
|
|
131
155
|
raise click.ClickException(
|
|
132
156
|
f"Could not read secret `{secret_id}`: {exc}\n"
|
|
@@ -151,6 +175,107 @@ def bedrock_key(handle: Optional[str], region: str, mode: str):
|
|
|
151
175
|
click.echo(json.dumps(payload, indent=2))
|
|
152
176
|
|
|
153
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
|
+
|
|
154
279
|
# --------------------------------------------------------------------------
|
|
155
280
|
# `dh bedrock cost`
|
|
156
281
|
# --------------------------------------------------------------------------
|
|
@@ -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
|
+
)
|
|
@@ -138,6 +138,32 @@ def test_key_unrecognised_caller_arn_exits_with_hint():
|
|
|
138
138
|
assert "awslogin" in result.output
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
def test_key_no_current_version_gives_clear_error():
|
|
142
|
+
"""A container with no AWSCURRENT version (a freshly-provisioned,
|
|
143
|
+
not-yet-rotated handle) must give a clear "no key yet — ask an admin to
|
|
144
|
+
run `dh bedrock rotate --handle <you>`" message, not a raw boto error.
|
|
145
|
+
|
|
146
|
+
The no-AWSCURRENT case surfaces as `get_secret_value` raising
|
|
147
|
+
ResourceNotFoundException for the version."""
|
|
148
|
+
|
|
149
|
+
class _ResourceNotFound(Exception):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
sm = MagicMock()
|
|
153
|
+
sm.exceptions.ResourceNotFoundException = _ResourceNotFound
|
|
154
|
+
sm.get_secret_value.side_effect = _ResourceNotFound(
|
|
155
|
+
"Secrets Manager can't find the specified secret value for staging label: AWSCURRENT"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
p_sess, p_cli = _patch_boto3(sm=sm)
|
|
159
|
+
with p_sess, p_cli:
|
|
160
|
+
result = _invoke([])
|
|
161
|
+
assert result.exit_code != 0
|
|
162
|
+
out = result.output.lower()
|
|
163
|
+
assert "rotate" in out
|
|
164
|
+
assert "admin" in out
|
|
165
|
+
|
|
166
|
+
|
|
141
167
|
def test_key_resolves_handle_from_dev_admin_access_session():
|
|
142
168
|
"""DevAdminAccess (and any other AWSReservedSSO_* role) must resolve
|
|
143
169
|
to the session-name as the developer handle. Regression: earlier the
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Tests for `dh bedrock rotate` — the admin-run key minter.
|
|
2
|
+
|
|
3
|
+
`rotate` is the one place `iam:CreateAccessKey` + `secretsmanager:PutSecretValue`
|
|
4
|
+
are called for a per-developer Cursor Bedrock key. It runs as the *admin*
|
|
5
|
+
(`dev-admin`), mints a fresh key for `dev-cursor-<handle>`, writes the
|
|
6
|
+
`{access_key_id, secret_access_key, region}` JSON to `cursor-bedrock/<handle>`,
|
|
7
|
+
and **then** deletes the prior key (delete-now).
|
|
8
|
+
|
|
9
|
+
The tests lock in the security/correctness invariants:
|
|
10
|
+
- `--handle` is required.
|
|
11
|
+
- Non-admin callers fail fast BEFORE any AWS write.
|
|
12
|
+
- Happy path is create → put → delete, in that order.
|
|
13
|
+
- Works against an empty container (no AWSCURRENT) — onboarding seed.
|
|
14
|
+
- A `PutSecretValue` failure rolls back the just-created key, never touching
|
|
15
|
+
any prior key.
|
|
16
|
+
- The `secret_access_key` never appears in stdout/stderr on any path.
|
|
17
|
+
- Preflight catches missing users and the 2-active-key IAM limit before
|
|
18
|
+
`CreateAccessKey`.
|
|
19
|
+
- The new (non-secret) `access_key_id` is printed on success.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from unittest.mock import MagicMock, patch
|
|
26
|
+
|
|
27
|
+
from click.testing import CliRunner
|
|
28
|
+
|
|
29
|
+
_ADMIN_ARN = "arn:aws:sts::074735440724:assumed-role/AWSReservedSSO_DevAdminAccess_2506d659c73583cc/dma"
|
|
30
|
+
_DEV_ARN = "arn:aws:sts::074735440724:assumed-role/AWSReservedSSO_DeveloperAccess_abcdef1234567890/dma"
|
|
31
|
+
|
|
32
|
+
_NEW_AKID = "AKIANEWKEY0000000NEW"
|
|
33
|
+
_OLD_AKID = "AKIAOLDKEY0000000OLD"
|
|
34
|
+
# Distinctive sentinel — a substring match anywhere in output is a real leak.
|
|
35
|
+
_SENTINEL_SECRET = "wSENTINEL/SecretAccessKey/0123456789ABCDEFROT"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_sts_stub(arn: str = _ADMIN_ARN) -> MagicMock:
|
|
39
|
+
sts = MagicMock()
|
|
40
|
+
sts.get_caller_identity.return_value = {"Arn": arn}
|
|
41
|
+
return sts
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_session_stub(sts: MagicMock) -> MagicMock:
|
|
45
|
+
session = MagicMock()
|
|
46
|
+
session.client.return_value = sts
|
|
47
|
+
return session
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_iam_stub(
|
|
51
|
+
*,
|
|
52
|
+
existing_key_ids: list[str] | None = None,
|
|
53
|
+
new_akid: str = _NEW_AKID,
|
|
54
|
+
new_secret: str = _SENTINEL_SECRET,
|
|
55
|
+
list_keys_error: Exception | None = None,
|
|
56
|
+
) -> MagicMock:
|
|
57
|
+
iam = MagicMock()
|
|
58
|
+
if list_keys_error is not None:
|
|
59
|
+
iam.list_access_keys.side_effect = list_keys_error
|
|
60
|
+
else:
|
|
61
|
+
ids = existing_key_ids if existing_key_ids is not None else []
|
|
62
|
+
iam.list_access_keys.return_value = {
|
|
63
|
+
"AccessKeyMetadata": [{"AccessKeyId": k, "Status": "Active"} for k in ids]
|
|
64
|
+
}
|
|
65
|
+
iam.create_access_key.return_value = {
|
|
66
|
+
"AccessKey": {"AccessKeyId": new_akid, "SecretAccessKey": new_secret}
|
|
67
|
+
}
|
|
68
|
+
return iam
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_sm_stub(*, put_error: Exception | None = None) -> MagicMock:
|
|
72
|
+
sm = MagicMock()
|
|
73
|
+
if put_error is not None:
|
|
74
|
+
sm.put_secret_value.side_effect = put_error
|
|
75
|
+
else:
|
|
76
|
+
sm.put_secret_value.return_value = {"VersionId": "v1"}
|
|
77
|
+
return sm
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _patch_boto3(*, sts=None, iam=None, sm=None, parent=None):
|
|
81
|
+
sts_stub = sts or _build_sts_stub()
|
|
82
|
+
session_stub = _build_session_stub(sts_stub)
|
|
83
|
+
iam_stub = iam if iam is not None else _build_iam_stub()
|
|
84
|
+
sm_stub = sm if sm is not None else _build_sm_stub()
|
|
85
|
+
|
|
86
|
+
# Attach IAM + SM to a shared parent Mock so `parent.mock_calls` records
|
|
87
|
+
# cross-client call ORDER (create_access_key vs put_secret_value vs
|
|
88
|
+
# delete_access_key). This is the load-bearing invariant: a test that only
|
|
89
|
+
# checks "all three were called" would pass a buggy delete-before-put.
|
|
90
|
+
if parent is not None:
|
|
91
|
+
parent.attach_mock(iam_stub, "iam")
|
|
92
|
+
parent.attach_mock(sm_stub, "sm")
|
|
93
|
+
|
|
94
|
+
def _client(name, **_kwargs):
|
|
95
|
+
if name == "iam":
|
|
96
|
+
return iam_stub
|
|
97
|
+
if name == "secretsmanager":
|
|
98
|
+
return sm_stub
|
|
99
|
+
if name == "sts":
|
|
100
|
+
return sts_stub
|
|
101
|
+
raise AssertionError(f"unexpected boto3.client call: {name}")
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
patch("boto3.Session", return_value=session_stub),
|
|
105
|
+
patch("boto3.client", side_effect=_client),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _invoke(args):
|
|
110
|
+
from dh_cli.bedrock.commands import bedrock
|
|
111
|
+
|
|
112
|
+
runner = CliRunner()
|
|
113
|
+
return runner.invoke(bedrock, ["rotate", *args], standalone_mode=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _all(result) -> str:
|
|
117
|
+
out = result.output or ""
|
|
118
|
+
try:
|
|
119
|
+
err = result.stderr or ""
|
|
120
|
+
except ValueError:
|
|
121
|
+
err = ""
|
|
122
|
+
return out + err
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_rotate_requires_handle():
|
|
126
|
+
"""`--handle` is required — invoking with no handle exits non-zero."""
|
|
127
|
+
p_sess, p_cli = _patch_boto3()
|
|
128
|
+
with p_sess, p_cli:
|
|
129
|
+
result = _invoke([])
|
|
130
|
+
assert result.exit_code != 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_rotate_rejects_non_admin_caller():
|
|
134
|
+
"""A DeveloperAccess caller is rejected before any AWS write, with a
|
|
135
|
+
message naming dev-admin."""
|
|
136
|
+
iam = _build_iam_stub()
|
|
137
|
+
sm = _build_sm_stub()
|
|
138
|
+
p_sess, p_cli = _patch_boto3(sts=_build_sts_stub(_DEV_ARN), iam=iam, sm=sm)
|
|
139
|
+
with p_sess, p_cli:
|
|
140
|
+
result = _invoke(["--handle", "dma"])
|
|
141
|
+
assert result.exit_code != 0
|
|
142
|
+
assert "dev-admin" in _all(result).lower()
|
|
143
|
+
iam.create_access_key.assert_not_called()
|
|
144
|
+
sm.put_secret_value.assert_not_called()
|
|
145
|
+
iam.delete_access_key.assert_not_called()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_rotate_admin_caller_happy_path():
|
|
149
|
+
"""Admin ARN → CreateAccessKey, PutSecretValue (JSON with the 3 fields),
|
|
150
|
+
then DeleteAccessKey on the prior key, in that order; exit 0."""
|
|
151
|
+
parent = MagicMock()
|
|
152
|
+
iam = _build_iam_stub(existing_key_ids=[_OLD_AKID])
|
|
153
|
+
sm = _build_sm_stub()
|
|
154
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm, parent=parent)
|
|
155
|
+
with p_sess, p_cli:
|
|
156
|
+
result = _invoke(["--handle", "dma"])
|
|
157
|
+
assert result.exit_code == 0, _all(result)
|
|
158
|
+
|
|
159
|
+
iam.create_access_key.assert_called_once_with(UserName="dev-cursor-dma")
|
|
160
|
+
|
|
161
|
+
sm.put_secret_value.assert_called_once()
|
|
162
|
+
put_kwargs = sm.put_secret_value.call_args.kwargs
|
|
163
|
+
assert put_kwargs["SecretId"] == "cursor-bedrock/dma"
|
|
164
|
+
# A ClientRequestToken makes the put idempotent / collision-safe.
|
|
165
|
+
assert put_kwargs.get("ClientRequestToken")
|
|
166
|
+
payload = json.loads(put_kwargs["SecretString"])
|
|
167
|
+
assert payload["access_key_id"] == _NEW_AKID
|
|
168
|
+
assert payload["secret_access_key"] == _SENTINEL_SECRET
|
|
169
|
+
assert payload["region"] == "us-east-1"
|
|
170
|
+
|
|
171
|
+
iam.delete_access_key.assert_called_once_with(UserName="dev-cursor-dma", AccessKeyId=_OLD_AKID)
|
|
172
|
+
|
|
173
|
+
# Load-bearing ordering invariant: create BEFORE put BEFORE delete. Read
|
|
174
|
+
# the cross-client call order off the shared parent mock; a buggy
|
|
175
|
+
# delete-before-put (which would destroy the only live key before the new
|
|
176
|
+
# value is stored) must fail here, not pass on "all three called".
|
|
177
|
+
names = [name for name, _args, _kwargs in parent.mock_calls]
|
|
178
|
+
create_i = names.index("iam.create_access_key")
|
|
179
|
+
put_i = names.index("sm.put_secret_value")
|
|
180
|
+
delete_i = names.index("iam.delete_access_key")
|
|
181
|
+
assert create_i < put_i < delete_i
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_rotate_against_empty_container_seeds_first_version():
|
|
185
|
+
"""Container with no prior key → create + put, no delete (no prior key),
|
|
186
|
+
exit 0. No --init flag needed."""
|
|
187
|
+
iam = _build_iam_stub(existing_key_ids=[])
|
|
188
|
+
sm = _build_sm_stub()
|
|
189
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm)
|
|
190
|
+
with p_sess, p_cli:
|
|
191
|
+
result = _invoke(["--handle", "newbie"])
|
|
192
|
+
assert result.exit_code == 0, _all(result)
|
|
193
|
+
iam.create_access_key.assert_called_once_with(UserName="dev-cursor-newbie")
|
|
194
|
+
sm.put_secret_value.assert_called_once()
|
|
195
|
+
iam.delete_access_key.assert_not_called()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_rotate_put_failure_rolls_back_new_key():
|
|
199
|
+
"""PutSecretValue raises → DeleteAccessKey called on the just-created key
|
|
200
|
+
id, exit non-zero, and NO prior key is deleted."""
|
|
201
|
+
iam = _build_iam_stub(existing_key_ids=[_OLD_AKID])
|
|
202
|
+
sm = _build_sm_stub(put_error=RuntimeError("PutSecretValue boom"))
|
|
203
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm)
|
|
204
|
+
with p_sess, p_cli:
|
|
205
|
+
result = _invoke(["--handle", "dma"])
|
|
206
|
+
assert result.exit_code != 0
|
|
207
|
+
iam.create_access_key.assert_called_once()
|
|
208
|
+
# The rollback deletes the NEW key, exactly once, and never the old one.
|
|
209
|
+
iam.delete_access_key.assert_called_once_with(UserName="dev-cursor-dma", AccessKeyId=_NEW_AKID)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_rotate_create_access_key_not_retried_on_put_failure():
|
|
213
|
+
"""CreateAccessKey returns the secret half exactly once and is
|
|
214
|
+
unrecoverable, so a Put failure must NOT trigger a retry of
|
|
215
|
+
CreateAccessKey (which would orphan a second key and burn the 2-key
|
|
216
|
+
limit)."""
|
|
217
|
+
iam = _build_iam_stub(existing_key_ids=[_OLD_AKID])
|
|
218
|
+
sm = _build_sm_stub(put_error=RuntimeError("PutSecretValue boom"))
|
|
219
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm)
|
|
220
|
+
with p_sess, p_cli:
|
|
221
|
+
result = _invoke(["--handle", "dma"])
|
|
222
|
+
assert result.exit_code != 0
|
|
223
|
+
assert iam.create_access_key.call_count == 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_rotate_never_prints_secret_on_any_path():
|
|
227
|
+
"""The sentinel secret must not appear in stdout/stderr on the happy
|
|
228
|
+
path OR a Put-failure path."""
|
|
229
|
+
# Happy path.
|
|
230
|
+
iam = _build_iam_stub(existing_key_ids=[_OLD_AKID])
|
|
231
|
+
sm = _build_sm_stub()
|
|
232
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm)
|
|
233
|
+
with p_sess, p_cli:
|
|
234
|
+
result = _invoke(["--handle", "dma"])
|
|
235
|
+
assert result.exit_code == 0, _all(result)
|
|
236
|
+
assert _SENTINEL_SECRET not in _all(result)
|
|
237
|
+
|
|
238
|
+
# Put-failure path — the error message echoes the secret.
|
|
239
|
+
iam = _build_iam_stub(existing_key_ids=[_OLD_AKID])
|
|
240
|
+
sm = _build_sm_stub(put_error=RuntimeError(f"boom involving {_SENTINEL_SECRET}"))
|
|
241
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm)
|
|
242
|
+
with p_sess, p_cli:
|
|
243
|
+
result = _invoke(["--handle", "dma"])
|
|
244
|
+
assert result.exit_code != 0
|
|
245
|
+
assert _SENTINEL_SECRET not in _all(result)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_rotate_preflight_missing_user():
|
|
249
|
+
"""list_access_keys raising NoSuchEntity → clear error BEFORE
|
|
250
|
+
CreateAccessKey."""
|
|
251
|
+
err = RuntimeError("NoSuchEntity: user dev-cursor-ghost not found")
|
|
252
|
+
iam = _build_iam_stub(list_keys_error=err)
|
|
253
|
+
sm = _build_sm_stub()
|
|
254
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm)
|
|
255
|
+
with p_sess, p_cli:
|
|
256
|
+
result = _invoke(["--handle", "ghost"])
|
|
257
|
+
assert result.exit_code != 0
|
|
258
|
+
iam.create_access_key.assert_not_called()
|
|
259
|
+
sm.put_secret_value.assert_not_called()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_rotate_preflight_two_active_keys():
|
|
263
|
+
"""User already has 2 active keys → clear 'delete one first' error before
|
|
264
|
+
CreateAccessKey (avoids LimitExceeded mid-transaction)."""
|
|
265
|
+
iam = _build_iam_stub(existing_key_ids=["AKIAONE0000000000ONE", "AKIATWO0000000000TWO"])
|
|
266
|
+
sm = _build_sm_stub()
|
|
267
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm)
|
|
268
|
+
with p_sess, p_cli:
|
|
269
|
+
result = _invoke(["--handle", "dma"])
|
|
270
|
+
assert result.exit_code != 0
|
|
271
|
+
iam.create_access_key.assert_not_called()
|
|
272
|
+
sm.put_secret_value.assert_not_called()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_rotate_prints_new_access_key_id_on_success():
|
|
276
|
+
"""The new access_key_id (AKIA…) appears in stdout; the secret does not."""
|
|
277
|
+
iam = _build_iam_stub(existing_key_ids=[_OLD_AKID])
|
|
278
|
+
sm = _build_sm_stub()
|
|
279
|
+
p_sess, p_cli = _patch_boto3(iam=iam, sm=sm)
|
|
280
|
+
with p_sess, p_cli:
|
|
281
|
+
result = _invoke(["--handle", "dma"])
|
|
282
|
+
assert result.exit_code == 0, _all(result)
|
|
283
|
+
assert _NEW_AKID in result.output
|
|
284
|
+
assert _SENTINEL_SECRET not in _all(result)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|