From e444e3ee37ae7369eac2ed60314cb53c8db0bd7a Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Fri, 31 Jul 2026 22:06:27 -0500 Subject: [PATCH 1/8] Expand AWS Login Boundaries - **Authentication Boundaries** - Add browser and MFA login workflows that derive role-scoped credentials, reducing permissions exposed to local agents while preserving explicit ECR access and transactional recovery. - **Reusable Configuration** - Introduce accounts, targets, boundaries, policy storage, caching, inspection, repair, and portable configuration so users can manage safe multi-account login contracts consistently. - **Reliability Contract** - Document setup and trust requirements, harden validation and rollback behavior, and enforce 95% coverage so future changes preserve the security model. --- CHEATSHEET.md | 168 ++ README.md | 452 ++++-- hacksaws/_cli.py | 898 ++++++++++- hacksaws/_configs.py | 31 +- hacksaws/_duration.py | 89 ++ hacksaws/_ecr.py | 51 +- hacksaws/_policies.py | 486 ++++++ hacksaws/_sessions.py | 1585 +++++++++++++++++++ hacksaws/_state.py | 501 ++++++ hacksaws/_test_runner.py | 25 + hacksaws/tests/test_cli_state_coverage.py | 490 ++++++ hacksaws/tests/test_coverage_closure.py | 338 ++++ hacksaws/tests/test_hacksaws.py | 23 +- hacksaws/tests/test_policy_leaf_coverage.py | 514 ++++++ hacksaws/tests/test_sessions_coverage.py | 1100 +++++++++++++ hacksaws/tests/test_v04.py | 981 ++++++++++++ pyproject.toml | 31 +- uv.lock | 44 +- 18 files changed, 7624 insertions(+), 183 deletions(-) create mode 100644 CHEATSHEET.md create mode 100644 hacksaws/_duration.py create mode 100644 hacksaws/_policies.py create mode 100644 hacksaws/_sessions.py create mode 100644 hacksaws/_state.py create mode 100644 hacksaws/_test_runner.py create mode 100644 hacksaws/tests/test_cli_state_coverage.py create mode 100644 hacksaws/tests/test_coverage_closure.py create mode 100644 hacksaws/tests/test_policy_leaf_coverage.py create mode 100644 hacksaws/tests/test_sessions_coverage.py create mode 100644 hacksaws/tests/test_v04.py diff --git a/CHEATSHEET.md b/CHEATSHEET.md new file mode 100644 index 0000000..d117b17 --- /dev/null +++ b/CHEATSHEET.md @@ -0,0 +1,168 @@ +# Hacksaws cheatsheet + +The canonical executable is `hacksaws`. Names are case-insensitive, portable +1–64-character identifiers (`A-Z`, `a-z`, `0-9`, `.`, `_`, `-`; leading +letter/digit). + +## Authentication + +```shell +# MFA: login/in, logout/out +hacksaws mfa login PROFILE MFA_CODE [-l SECONDS|--lifespan SECONDS] +hacksaws mfa in PROFILE MFA_CODE +hacksaws mfa in +TARGET MFA_CODE +hacksaws mfa in MFA_CODE --target TARGET +hacksaws mfa logout [PROFILE] +hacksaws mfa out [PROFILE] + +# Browser AWS CLI login (AWS CLI v2.32+): login/in, logout/out +hacksaws pk login [PROFILE] +hacksaws pk in [PROFILE] +hacksaws pk logout [PROFILE] +hacksaws web login [PROFILE] +hacksaws web out [PROFILE] + +# Top-level logout convenience +hacksaws logout [PROFILE] +``` + +Shared login flags: + +```text +--target +NAME saved secure preset (NAME is accepted) +-d, --dir, --directory PATH source AWS directory (default ~/.aws) +-n, --name, --account-name N source directory shortcut: ~/.aws-N +--to LOCATION:PROFILE destination logical AWS location/profile +--to-directory PATH --to-profile PROFILE +--boundary NAME, --as NAME saved role boundary +--role ROLE_OR_ARN --account ACCOUNT +--policy VALUE role-only policy (ARN, path, stored, remote name) +--external-id VALUE AssumeRole only +--session-name NAME AssumeRole only +--region REGION +--duration VALUE, --ttl VALUE role-only duration (e.g. 45m) +--htl N | --mtl N | --stl N role-only hours/minutes/seconds aliases +--ecr [--podman] [--ecr-region REGION]... +--remote browser login only +``` + +Examples: + +```shell +hacksaws mfa login human 123456 --as prod-readonly --duration 45m +hacksaws pk login +prod-agent +hacksaws web login default --role AgentReadOnly --account prod --to agent:default +hacksaws logout agent --ecr --podman +``` + +`default` and `.` are `~/.aws`; logical `team` is `~/.aws-team`. `--to` cannot +be combined with `--to-directory`/`--to-profile`; `--to-directory` requires +`--to-profile`. Role-only options fail unless the invocation resolves a concrete +role; an unbounded target is not enough. Saved targets reject source, +destination, role, policy, account, external-ID, and session-name overrides. +Only duration may override a saved boundary; an unbounded target may add one +named `--boundary`/`--as`. + +## Named resources + +All of account, boundary, and target support: + +```shell +hacksaws KIND add NAME ... [--description TEXT] +hacksaws KIND update NAME [--description TEXT|--clear-description] ... +hacksaws KIND get NAME [--json] +hacksaws KIND list [--json] +hacksaws KIND rename NAME NEW_NAME +hacksaws KIND remove NAME [--cascade] [--yes] +``` + +```shell +# Accounts +hacksaws account add NAME ACCOUNT_ID --partition aws|aws-us-gov|aws-cn \ + [--profile PROFILE|--target +TARGET] [--no-verify] [--description TEXT] +hacksaws account update NAME [--profile PROFILE|--target +TARGET] [--no-verify] + +# Boundaries +hacksaws boundary add NAME ROLE --account ACCOUNT [--policy VALUE] \ + [--external-id VALUE] [--duration 45m] [--no-verify] [--description TEXT] +hacksaws boundary update NAME [--policy VALUE|--clear-policy] \ + [--external-id VALUE|--clear-external-id] \ + [--duration VALUE|--clear-duration] + +# Targets +hacksaws target add NAME --source-account ACCOUNT [--source-profile PROFILE] \ + [--source-location LOCATION|--source-directory PATH] \ + [--to LOCATION:PROFILE|--to-directory PATH --to-profile PROFILE] \ + [--boundary BOUNDARY] [--description TEXT] +hacksaws target update NAME [--boundary NAME|--clear-boundary] +``` + +## Policies + +```shell +hacksaws policy add NAME FILE [--format json|yaml|yml|toml] [--description TEXT] +hacksaws policy update NAME FILE [--format json|yaml|yml|toml] [--description TEXT] +hacksaws policy get NAME [--json] +hacksaws policy list [--json] +hacksaws policy rename NAME NEW_NAME +hacksaws policy remove NAME [--json] + +# Read policy input from stdin: format is required. +some-command | hacksaws policy add NAME - --format yaml +``` + +`--policy` resolves: policy ARN → path → stored name → remote IAM name. Customer +ARNs must be in the target role account. AWS-managed documents are inline and +can exceed STS’s 2,048-character limit. + +## Cache, inspection, and portability + +```shell +hacksaws cache get [max-age] [--json] +hacksaws cache set max-age DURATION +hacksaws cache clear [--yes] + +hacksaws status [--json] +hacksaws config show [--account ACCOUNT] [--json] +hacksaws config explain +TARGET [--json] +hacksaws config check [--profile PROFILE|--target +TARGET] [--remote] [--probe] \ + [--account ACCOUNT] [--no-verify] [--json] +hacksaws config fix [--account ACCOUNT] [--yes] +hacksaws config export [ARCHIVE.zip] +hacksaws config import ARCHIVE.zip [--replace] [--yes] +``` + +Durations: `45m`, `1.5hours`, `90sec`; `--htl 1.5`, `--mtl 90`, and `--stl 5400` +are equivalent duration forms. Boundary sessions require at least 900 seconds; +role chaining caps them at 3,600 seconds. `cache set max-age 0s` disables cache +reads. + +`config check --probe` calls AssumeRole with a deny-all policy and discards the +credentials. `config fix` backs up first, returns nonzero for unresolved issues, +and offers repair/leave/remove interactively without weakening boundaries. +Import validates an exact checksummed archive and previews conflicts; +noninteractive replacement requires `--replace --yes`. + +## Common compact workflows + +```shell +# Create a staged production preset. +hacksaws account add prod 123456789012 +hacksaws policy add readonly policy.yaml +hacksaws boundary add prod-ro AgentReadOnly --account prod --policy readonly --duration 45m +hacksaws target add prod-agent --source-account prod --source-profile human \ + --to agent:default --boundary prod-ro +hacksaws config check --target +prod-agent --remote +hacksaws pk login --target +prod-agent +hacksaws status --json +hacksaws pk logout agent + +# Archive configuration before moving computers. +hacksaws config export hacksaws-config.zip +hacksaws config import hacksaws-config.zip +``` + +## Development tests + +`uv run test` and `uv run task test` run the same full pytest suite. Both fail +unless aggregate line coverage is at least 95%. diff --git a/README.md b/README.md index d572d37..a46b521 100644 --- a/README.md +++ b/README.md @@ -2,165 +2,437 @@ [![Checks](https://github.com/rocketboosters/hacksaws/actions/workflows/checks.yaml/badge.svg)](https://github.com/rocketboosters/hacksaws/actions/workflows/checks.yaml) [![PyPI version](https://img.shields.io/pypi/v/hacksaws.svg)](https://pypi.org/project/hacksaws/) -[![Python versions](https://img.shields.io/pypi/pyversions/hacksaws.svg)](https://pypi.org/project/hacksaws/) [![License](https://img.shields.io/pypi/l/hacksaws.svg)](https://github.com/rocketboosters/hacksaws/blob/main/LICENSE) -Hacksaws is a command-line utility for AWS profiles that use dynamic -authentication methods such as multi-factor authentication (MFA). It replaces a -profile's long-term access key and secret with temporary session credentials, -while storing the long-term credentials in a local backup until the next login -or logout. +Hacksaws is an AWS credential switcher and **agentic blast-radius manager**. It +can obtain credentials with MFA or AWS CLI browser sign-in, then optionally +assumes one deliberately constrained role and installs those credentials only at +an explicit destination profile. The intended contract is simple: automation +receives the smallest practical permission set, for a bounded time, in a +location you chose. A saved target is a secure preset, not a loose collection of +defaults; it cannot be overridden at login time. -Only MFA-based dynamic login is currently supported. Hacksaws supports Python -3.13 and 3.14. +The only supported executable is `hacksaws`. See [CHEATSHEET.md](CHEATSHEET.md) +for the compact command reference. -## Installation - -Install Hacksaws as an isolated command-line tool with -[uv](https://docs.astral.sh/uv/): +## Install ```shell uv tool install hacksaws +# or +python -m pip install hacksaws ``` -As a fallback, install it into the active Python environment with pip: +Hacksaws requires Python 3.13 or 3.14. Browser sign-in requires AWS CLI **v2.32 +or newer** on `PATH`. + +## Security model and local secrets + +Hacksaws has three useful credential tiers: + +1. **Native/source credentials** — the profile’s original static, SSO, + credential-process, or AWS CLI browser-login credentials. These are broad + enough to start the flow and may have an unbounded provider-controlled + lifetime. +2. **MFA intermediate credentials** — `hacksaws mfa login` exchanges static + source keys for an STS session (`--lifespan`, default 12 hours). When a role + boundary is requested, these are only an intermediate credential tier. +3. **Boundary credentials** — an STS `AssumeRole` session for the selected role, + optionally reduced further by a session policy and bounded by the selected + duration. This is what is written to the destination profile for the agent or + tool. + +Browser login has two related lifecycles. Without a role, `pk`/`web` leaves AWS +CLI’s native browser credentials in their normal, provider-controlled lifecycle; +Hacksaws cannot truthfully shorten or attest their lifetime. With a +role/boundary, it first performs native browser login and then writes a +**staged, bounded AssumeRole session** to the destination. `pk` and `web` wrap +`aws login` rather than implement a browser or passkey protocol; they cannot +prove that a passkey was used. + +The source credential entry and its `PROFILE.store.credentials` backup can be +read by the same OS user while a legacy MFA login is active. Treat the source +AWS directory and `~/.hacksaws` as sensitive user data. Hacksaws uses +user-scoped files where the platform supports it, but it is not a vault and +cannot prevent another process running as the same OS user from reading +credentials. Configuration, exports, and status deliberately never print access +keys, secret keys, session tokens, or ECR passwords. An external ID is not an +AWS credential, but it is configuration data and is visible to that same OS user +and in configuration exports. + +## Quick start: a constrained agent identity + +Create a target-account identity and a narrow stored session policy: ```shell -python -m pip install hacksaws +hacksaws account add prod 123456789012 --description "production" +hacksaws policy add deploy-readonly policies/deploy-readonly.yaml \ + --description "agent's production scope" +hacksaws boundary add prod-readonly AgentReadOnly \ + --account prod --policy deploy-readonly --duration 45m \ + --description "production role with a 45-minute ceiling" + +hacksaws target add prod-agent \ + --source-account prod --source-profile human \ + --source-location default --to agent:default --boundary prod-readonly + +hacksaws pk login --target +prod-agent ``` -## Usage +The `+` makes a saved target unmistakable. `--target prod-agent` is accepted as +shorthand and normalized to `+prod-agent`; `+prod-agent` is preferred in scripts +and reviews. Login to a target may not override its source, destination, role, +or policy. -Log in with MFA by supplying an AWS profile and the current MFA code: +Check the planned resolution before logging in: ```shell -hacksaws mfa login +hacksaws config explain +prod-agent +hacksaws config check --target +prod-agent --remote +hacksaws status ``` -The `--lifespan` option changes how long the temporary session remains valid. -The default is 12 hours (`--lifespan=43200` seconds). AWS allows at most 24 -hours, and the profile's role or account policy may set a lower maximum. +## Authentication commands -Hacksaws can also log a container engine into Amazon ECR in the profile's -default region. Docker is used by default: +### MFA + +The legacy direct flow replaces a source profile’s static credentials with an +MFA STS session and preserves the original entry in `PROFILE.store.credentials` +until logout: ```shell -hacksaws mfa login --ecr +hacksaws mfa login engineering 123456 --lifespan 43200 +hacksaws mfa logout engineering ``` -Select Podman by adding `--podman`. The option chooses the container engine but -does not enable ECR by itself, so use it together with `--ecr`: +`mfa in` and `mfa out` are aliases. `--lifespan` is a legacy MFA-session +duration in seconds; its default is 43,200 (12 hours). AWS and account policy +can impose a lower maximum. + +Use MFA as a source for a staged role session by selecting a boundary, direct +role, or target: ```shell -hacksaws mfa login --ecr --podman +hacksaws mfa login human 123456 --as prod-readonly +hacksaws mfa login human 123456 --role AgentReadOnly --account prod \ + --policy policies/deploy-readonly.yaml --duration 45m +hacksaws mfa login +prod-agent 123456 +# Universal named alternative: +hacksaws mfa login 123456 --target prod-agent ``` -Use `--ecr-region` more than once to add regions. The profile's primary region -is processed first, followed by each additional region once in the order -provided: +The target supplies the saved source and destination plan. Supplying a role-only +operand for an unbounded target is rejected before authentication; those flags +can never silently produce a broad native login. -```shell -hacksaws mfa login \ - --ecr \ - --ecr-region=eu-central-1 \ - --ecr-region=us-west-2 \ - --ecr-region=ca-central-1 -``` +### Browser (`pk` and `web`) -Log out of the AWS profile and restore its long-term credentials: +`pk` and `web` are equivalent browser-login command families, each wrapping AWS +CLI `aws login`. Use the one your team has standardized on: ```shell -hacksaws mfa logout +hacksaws pk login human +hacksaws web in human --as prod-readonly --duration 45m +hacksaws pk login --target +prod-agent +hacksaws web logout human ``` -Add `--ecr` to the logout command to log Docker out of the configured ECR -registries as well: +`login` has the alias `in`; `logout` has the alias `out`. `--remote` asks the +browser flow to perform remote validation/probing when supported. Browser +commands default the source profile to `default`. -```shell -hacksaws mfa logout --ecr -``` +### Destinations and locations -Use the same `--podman` selection when logging Podman out: +The source directory is `--directory`/`--dir` (default `~/.aws`). +`--name`/`--account-name NAME` is a source-directory shortcut for `~/.aws-NAME`. + +For a staged role login, choose exactly one destination form: ```shell -hacksaws mfa logout --ecr --podman +# Logical location and profile: ~/.aws-agent, profile agent +hacksaws pk login human --as prod-readonly --to agent:agent + +# Explicit directory requires its destination profile +hacksaws pk login human --as prod-readonly \ + --to-directory /secure/aws-agent --to-profile agent ``` -Use `--directory` to select a different AWS configuration directory: +`--to LOCATION:PROFILE` is mutually exclusive with `--to-directory` and +`--to-profile`; `--to-directory` always requires `--to-profile`. Logical +`default` and `.` both mean `~/.aws`; every other logical location `NAME` means +`~/.aws-NAME`. Location names use portable resource-name characters only (1–64 +letters/digits/`.`, `_`, `-`, beginning with a letter or digit), so they cannot +contain path separators, drive prefixes, or traversal. Use `--directory` or +`--to-directory` for arbitrary filesystem paths. + +Use `hacksaws logout PROFILE` as a top-level logout convenience, or the matching +authentication-family logout. Add `--ecr` and optionally `--podman` when ECR +container-engine logout is wanted. + +## Roles, trust, and policies + +A **boundary** names a target account, role ARN, optional external ID, optional +duration, and optional session policy. A boundary may be same-account or +cross-account. It does not create AWS IAM resources; configure both source +permission and target trust first. + +Source identity policy: permit the human/source role to assume the target role. +Replace the ARN with your source principal and target role ARN. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::123456789012:role/AgentReadOnly" + } + ] +} +``` -```shell -hacksaws mfa login --directory=/path/to/aws +Target role trust policy for a same-account source role: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::123456789012:role/HumanOperator" }, + "Action": "sts:AssumeRole" + } + ] +} ``` -For directories in the `~/.aws-` form, `--name` is shorthand for choosing -the named account directory: +For a cross-account role, the target account’s trust policy must name the source +account principal (or a narrowly selected source role). Add an external-ID +condition when your trust model requires it: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::111122223333:role/HumanOperator" }, + "Action": "sts:AssumeRole", + "Condition": { "StringEquals": { "sts:ExternalId": "vendor-opaque-id" } } + } + ] +} +``` + +Then save the same value with `--external-id` on `boundary add`, or supply it +for an ad hoc role login. It is passed only to `AssumeRole`. + +The target role’s identity policies still determine the maximum permission. A +session policy can only further reduce it. Therefore `--policy` requires a +role/boundary/target; it is never interpreted as a general local permission +system. + +### Policy resolution and the 2048-character limit + +For `--policy VALUE`, resolution order is: an explicit policy ARN, a file path +(`.json`, `.yaml`/`.yml`, or `.toml`, including paths with a slash), a stored +policy name, then a remote IAM policy name. JSON, YAML, and TOML documents must +contain IAM `Version` and `Statement`; inline documents are canonicalized to +compact JSON. + +Stored policies are held under `~/.hacksaws/stored_session_policies`. YAML is +preserved verbatim on store; JSON and TOML are converted to YAML. Local and +stored documents, AWS-managed policies, and remotely resolved policies are +recorded in the local inspection cache. `cache max-age 0s` disables cache reads; +`cache clear` removes cached records. + +Customer-managed policy ARNs must belong to the **target role account** and are +passed as managed session-policy ARNs. AWS-managed policy ARNs are fetched and +used as inline policy documents, so they are subject to STS’s 2,048-character +inline-session-policy limit. For example, +`arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess` may fail if its expanded +compact policy is over 2,048 characters; the error tells you to use a +same-account customer-managed policy ARN instead. A remote bare policy name is +rejected if it is ambiguous between AWS-managed and customer-managed policies. + +## Named configuration + +All named resources are case-insensitively unique and accept safe names. +`--description` is supported on account, boundary, target, and stored policy +records. ```shell -hacksaws mfa login --name=sandbox +# Account IDs are paired with immutable AWS partitions. +hacksaws account add prod 123456789012 --partition aws +hacksaws account add gov 210987654321 --partition aws-us-gov +hacksaws account add china 109876543210 --partition aws-cn + +# A short role is expanded using the account's partition and ID. +hacksaws boundary add prod-readonly AgentReadOnly --account prod \ + --policy deploy-readonly --external-id vendor-opaque-id --duration 45m + +hacksaws target add prod-agent --source-account prod --source-profile human \ + --source-location . --to agent:default --boundary prod-readonly ``` -The action aliases `in` and `out`, the directory alias `--dir`, and the account -name alias `--account-name` remain available. +An account’s partition is part of its identity because an account number alone +cannot construct correct ARN strings in commercial AWS, GovCloud, and China. +`account add` infers the partition from verified caller identity. An explicit +unverified save requires both `--no-verify` and `--partition`. Do not mix an ARN +from one partition with an account declared in another. -## Requiring MFA +Use `add`, `update`, `get`, `list`, `rename`, and `remove` for accounts, +boundaries, and targets. `remove` refuses resources with live configuration or +session references. `--cascade` previews whole-resource dependent deletion and +requires interactive confirmation; noninteractive use requires `--yes`. It still +refuses active session references. `--json` is available for `get` and `list`. -The repository includes -[an example IAM policy](https://github.com/rocketboosters/hacksaws/blob/main/example_mfa_iam_policy.json) -that lets users manage their own credentials while requiring MFA for other AWS -operations. +Boundaries can change `--policy`, `--external-id`, and `--duration`, or clear +them with `--clear-policy`, `--clear-external-id`, and `--clear-duration`. +Targets can change or `--clear-boundary`. Accounts, boundaries, and targets can +change a description with `update ... --description TEXT` or clear it with +`--clear-description`; stored-policy descriptions are supplied on `policy add` +or `policy update`. -AWS provides further guidance: +## Durations -- [Allow MFA-authenticated IAM users to manage their own credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_my-sec-creds-self-manage-mfa-only.html) -- [Allow IAM users to self-manage an MFA device](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_iam_mfa-selfmanage.html) -- [Configure MFA-protected API access](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_configure-api-require.html) -- [Set an IAM account password policy](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html) +AssumeRole duration accepts one of these mutually exclusive forms: -## Development +```shell +--duration 45m # --ttl is an alias +--htl 1.5 # hours-to-live +--mtl 90 # minutes-to-live +--stl 5400 # seconds-to-live +``` + +Decimal values are rounded conventionally to whole seconds and must be positive. +Boundary sessions must be at least 900 seconds; chained role sessions are capped +at 3,600 seconds. A boundary duration is its normal default; an ad hoc duration +is used for that login. AWS role configuration can still enforce a lower +maximum. `--ttl`, `--duration`, `--htl`, `--mtl`, and `--stl` are role-only +options and fail without a role/boundary/target. -Install the locked Python and Node.js development dependencies: +## Inspect, repair, and move configuration ```shell -uv sync --locked --all-groups -npm ci +hacksaws status +hacksaws status --json +hacksaws config show +hacksaws config show --account prod --json +hacksaws config explain +prod-agent +hacksaws config check --target +prod-agent --remote +hacksaws config fix --account prod + +hacksaws config export hacksaws-config.zip +hacksaws config import hacksaws-config.zip +hacksaws config import hacksaws-config.zip --replace --yes ``` -Format the repository: +`status` reports active destinations, auth method, source and target identities, +boundary, role, policy provenance, expiration/remaining time, and recorded ECR +state—never credentials. `config show` displays declared configuration; +`config explain` shows a target’s resolved plan; `check` validates locally and +can verify configured remote accounts and roles with `--remote`; `fix` writes a +timestamped backup then normalizes the configuration without changing security +references. `--probe` performs an explicit 900-second AssumeRole test with a +deny-all session policy, discards the returned credentials, and writes no +session files. `fix` reports unresolved issues with a nonzero status; in an +interactive terminal it offers repair/leave/remove per issue and never detaches +a boundary or weakens a security reference automatically. + +Export creates a portable archive containing configuration and stored-policy +files, with checksums; it excludes credentials, active-session metadata, and the +cache. Import requires an exact manifest/member set, verifies every checksum, +validates all content in memory, previews conflicts, and then atomically merges. +Interactive replacement asks for confirmation; noninteractive replacement uses +`--replace --yes`. Referenced external policy files are bundled and promoted to +deterministically named stored YAML policies during import. + +## ECR + +Add `--ecr` to a login to authenticate Docker, or `--podman` to select Podman. +Repeat `--ecr-region REGION` for more registries; the profile’s primary region +is first. ```shell -uv run task format +hacksaws pk login human --as prod-readonly --ecr --ecr-region us-west-2 +hacksaws mfa login human 123456 --ecr --podman ``` -Run the same non-mutating quality and test checks used by GitHub Actions: +Important: ECR deliberately gets its authorization token with the **broad +intermediate/source session**, before the boundary is installed. This makes +container authentication useful even when the boundary excludes ECR, but it also +means the resulting container-engine registry credential is outside that +boundary’s blast-radius guarantee. ECR and AWS destination updates are treated +transactionally where possible; a failed container login or credential write can +trigger rollback/recovery. Verify state with `hacksaws status` and run explicit +ECR logout when needed. + +Plain `hacksaws logout` restores AWS state but deliberately leaves recorded ECR +authorization installed and retains its cleanup record. Run +`hacksaws logout --ecr` (with the original `--podman` choice when applicable) to +remove only registries recorded by Hacksaws. Hacksaws does not pre-logout before +login because Docker exposes no safe portable way to distinguish and restore a +preexisting authorization. + +## Policy cache ```shell -uv run task check +hacksaws cache get +hacksaws cache get --json +hacksaws cache set max-age 30m +hacksaws cache set max-age 0s +hacksaws cache clear --yes ``` -Run an individual check when iterating: +`max-age` is the local policy-inspection-cache age, not a credential duration. + +## Setup checklist + +1. Install AWS CLI v2.32+ if using browser login and configure the source + profile normally. +2. For MFA, set `mfa_serial` in the matching AWS config profile and retain an + eligible source credential in its credentials file. +3. Create the source `sts:AssumeRole` permission, target trust relationship, and + target role policies in IAM. +4. Add accounts with the correct partitions; add stored policies, boundaries, + and targets. +5. Run `hacksaws config check --target +NAME --remote` before first use. +6. Start with a short boundary duration and a read-only session policy; inspect + with `hacksaws status`. + +## Manual live-AWS smoke matrix + +The normal automated suite uses mocked boto3/AWS CLI/container commands and +makes no live AWS calls. Keep live checks opt-in and run them only in disposable +or carefully scoped test accounts: + +| Scenario | Manual assertion | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| MFA direct profile | Login writes an MFA session, logout restores the original source entry. | +| Browser native | `pk login`/`web login` invokes AWS CLI v2.32+ and preserves the provider’s normal lifecycle. | +| Same-account boundary | Source can assume the trusted role; session policy reduces access; expiry is reported. | +| Cross-account boundary | Source permission, target trust, and external ID are all required. | +| Policy forms | File, stored policy, customer ARN, AWS-managed ARN, cache hit/miss, and 2048-character failure behave as documented. | +| Destination and rollback | `default`/`.` and named locations resolve correctly; a forced write/ECR failure recovers cleanly. | +| ECR | Docker and Podman receive a registry login from the intermediate credentials and explicit logout removes it. | + +## Development ```shell -uv run task lint +uv sync --locked --all-groups +npm ci +uv run task format +uv run test uv run task test +uv run task check uv run task build ``` -## Release process - -Publishing is handled by the -[`publish.yaml`](https://github.com/rocketboosters/hacksaws/blob/main/.github/workflows/publish.yaml) -GitHub Actions workflow and PyPI trusted publishing. Each successful release -publishes the wheel and source distribution to PyPI, then creates a GitHub -Release for the same tag with those exact artifacts attached. +`uv run test` and `uv run task test` share the same full pytest command and +enforce at least 95% aggregate line coverage. -1. Update `project.version` in `pyproject.toml`. -2. Run `uv lock`, `npm ci`, and `uv run task check`. -3. Build locally with `uv build` and inspect the wheel and source distribution. -4. Merge the version change to `main`. -5. Create and push a `v` tag, such as `v0.3.2`. +## License -The workflow verifies that the tag exactly matches the project version before it -builds once, publishes the resulting artifacts to PyPI, and creates the GitHub -Release only after PyPI succeeds. The repository's `pypi` environment must be -configured as a trusted publisher for owner `rocketboosters`, repository -`hacksaws`, workflow `publish.yaml`, and environment `pypi`. +MIT. See [LICENSE](LICENSE). diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index 6571a6a..665510a 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -1,135 +1,905 @@ -"""Command-line parsing and orchestration.""" +"""Hacksaws command-line parsing and orchestration.""" from __future__ import annotations import argparse +import json import os +import re +import shutil +import sys +from pathlib import Path from typing import TYPE_CHECKING +from typing import Any from typing import cast +import boto3 +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError + from hacksaws import _aws from hacksaws import _configs +from hacksaws import _duration from hacksaws import _ecr +from hacksaws import _policies +from hacksaws import _sessions +from hacksaws import _state if TYPE_CHECKING: from collections.abc import Sequence +def _duration_arguments(parser: argparse.ArgumentParser) -> None: + group = parser.add_mutually_exclusive_group() + group.add_argument("--duration", "--ttl") + group.add_argument("--htl") + group.add_argument("--mtl") + group.add_argument("--stl") + + +def _ecr_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--ecr", action="store_true") + parser.add_argument("--podman", action="store_true") + parser.add_argument("--ecr-region", action="append") + + +def _login_arguments(parser: argparse.ArgumentParser, *, browser: bool = False) -> None: + parser.add_argument("profile", nargs="?") + if not browser: + parser.add_argument("mfa_code", nargs="?") + parser.add_argument("-l", "--lifespan", type=int, default=43200) + parser.add_argument("--target") + parser.add_argument( + "-d", "--dir", "--directory", dest="directory", default="~/.aws" + ) + parser.add_argument("-n", "--name", "--account-name", dest="aws_account_name") + parser.add_argument("--to") + parser.add_argument("--to-directory") + parser.add_argument("--to-profile") + parser.add_argument("--boundary", "--as", dest="boundary") + parser.add_argument("--role") + parser.add_argument("--policy") + parser.add_argument("--external-id") + parser.add_argument("--account") + parser.add_argument("--session-name") + parser.add_argument("--region") + _duration_arguments(parser) + _ecr_arguments(parser) + if browser: + parser.add_argument("--remote", action="store_true") + + +def _logout_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("profile", nargs="?", default="default") + parser.add_argument("--target") + parser.add_argument( + "-d", "--dir", "--directory", dest="directory", default="~/.aws" + ) + parser.add_argument("-n", "--name", "--account-name", dest="aws_account_name") + _ecr_arguments(parser) + + +def _credential_selector(parser: argparse.ArgumentParser) -> None: + selector = parser.add_mutually_exclusive_group() + selector.add_argument("--profile", "--name", dest="profile", default="default") + selector.add_argument("--target") + parser.add_argument("--no-verify", action="store_true") + + +def _resource_parser( + parent: argparse._SubParsersAction[argparse.ArgumentParser], kind: str +) -> None: + parser = parent.add_parser(kind) + actions = parser.add_subparsers(dest="resource_action") + add = actions.add_parser("add") + add.add_argument("resource_name") + add.add_argument("--description") + update = actions.add_parser("update") + update.add_argument("resource_name") + update.add_argument("--description") + update.add_argument("--clear-description", action="store_true") + for action in ("get", "remove"): + item = actions.add_parser(action) + item.add_argument("resource_name") + item.add_argument("--json", action="store_true") + if action == "remove": + item.add_argument("--cascade", action="store_true") + item.add_argument("--yes", action="store_true") + listing = actions.add_parser("list") + listing.add_argument("--json", action="store_true") + rename = actions.add_parser("rename") + rename.add_argument("resource_name") + rename.add_argument("new_name") + + if kind == "account": + add.add_argument("account_id") + add.add_argument("--partition", choices=sorted(_state.PARTITIONS)) + _credential_selector(add) + _credential_selector(update) + elif kind == "boundary": + add.add_argument("role") + add.add_argument("--account", required=True) + add.add_argument("--policy") + add.add_argument("--external-id") + add.add_argument("--duration") + _credential_selector(add) + update.add_argument("--role") + update.add_argument("--account") + update.add_argument("--policy") + update.add_argument("--external-id") + update.add_argument("--duration") + update.add_argument("--clear-policy", action="store_true") + update.add_argument("--clear-external-id", action="store_true") + update.add_argument("--clear-duration", action="store_true") + update.add_argument("--profile", "--name", dest="profile", default="default") + update.add_argument("--no-verify", action="store_true") + elif kind == "target": + add.add_argument("--source-account", required=True) + add.add_argument("--source-profile", default="default") + source = add.add_mutually_exclusive_group() + source.add_argument("--source-location", default="default") + source.add_argument("--source-directory") + add.add_argument("--to") + add.add_argument("--to-directory") + add.add_argument("--to-profile") + add.add_argument("--boundary") + update.add_argument("--boundary") + update.add_argument("--clear-boundary", action="store_true") + update.add_argument("--source-account") + update.add_argument("--source-profile") + update_source = update.add_mutually_exclusive_group() + update_source.add_argument("--source-location") + update_source.add_argument("--source-directory") + update.add_argument("--to") + update.add_argument("--to-directory") + update.add_argument("--to-profile") + update.add_argument("--clear-destination", action="store_true") + + def _create_parser() -> argparse.ArgumentParser: - """Create the Hacksaws argument parser.""" + """Create the complete, non-abbreviating Hacksaws parser.""" parser = argparse.ArgumentParser( prog="hacksaws", - description="CLI for dynamic credential login management in AWS.", + description="Secure AWS login and boundary manager.", allow_abbrev=False, ) - type_subparsers = parser.add_subparsers(dest="access_type") + types = parser.add_subparsers(dest="access_type") - mfa_type_parser = type_subparsers.add_parser("mfa") - subparsers = mfa_type_parser.add_subparsers(dest="action") + mfa = types.add_parser("mfa") + mfa_actions = mfa.add_subparsers(dest="action") + mfa_login = mfa_actions.add_parser("login", aliases=["in"]) + _login_arguments(mfa_login) + mfa_logout = mfa_actions.add_parser("logout", aliases=["out"]) + _logout_arguments(mfa_logout) - login_parser = subparsers.add_parser("login", aliases=["in"]) - login_parser.add_argument("profile") - login_parser.add_argument("mfa_code") - login_parser.add_argument("-l", "--lifespan", type=int, default=43200) + for auth_name in ("pk", "web"): + auth = types.add_parser(auth_name) + actions = auth.add_subparsers(dest="action") + login = actions.add_parser("login", aliases=["in"]) + _login_arguments(login, browser=True) + logout = actions.add_parser("logout", aliases=["out"]) + _logout_arguments(logout) - logout_parser = subparsers.add_parser("logout", aliases=["out"]) - logout_parser.add_argument("profile") + logout = types.add_parser("logout") + _logout_arguments(logout) + status = types.add_parser("status") + status.add_argument("--json", action="store_true") - for action_parser in (login_parser, logout_parser): - action_parser.add_argument("--ecr", action="store_true") - action_parser.add_argument("--podman", action="store_true") - action_parser.add_argument("--ecr-region", action="append") - action_parser.add_argument( - "-d", - "--dir", - "--directory", - dest="directory", - default="~/.aws", - ) - action_parser.add_argument( - "-n", - "--name", - "--account-name", - dest="aws_account_name", - ) + for kind in ("account", "boundary", "target"): + _resource_parser(types, kind) + + policy = types.add_parser("policy") + policy_actions = policy.add_subparsers(dest="resource_action") + for action in ("add", "update"): + item = policy_actions.add_parser(action) + item.add_argument("resource_name") + item.add_argument("file") + item.add_argument("--format", choices=("json", "yaml", "yml", "toml")) + item.add_argument("--description") + for action in ("get", "remove"): + item = policy_actions.add_parser(action) + item.add_argument("resource_name") + item.add_argument("--json", action="store_true") + policy_actions.add_parser("list").add_argument("--json", action="store_true") + rename = policy_actions.add_parser("rename") + rename.add_argument("resource_name") + rename.add_argument("new_name") + cache = types.add_parser("cache") + cache_actions = cache.add_subparsers(dest="cache_action") + cache_get = cache_actions.add_parser("get") + cache_get.add_argument("setting", nargs="?", choices=("max-age",)) + cache_get.add_argument("--json", action="store_true") + cache_set = cache_actions.add_parser("set") + cache_set.add_argument("setting", choices=("max-age",)) + cache_set.add_argument("value") + cache_clear = cache_actions.add_parser("clear") + cache_clear.add_argument("--yes", action="store_true") + + config = types.add_parser("config") + config_actions = config.add_subparsers(dest="config_action") + show = config_actions.add_parser("show") + show.add_argument("--account") + show.add_argument("--json", action="store_true") + explain = config_actions.add_parser("explain") + explain.add_argument("target") + explain.add_argument("--json", action="store_true") + check = config_actions.add_parser("check") + check.add_argument("--remote", action="store_true") + check.add_argument("--probe", action="store_true") + check.add_argument("--account") + check.add_argument("--json", action="store_true") + _credential_selector(check) + fix = config_actions.add_parser("fix") + fix.add_argument("--account") + fix.add_argument("--yes", action="store_true") + export = config_actions.add_parser("export") + export.add_argument("zip", nargs="?") + imported = config_actions.add_parser("import") + imported.add_argument("zip") + imported.add_argument("--replace", action="store_true") + imported.add_argument("--yes", action="store_true") return parser def _print_help(command: Sequence[str] = ()) -> None: - """Print command help without exiting the program.""" try: _create_parser().parse_args([*command, "--help"]) except SystemExit: return +def _validate_login(namespace: argparse.Namespace) -> None: + if getattr(namespace, "profile", None) and namespace.profile.startswith("+"): + if getattr(namespace, "target", None): + raise _configs.OperationalError("Specify a target only once.") + namespace.target = namespace.profile + namespace.profile = None + if getattr(namespace, "policy", None) and not ( + getattr(namespace, "role", None) + or getattr(namespace, "boundary", None) + or getattr(namespace, "target", None) + ): + raise _configs.OperationalError("--policy requires --role or --boundary.") + if ( + getattr(namespace, "external_id", None) + or getattr(namespace, "session_name", None) + ) and not ( + getattr(namespace, "role", None) + or getattr(namespace, "boundary", None) + or getattr(namespace, "target", None) + ): + raise _configs.OperationalError( + "Role-only options require --role or --boundary." + ) + if getattr(namespace, "to", None) and ( + getattr(namespace, "to_directory", None) + or getattr(namespace, "to_profile", None) + ): + raise _configs.OperationalError( + "--to is mutually exclusive with --to-directory/--to-profile." + ) + if getattr(namespace, "to_directory", None) and not getattr( + namespace, "to_profile", None + ): + raise _configs.OperationalError("--to-directory requires --to-profile.") + if getattr(namespace, "target", None) and not namespace.target.startswith("+"): + namespace.target = "+" + namespace.target + if getattr(namespace, "role", None) and getattr(namespace, "boundary", None): + raise _configs.OperationalError( + "--role and --boundary/--as are mutually exclusive." + ) + if getattr(namespace, "target", None): + data = _state.load_config() + _, target = _state.get_resource(data, "target", namespace.target.lstrip("+")) + direct_boundary = getattr(namespace, "boundary", None) + if direct_boundary and target.get("boundary"): + raise _configs.OperationalError( + "A target with a saved boundary cannot accept --boundary/--as." + ) + overrides = [ + getattr(namespace, "profile", None), + getattr(namespace, "aws_account_name", None), + getattr(namespace, "to", None), + getattr(namespace, "to_directory", None), + getattr(namespace, "to_profile", None), + getattr(namespace, "role", None), + getattr(namespace, "policy", None), + getattr(namespace, "account", None), + getattr(namespace, "external_id", None), + getattr(namespace, "session_name", None), + ] + if ( + any(value is not None for value in overrides) + or getattr(namespace, "directory", "~/.aws") != "~/.aws" + ): + raise _configs.OperationalError( + "A saved target is a secure preset; source, destination, role, and policy cannot be overridden." + ) + + def _run_mfa(context: _configs.Context) -> _configs.Result: - """Execute an MFA login or logout action.""" + """Execute MFA while preserving the legacy direct-profile behavior.""" action = cast("str | None", context.args.action) if not action: _print_help(("mfa",)) return _configs.Result( - code="MFA_HELP", - message="Not enough arguments specified for the mfa command.", - exit_code=2, - stream="stderr", + "MFA_HELP", + "Not enough arguments specified for the mfa command.", + 2, + "stderr", + ) + if action in {"logout", "out"}: + return _run_logout(context) + _validate_login(context.args) + if context.args.profile is None and not context.args.target: + raise _configs.OperationalError( + "MFA login requires a source profile unless a saved target supplies it." ) + if context.args.mfa_code is None: + raise _configs.OperationalError("MFA login requires a token code.") + if _sessions.is_expanded_login(context.args): + return _sessions.mfa_login(context) os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(context.credentials_path) os.environ["AWS_CONFIG_FILE"] = str(context.config_path) - _aws.logout(context) aws_account = _configs.AwsAccount.from_context(context) - if cast("bool", context.args.ecr): - _ecr.logout( - context, - aws_account, - check=action not in {"login", "in"}, + _ecr.logout(context, aws_account, check=False) + _aws.login(context) + if cast("bool", context.args.ecr): + _ecr.login(context, aws_account) + return _configs.Result("MFA_LOGIN", f"Logged into profile {context.profile}") + + +def _run_logout(context: _configs.Context) -> _configs.Result: + if context.args.profile.startswith("+") and not context.args.target: + context.args.target = context.args.profile + context.args.profile = "default" + _sessions.recover_journal() + if _sessions.logout(context): + return _configs.Result("LOGOUT", f"Logged out of profile {context.profile}") + os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(context.credentials_path) + os.environ["AWS_CONFIG_FILE"] = str(context.config_path) + _aws.logout(context) + return _configs.Result("MFA_LOGOUT", f"Logged out of profile {context.profile}") + + +def _run_browser(context: _configs.Context) -> _configs.Result: + if not context.args.action: + _print_help((context.args.access_type,)) + return _configs.Result( + "BROWSER_HELP", + "Not enough arguments specified for browser authentication.", + 2, + "stderr", + ) + if context.args.action in {"logout", "out"}: + return _run_logout(context) + _validate_login(context.args) + return _sessions.browser_login(context) + + +def _json_or_text(value: object, use_json: bool) -> str: + if use_json: + return json.dumps(value, indent=2, default=str) + if isinstance(value, dict): + return "\n".join(f"{key}: {item}" for key, item in value.items()) + if isinstance(value, list): + return "\n".join(json.dumps(item, default=str) for item in value) or "(none)" + return str(value) + + +def _cascade_plan(data: dict[str, Any], kind: str, name: str) -> dict[str, set[str]]: + """Compute whole-resource dependent deletion without weakening boundaries.""" + key, _ = _state.get_resource(data, kind, name) + plan: dict[str, set[str]] = { + "accounts": set(), + "boundaries": set(), + "targets": set(), + "policies": set(), + } + plan[_state.collection_name(kind)].add(key) + changed = True + while changed: + changed = False + for target_name, target in data["targets"].items(): + if target_name in plan["targets"]: + continue + if any( + str(target.get("source_account", "")).casefold() == account.casefold() + for account in plan["accounts"] + ) or any( + str(target.get("boundary", "")).casefold() == boundary.casefold() + for boundary in plan["boundaries"] + ): + plan["targets"].add(target_name) + changed = True + for boundary_name, boundary in data["boundaries"].items(): + if boundary_name in plan["boundaries"]: + continue + if any( + str(boundary.get("account", "")).casefold() == account.casefold() + for account in plan["accounts"] + ) or any( + str(boundary.get("policy", "")).casefold() == policy.casefold() + for policy in plan["policies"] + ): + plan["boundaries"].add(boundary_name) + changed = True + return plan + + +def _cascade_remove(data: dict[str, Any], kind: str, name: str, *, yes: bool) -> str: + plan = _cascade_plan(data, kind, name) + planned = [ + f"{collection[:-1]}:{item}" + for collection, names in plan.items() + for item in sorted(names) + ] + active = [ + reference + for planned_kind in ("account", "boundary", "target", "policy") + for planned_name in plan[_state.collection_name(planned_kind)] + for reference in _state.references(data, planned_kind, planned_name) + if reference.startswith("session:") + ] + if active: + raise _configs.OperationalError( + f"Cascade cannot remove active session references: {', '.join(active)}. " + "Log out first." ) + preview = f"Cascade preview: {', '.join(planned)}." + if not yes: + if not sys.stdin.isatty(): + raise _configs.OperationalError( + f"{preview} Noninteractive cascade requires --yes." + ) + if input( + f"{preview} Delete all listed resources? [y/N] " + ).strip().casefold() not in { + "y", + "yes", + }: + raise _configs.OperationalError("Cascade cancelled; no files changed.") + policy_paths = [ + _policies.stored_directory() / f"{policy}.yaml" for policy in plan["policies"] + ] + journal = _sessions._begin([_state.root() / "config.json", *policy_paths]) + try: + for collection, names in plan.items(): + for item in names: + del data[collection][item] + _state.save_config(data) + for path in policy_paths: + path.unlink(missing_ok=True) + _sessions._commit() + except Exception: + _sessions._rollback(journal) + raise + return preview + - if action in {"login", "in"}: - _aws.login(context) - if cast("bool", context.args.ecr): - _ecr.login(context, aws_account) +def _run_resource(args: argparse.Namespace) -> _configs.Result: + kind = args.access_type + action = args.resource_action + if not action: + _print_help((kind,)) return _configs.Result( - code="MFA_LOGIN", - message=f"Logged into profile {context.profile}", + "RESOURCE_HELP", f"Choose an action for {kind}.", 2, "stderr" ) + data = _state.load_config() + if action == "list": + values = [ + {"name": name, **item} + for name, item in data[_state.collection_name(kind)].items() + ] + return _configs.Result("RESOURCE_LIST", _json_or_text(values, args.json)) + if action == "get": + name, item = _state.get_resource(data, kind, args.resource_name) + return _configs.Result( + "RESOURCE_GET", _json_or_text({"name": name, **item}, args.json) + ) + if action == "rename": + journal = _sessions._begin( + [_state.root() / "config.json", _state.sessions_path()] + ) + try: + _state.rename_resource(data, kind, args.resource_name, args.new_name) + _state.save_config(data) + _sessions._commit() + except Exception: + _sessions._rollback(journal) + raise + return _configs.Result( + "RESOURCE_RENAME", + f"Renamed {kind} {args.resource_name} to {args.new_name}.", + ) + if action == "remove": + if args.cascade: + preview = _cascade_remove(data, kind, args.resource_name, yes=args.yes) + return _configs.Result("RESOURCE_REMOVE", f"{preview} Removed.") + _state.remove_resource(data, kind, args.resource_name) + _state.save_config(data) + return _configs.Result( + "RESOURCE_REMOVE", f"Removed {kind} {args.resource_name}." + ) + if action == "add": + if kind == "account": + partition = args.partition + unverified = bool(args.no_verify) + if args.no_verify: + if partition is None: + raise _configs.OperationalError( + "--no-verify account saves require an explicit --partition." + ) + else: + try: + caller_id, caller_partition, _ = _sessions._identity( + boto3.Session(profile_name=args.profile), + label="account configuration", + ) + except _configs.OperationalError: + raise + if caller_id != args.account_id: + raise _configs.OperationalError( + f"Configured account id {args.account_id} does not match caller {caller_id}." + ) + if partition and partition != caller_partition: + raise _configs.OperationalError( + f"Configured partition {partition} does not match caller {caller_partition}." + ) + partition = caller_partition + value = { + "id": args.account_id, + "partition": partition, + **({"unverified": True} if unverified else {}), + } + elif kind == "boundary": + _, account = _state.get_resource(data, "account", args.account) + role = args.role + if not role.startswith("arn:"): + role = f"arn:{account['partition']}:iam::{account['id']}:role/{role}" + if not args.no_verify: + role_name = role.split("role/", 1)[-1] + try: + response = ( + boto3.Session(profile_name=args.profile) + .client("iam") + .get_role(RoleName=role_name) + ) + role = response["Role"]["Arn"] + except (BotoCoreError, ClientError, KeyError) as error: + raise _configs.OperationalError( + f"Unable to verify role {role_name!r}; retry with working " + f"credentials or explicitly use --no-verify: {error}" + ) from error + match = re.fullmatch( + r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/.+", role + ) + if not match or ( + match.group(1) != account["partition"] + or match.group(2) != account["id"] + ): + raise _configs.OperationalError( + "Boundary role account/partition conflicts with --account." + ) + value = { + "role_arn": role, + "account": args.account, + "verified": not args.no_verify, + } + if args.policy: + value["policy"] = args.policy + if args.external_id: + value["external_id"] = args.external_id + if args.duration: + value["duration"] = _duration.parse_duration(args.duration) + else: + value = { + "source_account": args.source_account, + "source_profile": args.source_profile, + } + if args.source_directory: + value["source_directory"] = str( + Path(args.source_directory).expanduser().absolute() + ) + else: + value["source_location"] = _state.normalize_location( + args.source_location + ) + if args.to: + location, separator, profile = args.to.partition(":") + if not separator or not profile: + raise _configs.OperationalError("--to must be LOCATION:PROFILE.") + value.update( + destination_location=_state.normalize_location(location), + destination_profile=profile, + ) + elif args.to_directory: + if not args.to_profile: + raise _configs.OperationalError( + "--to-directory requires --to-profile." + ) + value.update( + destination_directory=str( + Path(args.to_directory).expanduser().absolute() + ), + destination_profile=args.to_profile, + ) + if args.boundary: + value["boundary"] = args.boundary + if args.description: + value["description"] = args.description + _state.add_resource(data, kind, args.resource_name, value) + else: + patch: dict[str, Any] = {} + if args.description is not None: + patch["description"] = args.description + if args.clear_description: + _, item = _state.get_resource(data, kind, args.resource_name) + item.pop("description", None) + if kind == "boundary": + _, existing_boundary = _state.get_resource(data, kind, args.resource_name) + selected_account = args.account or existing_boundary["account"] + if args.account: + _state.get_resource(data, "account", args.account) + patch["account"] = args.account + if args.role: + _, account_record = _state.get_resource( + data, "account", selected_account + ) + role = args.role + if not role.startswith("arn:"): + role = ( + f"arn:{account_record['partition']}:iam::" + f"{account_record['id']}:role/{role}" + ) + if not args.no_verify: + role_name = role.split("role/", 1)[-1] + try: + role = ( + boto3.Session(profile_name=args.profile) + .client("iam") + .get_role(RoleName=role_name)["Role"]["Arn"] + ) + except (BotoCoreError, ClientError, KeyError) as error: + raise _configs.OperationalError( + f"Unable to verify role update {role_name!r}: {error}" + ) from error + patch["role_arn"] = role + patch["verified"] = not args.no_verify + for field in ("policy", "external_id"): + supplied = getattr(args, field) + if supplied is not None: + patch[field] = supplied + if getattr(args, f"clear_{field}"): + _, item = _state.get_resource(data, kind, args.resource_name) + item.pop(field, None) + if args.duration: + patch["duration"] = _duration.parse_duration(args.duration) + if args.clear_duration: + _, item = _state.get_resource(data, kind, args.resource_name) + item.pop("duration", None) + if kind == "target": + _, item = _state.get_resource(data, kind, args.resource_name) + if args.source_account: + _state.get_resource(data, "account", args.source_account) + patch["source_account"] = args.source_account + if args.source_profile: + patch["source_profile"] = args.source_profile + if args.source_location: + item.pop("source_directory", None) + patch["source_location"] = _state.normalize_location( + args.source_location + ) + if args.source_directory: + item.pop("source_location", None) + patch["source_directory"] = str( + Path(args.source_directory).expanduser().absolute() + ) + if args.boundary: + patch["boundary"] = args.boundary + if args.clear_boundary: + _, item = _state.get_resource(data, kind, args.resource_name) + item.pop("boundary", None) + if args.clear_destination: + for field in ( + "destination_location", + "destination_directory", + "destination_profile", + ): + item.pop(field, None) + if args.to and (args.to_directory or args.to_profile): + raise _configs.OperationalError( + "--to is mutually exclusive with --to-directory/--to-profile." + ) + if args.to: + location, separator, profile = args.to.partition(":") + if not separator or not profile: + raise _configs.OperationalError("--to must be LOCATION:PROFILE.") + item.pop("destination_directory", None) + patch.update( + destination_location=_state.normalize_location(location), + destination_profile=profile, + ) + if args.to_directory: + if not args.to_profile: + raise _configs.OperationalError( + "--to-directory requires --to-profile." + ) + item.pop("destination_location", None) + patch.update( + destination_directory=str( + Path(args.to_directory).expanduser().absolute() + ), + destination_profile=args.to_profile, + ) + _state.update_resource(data, kind, args.resource_name, patch) + _state.save_config(data) + return _configs.Result("RESOURCE_SAVED", f"Saved {kind} {args.resource_name}.") + + +def _stdin_policy(args: argparse.Namespace) -> Path: + if args.file != "-": + return Path(args.file).expanduser() + if not args.format: + raise _configs.OperationalError("Policy FILE '-' requires --format.") + temporary = _state.root() / f".stdin-policy.{args.format}" + _state.atomic_write(temporary, sys.stdin.buffer.read()) + return temporary + +def _run_policy(args: argparse.Namespace) -> _configs.Result: + action = args.resource_action + if not action: + _print_help(("policy",)) + return _configs.Result("POLICY_HELP", "Choose a policy action.", 2, "stderr") + if action in {"add", "update"}: + path = _stdin_policy(args) + try: + operation = ( + _policies.add_stored if action == "add" else _policies.update_stored + ) + operation(args.resource_name, path, args.description) + finally: + if args.file == "-": + path.unlink(missing_ok=True) + return _configs.Result("POLICY_SAVED", f"Saved policy {args.resource_name}.") + if action == "remove": + _policies.remove_stored(args.resource_name) + return _configs.Result("POLICY_REMOVE", f"Removed policy {args.resource_name}.") + if action == "rename": + _policies.rename_stored(args.resource_name, args.new_name) + return _configs.Result( + "POLICY_RENAME", f"Renamed policy {args.resource_name} to {args.new_name}." + ) + data = _state.load_config() + if action == "list": + values = [{"name": name, **item} for name, item in data["policies"].items()] + return _configs.Result("POLICY_LIST", _json_or_text(values, args.json)) + key, metadata = _state.get_resource(data, "policy", args.resource_name) + document, _ = _policies.parse_policy(_policies.stored_directory() / f"{key}.yaml") return _configs.Result( - code="MFA_LOGOUT", - message=f"Logged out of profile {context.profile}", + "POLICY_GET", + _json_or_text({"name": key, **metadata, "document": document}, args.json), ) +def _run_cache(args: argparse.Namespace) -> _configs.Result: + data = _state.load_config() + if args.cache_action == "set": + data["cache"]["max_age"] = _duration.parse_duration(args.value, allow_zero=True) + _state.save_config(data) + return _configs.Result( + "CACHE_SET", + f"Policy cache max-age set to {data['cache']['max_age']} seconds.", + ) + if args.cache_action == "clear": + shutil.rmtree(_policies.cache_root(), ignore_errors=True) + return _configs.Result("CACHE_CLEAR", "Policy cache cleared.") + if args.cache_action == "get": + entries = ( + list(_policies.cache_root().glob("*.json")) + if _policies.cache_root().exists() + else [] + ) + value = {"max_age": data["cache"]["max_age"], "entries": len(entries)} + return _configs.Result("CACHE_GET", _json_or_text(value, args.json)) + _print_help(("cache",)) + return _configs.Result("CACHE_HELP", "Choose a cache action.", 2, "stderr") + + +def _run_config(args: argparse.Namespace) -> _configs.Result: + action = args.config_action + if action == "show": + data = _state.load_config() + if args.account: + key, account = _state.get_resource(data, "account", args.account) + data = { + "account": {"name": key, **account}, + "boundaries": { + k: v + for k, v in data["boundaries"].items() + if str(v["account"]).casefold() == key.casefold() + }, + "targets": data["targets"], + } + return _configs.Result("CONFIG_SHOW", _json_or_text(data, args.json)) + if action == "explain": + value = _sessions.explain_target(args.target) + return _configs.Result("CONFIG_EXPLAIN", _json_or_text(value, args.json)) + if action == "check": + report = _sessions.check_config(args) + return _configs.Result( + "CONFIG_CHECK", + _json_or_text(report, args.json), + 0 if not report["errors"] else 1, + ) + if action == "fix": + return _sessions.fix_config(args) + if action == "export": + path = _sessions.export_config(args.zip) + return _configs.Result( + "CONFIG_EXPORT", f"Exported portable configuration to {path}." + ) + if action == "import": + summary = _sessions.import_config( + Path(args.zip), replace=args.replace, yes=args.yes + ) + return _configs.Result("CONFIG_IMPORT", summary) + _print_help(("config",)) + return _configs.Result("CONFIG_HELP", "Choose a config action.", 2, "stderr") + + def console_main(arguments: Sequence[str] | None = None) -> _configs.Result: - """Run a command-line invocation and return its structured result.""" parser = _create_parser() try: namespace = parser.parse_args(arguments) except SystemExit as error: - exit_code = cast("int", error.code) - code = "HELP" if exit_code == 0 else "ARGUMENT_ERROR" - return _configs.Result(code=code, message="", exit_code=exit_code) - + return _configs.Result( + "HELP" if error.code == 0 else "ARGUMENT_ERROR", "", cast("int", error.code) + ) if not namespace.access_type: _print_help() return _configs.Result( - code="ACCESS_TYPE_HELP", - message="Not enough arguments.", - exit_code=2, - stream="stderr", + "ACCESS_TYPE_HELP", "Not enough arguments.", 2, "stderr" ).echo() - + if namespace.access_type == "mfa" and namespace.action in {"login", "in"}: + if namespace.target and namespace.profile and namespace.mfa_code is None: + namespace.mfa_code = namespace.profile + namespace.profile = None + if namespace.mfa_code is None: + parser.print_usage(sys.stderr) + return _configs.Result( + "ARGUMENT_ERROR", + "the following arguments are required: PROFILE CODE or +TARGET CODE.", + 2, + "stderr", + ).echo() try: - result = _run_mfa(_configs.Context(args=namespace)) + _sessions.recover_journal() + if namespace.access_type == "mfa": + result = _run_mfa(_configs.Context(args=namespace)) + elif namespace.access_type in {"pk", "web"}: + result = _run_browser(_configs.Context(args=namespace)) + elif namespace.access_type == "logout": + result = _run_logout(_configs.Context(args=namespace)) + elif namespace.access_type == "status": + result = _configs.Result( + "STATUS", _json_or_text(_sessions.status(), namespace.json) + ) + elif namespace.access_type in {"account", "boundary", "target"}: + result = _run_resource(namespace) + elif namespace.access_type == "policy": + result = _run_policy(namespace) + elif namespace.access_type == "cache": + result = _run_cache(namespace) + else: + result = _run_config(namespace) except _configs.OperationalError as error: return _configs.Result( - code="OPERATIONAL_ERROR", - message=f"Error: {error}", - exit_code=1, - stream="stderr", + "OPERATIONAL_ERROR", f"Error: {error}", 1, "stderr" ).echo() return result.echo() diff --git a/hacksaws/_configs.py b/hacksaws/_configs.py index 435d57c..f01e6de 100644 --- a/hacksaws/_configs.py +++ b/hacksaws/_configs.py @@ -33,18 +33,20 @@ class Context: @property def profile(self) -> str: """Return the AWS profile name for this invocation.""" - return cast("str", self.args.profile) + return cast("str", getattr(self.args, "profile", None) or "default") @property def container_engine(self) -> ContainerEngine: """Return the container engine selected for ECR authentication.""" - return "podman" if cast("bool", self.args.podman) else "docker" + return ( + "podman" if cast("bool", getattr(self.args, "podman", False)) else "docker" + ) @property def aws_directory(self) -> Path: """Return the directory containing AWS configuration and credentials.""" - account_name = cast("str | None", self.args.aws_account_name) - configured_directory = cast("str", self.args.directory) + account_name = cast("str | None", getattr(self.args, "aws_account_name", None)) + configured_directory = cast("str", getattr(self.args, "directory", "~/.aws")) value = f"~/.aws-{account_name}" if account_name else configured_directory return Path(value).expanduser().absolute() @@ -93,6 +95,24 @@ def user_id(self) -> str | None: value = self.identity_response.get("UserId") return value if isinstance(value, str) else None + @property + def partition(self) -> str: + """Return the caller partition, defaulting to commercial for legacy data.""" + arn = self.user_arn + if arn: + try: + partition = arn.split(":", 2)[1] + except IndexError: + partition = "" + if partition in {"aws", "aws-us-gov", "aws-cn"}: + return partition + return "aws" + + @property + def dns_suffix(self) -> str: + """Return the AWS DNS suffix for this partition.""" + return "amazonaws.com.cn" if self.partition == "aws-cn" else "amazonaws.com" + @property def ecr_regions(self) -> tuple[str, ...]: """Return ECR regions in stable, primary-first order without duplicates.""" @@ -104,7 +124,8 @@ def ecr_regions(self) -> tuple[str, ...]: def ecr_registries(self) -> list[str]: """Return registry hostnames for all configured ECR regions.""" return [ - f"{self.id}.dkr.ecr.{region}.amazonaws.com" for region in self.ecr_regions + f"{self.id}.dkr.ecr.{region}.{self.dns_suffix}" + for region in self.ecr_regions ] @classmethod diff --git a/hacksaws/_duration.py b/hacksaws/_duration.py new file mode 100644 index 0000000..2350843 --- /dev/null +++ b/hacksaws/_duration.py @@ -0,0 +1,89 @@ +"""Exact duration parsing shared by sessions and the policy cache.""" + +from __future__ import annotations + +import re +from decimal import ROUND_HALF_UP +from decimal import Decimal +from decimal import InvalidOperation + +from hacksaws._configs import OperationalError + +_DURATION = re.compile(r"^((?:[0-9]+(?:\.[0-9]+)?)|(?:\.[0-9]+))\s*([A-Za-z]+)$") +_UNITS = { + "s": 1, + "sec": 1, + "second": 1, + "seconds": 1, + "m": 60, + "min": 60, + "minute": 60, + "minutes": 60, + "h": 3600, + "hr": 3600, + "hour": 3600, + "hours": 3600, +} + + +def parse_duration(value: str, *, allow_zero: bool = False) -> int: + """Parse one decimal duration and return conventional whole seconds.""" + match = _DURATION.fullmatch(value.strip()) + if not match: + raise OperationalError( + "Duration must be one positive decimal followed by a second, minute, " + "or hour unit (for example 90m or 1.5hours)." + ) + unit = match.group(2).lower() + if unit not in _UNITS: + raise OperationalError(f"Unsupported duration unit {match.group(2)!r}.") + try: + seconds = (Decimal(match.group(1)) * _UNITS[unit]).quantize( + Decimal(1), rounding=ROUND_HALF_UP + ) + except InvalidOperation as error: + raise OperationalError(f"Invalid duration {value!r}.") from error + result = int(seconds) + if result < 0 or (result == 0 and not allow_zero): + qualifier = "non-negative" if allow_zero else "positive" + raise OperationalError(f"Duration must round to a {qualifier} whole second.") + return result + + +def parse_count(value: str, multiplier: int) -> int: + """Convert a decimal count for --htl/--mtl/--stl to seconds.""" + try: + number = Decimal(value) + except InvalidOperation as error: + raise OperationalError(f"Invalid duration count {value!r}.") from error + if not number.is_finite() or number <= 0: + raise OperationalError("Duration count must be a positive decimal.") + result = int((number * multiplier).quantize(Decimal(1), rounding=ROUND_HALF_UP)) + if result <= 0: + raise OperationalError("Duration must round to a positive whole second.") + return result + + +def session_duration( + *, + duration: str | None = None, + htl: str | None = None, + mtl: str | None = None, + stl: str | None = None, + default: int = 3600, +) -> int: + """Resolve mutually-exclusive CLI duration forms.""" + values = [value is not None for value in (duration, htl, mtl, stl)] + if sum(values) > 1: + raise OperationalError( + "Only one of --duration/--ttl, --htl, --mtl, and --stl may be used." + ) + if duration is not None: + return parse_duration(duration) + if htl is not None: + return parse_count(htl, 3600) + if mtl is not None: + return parse_count(mtl, 60) + if stl is not None: + return parse_count(stl, 1) + return default diff --git a/hacksaws/_ecr.py b/hacksaws/_ecr.py index b206823..8f5d5b4 100644 --- a/hacksaws/_ecr.py +++ b/hacksaws/_ecr.py @@ -7,6 +7,8 @@ import subprocess from datetime import UTC from datetime import datetime +from typing import TYPE_CHECKING +from typing import Any from typing import cast import boto3 @@ -15,6 +17,9 @@ from hacksaws import _configs +if TYPE_CHECKING: + from collections.abc import Callable + def _run_container_engine( engine: _configs.ContainerEngine, @@ -43,16 +48,23 @@ def _do_login( *, account_id: str, region_name: str, -) -> None: + dns_suffix: str = "amazonaws.com", + session: Any | None = None, +) -> str: """Log the selected container engine into one region-specific ECR registry.""" - registry = f"{account_id}.dkr.ecr.{region_name}.amazonaws.com" + registry = f"{account_id}.dkr.ecr.{region_name}.{dns_suffix}" print(f"[STARTED]: Logging into {registry}", flush=True) # noqa: T201 try: - session = boto3.Session( + aws_session = session or boto3.Session( profile_name=context.profile, region_name=region_name, ) - response = session.client("ecr").get_authorization_token( + client = ( + aws_session.client("ecr", region_name=region_name) + if session is not None + else aws_session.client("ecr") + ) + response = client.get_authorization_token( registryIds=[account_id], ) authorization_data = response["authorizationData"][0] @@ -94,16 +106,43 @@ def _do_login( f"[SUCCESS]: Login session will expire in {hours} hours", flush=True, ) + return registry -def login(context: _configs.Context, aws_account: _configs.AwsAccount) -> None: +def login(context: _configs.Context, aws_account: _configs.AwsAccount) -> list[str]: """Log the selected container engine into every configured ECR region.""" - for region_name in aws_account.ecr_regions: + return [ _do_login( context, account_id=aws_account.id, region_name=region_name, + dns_suffix=aws_account.dns_suffix, + ) + for region_name in aws_account.ecr_regions + ] + + +def login_with_session( + context: _configs.Context, + aws_account: _configs.AwsAccount, + session: Any, + *, + on_success: Callable[[str], None] | None = None, +) -> list[str]: + """Install ECR tokens using broad intermediate credentials.""" + completed: list[str] = [] + for region_name in aws_account.ecr_regions: + registry = _do_login( + context, + account_id=aws_account.id, + region_name=region_name, + dns_suffix=aws_account.dns_suffix, + session=session, ) + completed.append(registry) + if on_success: + on_success(registry) + return completed def logout( diff --git a/hacksaws/_policies.py b/hacksaws/_policies.py new file mode 100644 index 0000000..25e9902 --- /dev/null +++ b/hacksaws/_policies.py @@ -0,0 +1,486 @@ +"""Session-policy parsing, storage, resolution, and inspection cache.""" + +from __future__ import annotations + +import json +import re +import tomllib +from dataclasses import dataclass +from datetime import UTC +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import unquote + +import boto3 +import yaml +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError + +from hacksaws import _state +from hacksaws._configs import OperationalError + +POLICY_ARN = re.compile(r"^arn:(aws|aws-us-gov|aws-cn):iam::(aws|\d{12}):policy/(.+)$") +PATH_SUFFIXES = {".json", ".yaml", ".yml", ".toml"} + + +@dataclass(frozen=True) +class ResolvedPolicy: + """Policy material ready for AssumeRole.""" + + identity: str + origin: str + provenance: str + arn: str | None = None + document: str | None = None + cached: bool = False + + +def _validate_document(value: object) -> dict[str, Any]: + if not isinstance(value, dict): + raise OperationalError("An IAM policy document must be an object.") + if "Version" not in value or "Statement" not in value: + raise OperationalError("An IAM policy requires Version and Statement fields.") + if not isinstance(value["Statement"], (dict, list)): + raise OperationalError("IAM policy Statement must be an object or list.") + return value + + +def parse_policy( + path: Path, *, format_name: str | None = None +) -> tuple[dict[str, Any], bytes]: + """Parse and validate an IAM policy file, returning its original bytes.""" + try: + raw = path.read_bytes() + except OSError as error: + raise OperationalError(f"Unable to read policy file {path}: {error}") from error + kind = (format_name or path.suffix.lstrip(".")).lower() + return parse_policy_bytes(raw, kind=kind, source=str(path)), raw + + +def parse_policy_bytes(raw: bytes, *, kind: str, source: str) -> dict[str, Any]: + """Parse policy bytes without touching live state.""" + try: + text = raw.decode("utf-8") + except UnicodeError as error: + raise OperationalError( + f"Policy {source} is not valid UTF-8: {error}" + ) from error + try: + if kind == "json": + value = json.loads(text) + elif kind in {"yaml", "yml"}: + value = yaml.safe_load(text) + elif kind == "toml": + value = tomllib.loads(text) + else: + raise OperationalError(f"Unsupported policy format {kind!r}.") + except (json.JSONDecodeError, tomllib.TOMLDecodeError, yaml.YAMLError) as error: + raise OperationalError( + f"Invalid {kind.upper()} policy {source}: {error}" + ) from error + return _validate_document(value) + + +def minify(value: object) -> str: + """Return canonical compact JSON for an IAM document.""" + return json.dumps(_validate_document(value), separators=(",", ":"), sort_keys=True) + + +def enforce_inline_limit(document: str) -> None: + """Enforce the STS inline session policy character limit.""" + size = len(document) + if size > 2048: + raise OperationalError( + f"Inline session policy is {size} characters; AWS AssumeRole permits at most " + "2048. Use a same-account customer-managed policy ARN instead." + ) + + +def stored_directory() -> Path: + return _state.root() / "stored_session_policies" + + +def add_stored(name: str, source: Path, description: str | None = None) -> None: + """Add a validated canonical stored policy.""" + config = _state.load_config() + document, raw = parse_policy(source) + suffix = source.suffix.lower() + if suffix in {".yaml", ".yml"}: + encoded = raw + else: + encoded = yaml.safe_dump(document, sort_keys=False).encode() + _state.add_resource( + config, + "policy", + name, + { + "file": f"stored_session_policies/{name}.yaml", + **({"description": description} if description else {}), + }, + ) + _state.atomic_write(stored_directory() / f"{name}.yaml", encoded) + try: + _state.save_config(config) + except Exception: + (stored_directory() / f"{name}.yaml").unlink(missing_ok=True) + raise + + +def update_stored(name: str, source: Path, description: str | None = None) -> None: + """Replace stored policy content while retaining its identity.""" + config = _state.load_config() + key, metadata = _state.get_resource(config, "policy", name) + document, raw = parse_policy(source) + encoded = ( + raw + if source.suffix.lower() in {".yaml", ".yml"} + else yaml.safe_dump(document, sort_keys=False).encode() + ) + path = stored_directory() / f"{key}.yaml" + _state.atomic_write(path, encoded) + if description is not None: + metadata["description"] = description + _state.save_config(config) + + +def remove_stored(name: str) -> None: + config = _state.load_config() + key, _ = _state.get_resource(config, "policy", name) + _state.remove_resource(config, "policy", name) + _state.save_config(config) + (stored_directory() / f"{key}.yaml").unlink(missing_ok=True) + + +def rename_stored(old: str, new: str) -> None: + config = _state.load_config() + key, metadata = _state.get_resource(config, "policy", old) + old_path = stored_directory() / f"{key}.yaml" + new_path = stored_directory() / f"{new}.yaml" + config_path = _state.root() / "config.json" + sessions_path = _state.sessions_path() + snapshots = { + path: path.read_bytes() if path.exists() else None + for path in (config_path, sessions_path, old_path, new_path) + } + try: + # The resource validator requires a policy's canonical path to match its + # name. Update the shared metadata object before rename_resource performs + # its final whole-config validation. + metadata["file"] = f"stored_session_policies/{new}.yaml" + _state.rename_resource(config, "policy", old, new) + if old_path.exists(): + old_path.replace(new_path) + _state.save_config(config) + except Exception: + for path, content in snapshots.items(): + if content is None: + path.unlink(missing_ok=True) + else: + _state.atomic_write(path, content) + raise + + +def cache_root() -> Path: + return _state.root() / "policy-cache" + + +def _cache_identity(source: str, *, account: str, partition: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", source.casefold()).strip("-")[-80:] or "policy" + return f"{partition}-{account}-{slug}-{_state.digest(source.encode())[:12]}" + + +def _cache_path(identity: str) -> Path: + return cache_root() / f"{identity}.json" + + +def cache_write( + identity: str, + document: object, + *, + origin: str, + resolver: str, + source_identity: str, +) -> str: + compact = minify(document) + record = { + "schema_version": 1, + "origin": origin, + "resolver": resolver, + "source_identity": source_identity, + "fetched_at": datetime.now(UTC).isoformat(), + "digest": _state.digest(compact.encode()), + "document": json.loads(compact), + } + _state.atomic_write( + _cache_path(identity), (json.dumps(record, indent=2) + "\n").encode() + ) + return compact + + +def cache_read(identity: str, max_age: int) -> tuple[dict[str, Any], float] | None: + if max_age == 0: + return None + path = _cache_path(identity) + if not path.exists(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + fetched = datetime.fromisoformat(value["fetched_at"]) + age = (datetime.now(UTC) - fetched).total_seconds() + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error: + raise OperationalError(f"Invalid policy cache entry {path}: {error}") from error + if age > max_age: + return None + if not isinstance(value, dict): + raise OperationalError(f"Invalid policy cache entry {path}.") + return value, age + + +def resolve( + value: str, + *, + account_id: str, + partition: str, + profile: str = "default", + max_age: int | None = None, + session: Any | None = None, +) -> ResolvedPolicy: + """Resolve ARN, file, stored, then remote policy name in strict order.""" + arn_match = POLICY_ARN.fullmatch(value) + if arn_match: + if arn_match.group(1) != partition: + raise OperationalError( + f"Policy ARN partition {arn_match.group(1)!r} does not match " + f"target partition {partition!r}." + ) + policy_account = arn_match.group(2) + if policy_account != "aws" and policy_account != account_id: + raise OperationalError( + "A customer-managed session policy must belong to the target role account." + ) + if policy_account != "aws": + return ResolvedPolicy(value, "remote-customer", "explicit ARN", arn=value) + return _fetch_aws_managed( + value, + account_id=account_id, + partition=partition, + profile=profile, + max_age=max_age, + session=session, + ) + + candidate = Path(value).expanduser() + explicit_path = candidate.suffix.lower() in PATH_SUFFIXES or any( + mark in value for mark in ("/", "\\") + ) + if explicit_path: + if not candidate.is_file(): + raise OperationalError(f"Policy path does not exist: {candidate}") + document, _ = parse_policy(candidate) + compact = minify(document) + enforce_inline_limit(compact) + identity = ( + "local-" + _state.digest(str(candidate.resolve()).casefold().encode())[:24] + ) + cache_write( + identity, + document, + origin="local", + resolver="file", + source_identity=str(candidate.resolve()), + ) + return ResolvedPolicy(identity, "local", str(candidate), document=compact) + + config = _state.load_config() + stored_key = next( + (key for key in config["policies"] if key.casefold() == value.casefold()), None + ) + if stored_key: + path = stored_directory() / f"{stored_key}.yaml" + document, _ = parse_policy(path) + compact = minify(document) + enforce_inline_limit(compact) + cache_write( + f"stored-{stored_key.casefold()}", + document, + origin="stored", + resolver="stored", + source_identity=stored_key, + ) + return ResolvedPolicy( + stored_key, "stored", f"stored policy {stored_key}", document=compact + ) + return _resolve_remote_name( + value, account_id, partition, profile, max_age, session=session + ) + + +def _get_document(client: Any, arn: str, version: str) -> object: + response = client.get_policy_version(PolicyArn=arn, VersionId=version) + document = response["PolicyVersion"]["Document"] + if isinstance(document, str): + document = json.loads(unquote(document)) + return document + + +def _fetch_aws_managed( + arn: str, + *, + account_id: str, + partition: str, + profile: str, + max_age: int | None, + session: Any | None = None, +) -> ResolvedPolicy: + identity = _cache_identity(arn, account=account_id, partition=partition) + configured_age = ( + _state.load_config()["cache"]["max_age"] if max_age is None else max_age + ) + cached = cache_read(identity, configured_age) + if cached: + compact = minify(cached[0]["document"]) + enforce_inline_limit(compact) + return ResolvedPolicy( + identity, + "aws-managed", + f"cached policy ({cached[1]:.0f}s old)", + document=compact, + cached=True, + ) + try: + client = (session or boto3.Session(profile_name=profile)).client("iam") + metadata = client.get_policy(PolicyArn=arn)["Policy"] + document = _get_document(client, arn, metadata["DefaultVersionId"]) + except ( + BotoCoreError, + ClientError, + KeyError, + ValueError, + json.JSONDecodeError, + ) as error: + raise OperationalError( + f"Unable to fetch AWS-managed policy {arn}: {error}" + ) from error + compact = cache_write( + identity, document, origin="aws-managed", resolver="arn", source_identity=arn + ) + enforce_inline_limit(compact) + return ResolvedPolicy(identity, "aws-managed", arn, document=compact) + + +def _resolve_remote_name( + name: str, + account_id: str, + partition: str, + profile: str, + max_age: int | None, + *, + session: Any | None = None, +) -> ResolvedPolicy: + source = f"name:{name}" + identity = _cache_identity(source, account=account_id, partition=partition) + configured_age = ( + _state.load_config()["cache"]["max_age"] if max_age is None else max_age + ) + cached = cache_read(identity, configured_age) + if cached: + origin = str(cached[0]["origin"]) + compact = minify(cached[0]["document"]) + if origin == "remote-customer": + cached_arn = str(cached[0]["source_identity"]) + cached_match = POLICY_ARN.fullmatch(cached_arn) + if ( + cached_match is None + or cached_match.group(1) != partition + or cached_match.group(2) != account_id + ): + raise OperationalError( + "Cached customer policy identity does not match the target account." + ) + return ResolvedPolicy( + identity, + origin, + f"cached policy ({cached[1]:.0f}s old)", + arn=cached_arn, + cached=True, + ) + enforce_inline_limit(compact) + return ResolvedPolicy( + identity, + origin, + f"cached policy ({cached[1]:.0f}s old)", + document=compact, + cached=True, + ) + resolution_session = session or boto3.Session(profile_name=profile) + try: + identity_response = resolution_session.client("sts").get_caller_identity() + caller_account = str(identity_response["Account"]) + caller_arn = str(identity_response["Arn"]) + caller_partition = caller_arn.split(":", 2)[1] + except (BotoCoreError, ClientError, KeyError, IndexError) as error: + raise OperationalError( + f"Unable to verify credentials for remote policy name {name!r}: {error}" + ) from error + if caller_account != account_id or caller_partition != partition: + raise OperationalError( + f"Bare policy name {name!r} targets {partition}:{account_id}, but the " + f"authenticated resolver is {caller_partition}:{caller_account}. Use a " + "full ARN or authenticate to the target account." + ) + try: + client = resolution_session.client("iam") + local = [ + item + for page in client.get_paginator("list_policies").paginate(Scope="Local") + for item in page.get("Policies", []) + if item.get("PolicyName") == name + ] + aws = [ + item + for page in client.get_paginator("list_policies").paginate(Scope="AWS") + for item in page.get("Policies", []) + if item.get("PolicyName") == name + ] + except (BotoCoreError, ClientError) as error: + arn = f"arn:{partition}:iam::{account_id}:policy/{name}" + return ResolvedPolicy( + identity, + "remote-customer", + f"unverified constructed ARN after list failure: {error}", + arn=arn, + ) + if local and aws: + raise OperationalError( + f"Policy name {name!r} is ambiguous between customer and AWS managed policies; use an ARN." + ) + if not local and not aws: + raise OperationalError(f"Remote IAM policy {name!r} does not exist.") + item = (local or aws)[0] + arn = str(item["Arn"]) + if local: + try: + document = _get_document(client, arn, str(item["DefaultVersionId"])) + cache_write( + identity, + document, + origin="remote-customer", + resolver="name", + source_identity=arn, + ) + except (BotoCoreError, ClientError, KeyError) as error: + raise OperationalError( + f"Unable to inspect customer-managed policy {arn}: {error}" + ) from error + return ResolvedPolicy( + identity, "remote-customer", "verified remote name", arn=arn + ) + return _fetch_aws_managed( + arn, + account_id=account_id, + partition=partition, + profile=profile, + max_age=max_age, + session=session, + ) diff --git a/hacksaws/_sessions.py b/hacksaws/_sessions.py new file mode 100644 index 0000000..f0f313e --- /dev/null +++ b/hacksaws/_sessions.py @@ -0,0 +1,1585 @@ +"""Transactional MFA/browser login, boundary, logout, and portability workflows.""" + +from __future__ import annotations + +import base64 +import configparser +import copy +import getpass +import json +import os +import re +import shutil +import subprocess +import sys +import uuid +import zipfile +from contextlib import contextmanager +from datetime import UTC +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any + +import boto3 +import yaml +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError + +from hacksaws import _configs +from hacksaws import _duration +from hacksaws import _ecr +from hacksaws import _policies +from hacksaws import _state + +if TYPE_CHECKING: + from collections.abc import Iterator + +_AWS_VERSION = re.compile(r"aws-cli/(\d+)\.(\d+)\.(\d+)") +_CONFLICTING_ENV = { + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_CONFIG_FILE", +} + + +def is_expanded_login(args: Any) -> bool: + """Return whether an invocation needs the v0.4 transaction path.""" + return any( + getattr(args, name, None) + for name in ( + "target", + "to", + "to_directory", + "to_profile", + "boundary", + "role", + "policy", + "external_id", + "account", + "session_name", + "region", + "duration", + "htl", + "mtl", + "stl", + ) + ) + + +def _journal_path() -> Path: + return _state.root() / "transaction.json" + + +def _snapshot(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"path": str(path), "exists": False} + stat = path.stat() + return { + "path": str(path), + "exists": True, + "data": base64.b64encode(path.read_bytes()).decode(), + "mode": stat.st_mode, + "mtime_ns": stat.st_mtime_ns, + } + + +def _restore(snapshot: dict[str, Any]) -> None: + path = Path(snapshot["path"]) + if snapshot["exists"]: + _state.atomic_write(path, base64.b64decode(snapshot["data"])) + if "mode" in snapshot: + path.chmod(snapshot["mode"]) + if "mtime_ns" in snapshot: + os.utime(path, ns=(snapshot["mtime_ns"], snapshot["mtime_ns"])) + else: + path.unlink(missing_ok=True) + + +def _begin( + paths: list[Path], *, cache_roots: list[Path] | None = None +) -> dict[str, Any]: + cache_snapshots = [] + for cache_root in cache_roots or []: + existing = ( + [ + _snapshot(path.absolute()) + for path in cache_root.rglob("*") + if path.is_file() + ] + if cache_root.exists() + else [] + ) + cache_snapshots.append({"root": str(cache_root.absolute()), "files": existing}) + journal = { + "schema_version": 1, + "started_at": _state.iso_now(), + "files": [_snapshot(path) for path in paths], + "safe_to_rollback": True, + "ecr_created": [], + "cache_snapshots": cache_snapshots, + } + _state.atomic_write( + _journal_path(), (json.dumps(journal, indent=2) + "\n").encode() + ) + return journal + + +def _record_ecr_in_journal(journal: dict[str, Any], engine: str, registry: str) -> None: + """Persist each ECR side effect immediately for crash recovery.""" + journal["ecr_engine"] = engine + journal.setdefault("ecr_created", []).append(registry) + _state.atomic_write( + _journal_path(), (json.dumps(journal, indent=2) + "\n").encode() + ) + + +def _rollback(journal: dict[str, Any]) -> None: + failures: list[str] = [] + engine = journal.get("ecr_engine") + if engine: + for registry in reversed(journal.get("ecr_created", [])): + try: + _ecr._run_container_engine( + engine, [engine, "logout", registry], check=False + ) + except _configs.OperationalError as error: + failures.append(f"ECR {registry}: {error}") + for snapshot in journal.get("cache_snapshots", []): + cache_root = Path(snapshot["root"]).absolute() + before = {item["path"] for item in snapshot.get("files", [])} + if cache_root.exists(): + for cache_file in cache_root.rglob("*"): + if cache_file.is_file() and str(cache_file.absolute()) not in before: + try: + cache_file.unlink() + except OSError as error: + failures.append(f"cache {cache_file}: {error}") + for cache_file_snapshot in snapshot.get("files", []): + try: + _restore(cache_file_snapshot) + except OSError as error: + failures.append(f"cache {cache_file_snapshot['path']}: {error}") + for snapshot in reversed(journal["files"]): + try: + _restore(snapshot) + except OSError as error: + failures.append(f"{snapshot['path']}: {error}") + if failures: + raise _configs.OperationalError( + "Login failed and automatic recovery was incomplete. Restore these files " + f"from {_journal_path()}: {'; '.join(failures)}" + ) + _journal_path().unlink(missing_ok=True) + + +def recover_journal() -> None: + """Roll back a safely recoverable interrupted transaction before every command.""" + path = _journal_path() + if not path.exists(): + return + try: + journal = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise _configs.OperationalError( + f"An unreadable transaction journal remains at {path}; preserve it and restore affected AWS files manually: {error}" + ) from error + if not journal.get("safe_to_rollback") or not isinstance( + journal.get("files"), list + ): + raise _configs.OperationalError( + f"Transaction recovery is unsafe; inspect {path} and restore AWS files manually." + ) + _rollback(journal) + + +def _commit() -> None: + _journal_path().unlink(missing_ok=True) + + +def _read_ini(path: Path) -> configparser.ConfigParser: + parser = configparser.ConfigParser(interpolation=None) + if path.exists(): + try: + parser.read(path, encoding="utf-8") + except (OSError, configparser.Error) as error: + raise _configs.OperationalError( + f"Unable to parse AWS file {path}: {error}" + ) from error + return parser + + +def _write_ini(path: Path, parser: configparser.ConfigParser) -> None: + import io + + stream = io.StringIO() + parser.write(stream) + _state.atomic_write(path, stream.getvalue().encode()) + + +def _section(profile: str, *, config: bool) -> str: + return profile if not config or profile == "default" else f"profile {profile}" + + +def _partition(arn: str) -> str: + parts = arn.split(":", 2) + if len(parts) < 2 or parts[0] != "arn" or parts[1] not in _state.PARTITIONS: + raise _configs.OperationalError(f"AWS returned an invalid ARN {arn!r}.") + return parts[1] + + +def _identity(session: Any, *, label: str) -> tuple[str, str, str]: + try: + response = session.client("sts").get_caller_identity() + account = str(response["Account"]) + arn = str(response["Arn"]) + except (BotoCoreError, ClientError, KeyError) as error: + raise _configs.OperationalError( + f"Unable to verify {label} with GetCallerIdentity: {error}" + ) from error + if not re.fullmatch(r"\d{12}", account): + raise _configs.OperationalError( + f"AWS returned invalid account {account!r} for {label}." + ) + return account, _partition(arn), arn + + +def _paths(args: Any) -> tuple[Path, str, Path, str]: + """Resolve secure preset or raw source/destination.""" + if getattr(args, "target", None): + data = _state.load_config() + target_name = args.target.lstrip("+") + _, target = _state.get_resource(data, "target", target_name) + source_dir = ( + Path(target["source_directory"]) + if target.get("source_directory") + else _state.aws_directory(target.get("source_location")) + ) + source_profile = str(target.get("source_profile", "default")) + if target.get("destination_directory"): + destination_dir = Path(target["destination_directory"]) + elif target.get("destination_location"): + destination_dir = _state.aws_directory(target["destination_location"]) + else: + destination_dir = source_dir + destination_profile = str(target.get("destination_profile", source_profile)) + return source_dir, source_profile, destination_dir, destination_profile + source = Path(args.directory).expanduser().absolute() + profile = args.profile or "default" + if getattr(args, "aws_account_name", None): + source = _state.aws_directory(args.aws_account_name) + if getattr(args, "to", None): + location, separator, destination_profile = args.to.partition(":") + if not separator or not destination_profile: + raise _configs.OperationalError("--to must be LOCATION:PROFILE.") + return source, profile, _state.aws_directory(location), destination_profile + if getattr(args, "to_directory", None): + return ( + source, + profile, + Path(args.to_directory).expanduser().absolute(), + args.to_profile, + ) + return source, profile, source, profile + + +def _target_details(args: Any, source_account: str, partition: str) -> dict[str, Any]: + data = _state.load_config() + target: dict[str, Any] = {} + if args.target: + target_name, target = _state.get_resource( + data, "target", args.target.lstrip("+") + ) + target = dict(target) + target["target_name"] = target_name + account_name, account = _state.get_resource( + data, "account", str(target["source_account"]) + ) + if account["id"] != source_account or account["partition"] != partition: + raise _configs.OperationalError( + f"Source identity is {partition}:{source_account}, but target {target_name!r} requires {account['partition']}:{account['id']}." + ) + boundary_name = args.boundary or target.get("boundary") + if boundary_name: + canonical, boundary = _state.get_resource(data, "boundary", boundary_name) + target["boundary_name"] = canonical + target["boundary_data"] = boundary + return target + + +def _role_details( + args: Any, target: dict[str, Any], source_account: str, partition: str +) -> tuple[str | None, str | None, str | None, str | None]: + boundary = target.get("boundary_data", {}) + role = args.role or boundary.get("role_arn") + policy = args.policy or boundary.get("policy") + external_id = args.external_id or boundary.get("external_id") + account_name = args.account + if role and not str(role).startswith("arn:"): + account_id = source_account + role_partition = partition + if account_name: + data = _state.load_config() + _, account = _state.get_resource(data, "account", account_name) + account_id, role_partition = account["id"], account["partition"] + role = f"arn:{role_partition}:iam::{account_id}:role/{role}" + if role: + match = re.fullmatch( + r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/(.+)", str(role) + ) + if not match: + raise _configs.OperationalError(f"Invalid role ARN {role!r}.") + if account_name: + data = _state.load_config() + _, selected = _state.get_resource(data, "account", account_name) + if ( + match.group(1) != selected["partition"] + or match.group(2) != selected["id"] + ): + raise _configs.OperationalError( + "Explicit role ARN account/partition conflicts with --account." + ) + if boundary: + data = _state.load_config() + _, configured = _state.get_resource(data, "account", boundary["account"]) + if ( + match.group(1) != configured["partition"] + or match.group(2) != configured["id"] + ): + raise _configs.OperationalError( + "Boundary role account/partition conflicts with its configured account." + ) + return role, policy, external_id, target.get("boundary_name") + + +def _require_concrete_role(args: Any, role: str | None) -> None: + """Fail before authentication when role-only operands have no concrete role.""" + operands = { + "--policy": getattr(args, "policy", None), + "--duration/--ttl": getattr(args, "duration", None), + "--htl": getattr(args, "htl", None), + "--mtl": getattr(args, "mtl", None), + "--stl": getattr(args, "stl", None), + "--account": getattr(args, "account", None), + "--external-id": getattr(args, "external_id", None), + "--session-name": getattr(args, "session_name", None), + } + supplied = [name for name, value in operands.items() if value is not None] + if supplied and role is None: + raise _configs.OperationalError( + f"{', '.join(supplied)} require a concrete role or boundary; the " + "selected target is unbounded." + ) + + +def _configured_role_before_auth(args: Any) -> str | None: + """Resolve only local target/boundary role configuration before browser auth.""" + if getattr(args, "role", None): + return str(args.role) + data = _state.load_config() + boundary_name = getattr(args, "boundary", None) + if getattr(args, "target", None): + _, target = _state.get_resource(data, "target", args.target.lstrip("+")) + boundary_name = boundary_name or target.get("boundary") + if not boundary_name: + return None + _, boundary = _state.get_resource(data, "boundary", boundary_name) + return str(boundary["role_arn"]) + + +def _session_name(role: str, boundary: str | None, override: str | None) -> str: + raw = ( + override + or f"hacksaws-{getpass.getuser()}-{boundary or role.rsplit('/', 1)[-1]}" + ) + cleaned = re.sub(r"[^A-Za-z0-9+=,.@_-]", "-", raw).strip("-")[:64] + if len(cleaned) < 2: + cleaned = f"hacksaws-{cleaned or 'session'}" + return cleaned[:64] + + +def _duration_for(args: Any, target: dict[str, Any], *, chained: bool) -> int: + configured = target.get("boundary_data", {}).get("duration", 3600) + duration = _duration.session_duration( + duration=args.duration, + htl=args.htl, + mtl=args.mtl, + stl=args.stl, + default=int(configured), + ) + if duration < 900: + raise _configs.OperationalError( + "AWS boundary sessions require at least 900 seconds." + ) + if chained and duration > 3600: + raise _configs.OperationalError( + "AWS role chaining permits at most 3600 seconds." + ) + return duration + + +def _assume( + session: Any, + role: str, + *, + policy: str | None, + source_profile: str, + args: Any, + target: dict[str, Any], + external_id: str | None, + boundary_name: str | None, +) -> tuple[dict[str, Any], dict[str, Any]]: + match = re.fullmatch(r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/.+", role) + if not match: + raise _configs.OperationalError(f"Invalid role ARN {role!r}.") + credentials = session.get_credentials() + chained = bool(credentials and credentials.token) + duration = _duration_for(args, target, chained=chained) + role_name = role.split("role/", 1)[-1] + try: + maximum = int( + session.client("iam").get_role(RoleName=role_name)["Role"][ + "MaxSessionDuration" + ] + ) + except (BotoCoreError, ClientError, KeyError, TypeError, ValueError): + maximum = None + if maximum is not None and duration > maximum: + raise _configs.OperationalError( + f"Requested boundary duration {duration} seconds exceeds role " + f"MaxSessionDuration {maximum} seconds." + ) + request: dict[str, Any] = { + "RoleArn": role, + "RoleSessionName": _session_name(role, boundary_name, args.session_name), + "DurationSeconds": duration, + } + resolved = None + if policy: + resolved = _policies.resolve( + policy, + account_id=match.group(2), + partition=match.group(1), + profile=source_profile, + session=session, + ) + if resolved.arn: + request["PolicyArns"] = [{"arn": resolved.arn}] + elif resolved.document: + request["Policy"] = resolved.document + if external_id: + request["ExternalId"] = external_id + try: + response = session.client("sts").assume_role(**request) + except (BotoCoreError, ClientError) as error: + raise _configs.OperationalError( + f"Unable to assume boundary role {role}: {error}" + ) from error + final_session = boto3.Session( + aws_access_key_id=response["Credentials"]["AccessKeyId"], + aws_secret_access_key=response["Credentials"]["SecretAccessKey"], + aws_session_token=response["Credentials"]["SessionToken"], + ) + account, final_partition, _ = _identity(final_session, label="boundary credentials") + if account != match.group(2) or final_partition != match.group(1): + raise _configs.OperationalError( + f"Boundary identity mismatch: expected {match.group(1)}:{match.group(2)}, got {final_partition}:{account}." + ) + metadata = { + "target_account": account, + "role": role, + "boundary": boundary_name, + "policy": resolved.identity if resolved else None, + "policy_provenance": resolved.provenance if resolved else None, + "expires_at": response["Credentials"]["Expiration"].astimezone(UTC).isoformat(), + } + return response["Credentials"], metadata + + +def _save_credentials(path: Path, profile: str, credentials: dict[str, Any]) -> None: + parser = _read_ini(path) + parser[profile] = { + "aws_access_key_id": credentials["AccessKeyId"], + "aws_secret_access_key": credentials["SecretAccessKey"], + "aws_session_token": credentials["SessionToken"], + } + _write_ini(path, parser) + + +def _copy_region( + source_config: Path, + source_profile: str, + destination_config: Path, + destination_profile: str, + explicit: str | None, +) -> None: + source = _read_ini(source_config) + destination = _read_ini(destination_config) + source_section = _section(source_profile, config=True) + destination_section = _section(destination_profile, config=True) + if destination_section not in destination: + destination.add_section(destination_section) + if explicit: + destination[destination_section]["region"] = explicit + else: + for key in ("region", "output"): + if key not in destination[destination_section] and source.has_option( + source_section, key + ): + destination[destination_section][key] = source[source_section][key] + _write_ini(destination_config, destination) + + +def _record( + destination: Path, + profile: str, + metadata: dict[str, Any], + journal: dict[str, Any], + *, + method: str, + ecr: list[str] | None = None, +) -> None: + sessions = _state.load_sessions() + key = f"{destination.absolute()}::{profile}" + previous = sessions.get(key) + original_backup = ( + previous.get("backup") or journal["files"] if previous else journal["files"] + ) + previous_ecr = previous.get("ecr", []) if previous else [] + previous_cache = previous.get("login_cache_files", []) if previous else [] + current_cache = metadata.get("login_cache_files", []) + if previous_cache or current_cache: + metadata["login_cache_files"] = list( + dict.fromkeys([*previous_cache, *current_cache]) + ) + sessions[key] = { + **metadata, + "destination": str(destination.absolute()), + "profile": profile, + "auth_method": method, + "started_at": _state.iso_now(), + "backup": original_backup, + "ecr": list(dict.fromkeys([*previous_ecr, *(ecr or [])])), + } + _state.save_sessions(sessions) + + +def _original_file(path: Path, profile: str) -> bytes | None: + """Read an active session's original file snapshot when this is its destination.""" + key = f"{path.parent.absolute()}::{profile}" + session = _state.load_sessions().get(key) + if session: + for snapshot in session.get("backup", []): + if Path(snapshot["path"]).absolute() == path.absolute(): + return ( + base64.b64decode(snapshot["data"]) + if snapshot.get("exists") + else None + ) + return path.read_bytes() if path.exists() else None + + +def _parser_from_bytes(value: bytes | None, path: Path) -> configparser.ConfigParser: + parser = configparser.ConfigParser(interpolation=None) + if value is not None: + try: + parser.read_string(value.decode("utf-8"), source=str(path)) + except (UnicodeError, configparser.Error) as error: + raise _configs.OperationalError( + f"Unable to parse original AWS file {path}: {error}" + ) from error + return parser + + +def _persistent_source( + source_dir: Path, profile: str +) -> tuple[Any, configparser.ConfigParser]: + """Build a session from original persistent credentials, never installed output.""" + credentials_path = source_dir / "credentials" + config_path = source_dir / "config" + credentials = _parser_from_bytes( + _original_file(credentials_path, profile), credentials_path + ) + config = _parser_from_bytes(_original_file(config_path, profile), config_path) + if profile not in credentials: + raise _configs.OperationalError( + f"Persistent source profile {profile!r} is missing from {credentials_path}." + ) + values = credentials[profile] + if "aws_access_key_id" not in values or "aws_secret_access_key" not in values: + raise _configs.OperationalError( + f"Persistent source profile {profile!r} must contain readable access keys " + "for transactional MFA re-login." + ) + config_section = _section(profile, config=True) + region = config.get(config_section, "region", fallback=None) + source = boto3.Session( + aws_access_key_id=values["aws_access_key_id"], + aws_secret_access_key=values["aws_secret_access_key"], + aws_session_token=values.get("aws_session_token"), + region_name=region, + ) + return source, config + + +def _mfa_session( + source: Any, + config: configparser.ConfigParser, + profile: str, + token: str, + lifespan: int, +) -> Any: + section = _section(profile, config=True) + if not config.has_option(section, "mfa_serial"): + raise _configs.OperationalError( + f"Profile {profile!r} does not define mfa_serial." + ) + try: + response = source.client("sts").get_session_token( + DurationSeconds=lifespan, + SerialNumber=config[section]["mfa_serial"], + TokenCode=token, + ) + except (BotoCoreError, ClientError) as error: + raise _configs.OperationalError( + f"Unable to start MFA session for {profile!r}: {error}" + ) from error + values = response["Credentials"] + return boto3.Session( + aws_access_key_id=values["AccessKeyId"], + aws_secret_access_key=values["SecretAccessKey"], + aws_session_token=values["SessionToken"], + region_name=source.region_name, + ) + + +def mfa_login(context: _configs.Context) -> _configs.Result: + """Authenticate with MFA and transactionally persist only the final tier.""" + args = context.args + source_dir, source_profile, destination_dir, destination_profile = _paths(args) + raw, source_config = _persistent_source(source_dir, source_profile) + source_account, partition, _ = _identity(raw, label="MFA source credentials") + target = _target_details(args, source_account, partition) + role, policy, external_id, boundary_name = _role_details( + args, target, source_account, partition + ) + _require_concrete_role(args, role) + intermediate = _mfa_session( + raw, source_config, source_profile, args.mfa_code, args.lifespan + ) + journal = _begin( + [ + destination_dir / "credentials", + destination_dir / "config", + _state.sessions_path(), + ] + ) + ecr_registries = [] + try: + if args.ecr: + account = _configs.AwsAccount( + identity_response={ + "Account": source_account, + "Arn": f"arn:{partition}:iam::{source_account}:user/hacksaws", + }, + region_name=intermediate.region_name or args.region or "us-east-1", + ecr_additional_regions=tuple(args.ecr_region or ()), + ) + ecr_registries = _ecr.login_with_session( + context, + account, + intermediate, + on_success=lambda registry: _record_ecr_in_journal( + journal, context.container_engine, registry + ), + ) + if role: + credentials, metadata = _assume( + intermediate, + role, + policy=policy, + source_profile=source_profile, + args=args, + target=target, + external_id=external_id, + boundary_name=boundary_name, + ) + else: + frozen = intermediate.get_credentials().get_frozen_credentials() + credentials = { + "AccessKeyId": frozen.access_key, + "SecretAccessKey": frozen.secret_key, + "SessionToken": frozen.token, + } + metadata = { + "target_account": source_account, + "role": None, + "boundary": None, + "policy": None, + "policy_provenance": None, + "expires_at": None, + } + _save_credentials( + destination_dir / "credentials", destination_profile, credentials + ) + _copy_region( + source_dir / "config", + source_profile, + destination_dir / "config", + destination_profile, + args.region, + ) + metadata["source_account"] = source_account + metadata["target"] = target.get("target_name") + _record( + destination_dir, + destination_profile, + metadata, + journal, + method="mfa", + ecr=ecr_registries, + ) + _commit() + except Exception: + _rollback(journal) + raise + return _configs.Result("MFA_LOGIN", f"Logged into profile {destination_profile}") + + +def _aws_cli_version() -> tuple[int, int, int]: + try: + result = subprocess.run( + ["aws", "--version"], + capture_output=True, + text=True, + check=True, + env=_clean_env(), + ) + except (FileNotFoundError, OSError, subprocess.CalledProcessError) as error: + raise _configs.OperationalError( + f"AWS CLI v2.32.0 or newer is required for browser login: {error}" + ) from error + match = _AWS_VERSION.search(result.stdout + result.stderr) + if ( + not match + or int(match.group(1)) != 2 + or tuple(map(int, match.groups())) < (2, 32, 0) + ): + found = match.group(0) if match else "unknown version" + raise _configs.OperationalError( + f"AWS CLI v2.32.0 or newer is required; found {found}." + ) + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def _clean_env( + config: Path | None = None, credentials: Path | None = None +) -> dict[str, str]: + env = { + key: value for key, value in os.environ.items() if key not in _CONFLICTING_ENV + } + if config: + env["AWS_CONFIG_FILE"] = str(config) + if credentials: + env["AWS_SHARED_CREDENTIALS_FILE"] = str(credentials) + return env + + +@contextmanager +def _aws_environment(config: Path, credentials: Path) -> Iterator[None]: + """Temporarily scrub inherited AWS identity/path variables for one staging area.""" + previous = {key: os.environ.get(key) for key in _CONFLICTING_ENV} + for key in _CONFLICTING_ENV: + os.environ.pop(key, None) + os.environ["AWS_CONFIG_FILE"] = str(config) + os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(credentials) + try: + yield + finally: + for key in _CONFLICTING_ENV: + value = previous[key] + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _aws_login(config: Path, credentials: Path, profile: str, *, remote: bool) -> None: + _aws_cli_version() + config.parent.mkdir(parents=True, exist_ok=True) + command = ["aws", "login", "--profile", profile] + if remote: + command.append("--remote") + try: + subprocess.run(command, check=True, env=_clean_env(config, credentials)) + except (FileNotFoundError, OSError, subprocess.CalledProcessError) as error: + raise _configs.OperationalError(f"AWS browser login failed: {error}") from error + + +def browser_login(context: _configs.Context) -> _configs.Result: + """Run AWS-native browser login or isolate it before a role boundary.""" + args = context.args + source_dir, source_profile, destination_dir, destination_profile = _paths(args) + # Determine role from config without requiring caller identity first. + configured_role = _configured_role_before_auth(args) + _require_concrete_role(args, configured_role) + has_boundary = configured_role is not None + if not has_boundary: + native_cache = destination_dir / "login" / "cache" + journal = _begin( + [ + destination_dir / "config", + destination_dir / "credentials", + _state.sessions_path(), + ], + cache_roots=[native_cache], + ) + ecr_registries: list[str] = [] + try: + _aws_login( + destination_dir / "config", + destination_dir / "credentials", + destination_profile, + remote=args.remote, + ) + with _aws_environment( + destination_dir / "config", destination_dir / "credentials" + ): + native = boto3.Session(profile_name=destination_profile) + account, partition, _ = _identity(native, label="browser login") + cache_before = { + item["path"] for item in journal["cache_snapshots"][0]["files"] + } + cache_after = ( + { + str(path.absolute()) + for path in native_cache.rglob("*") + if path.is_file() + } + if native_cache.exists() + else set() + ) + target = _target_details(args, account, partition) + if args.ecr: + aws_account = _configs.AwsAccount( + { + "Account": account, + "Arn": f"arn:{partition}:iam::{account}:user/hacksaws", + }, + native.region_name or args.region or "us-east-1", + tuple(args.ecr_region or ()), + ) + ecr_registries = _ecr.login_with_session( + context, + aws_account, + native, + on_success=lambda registry: _record_ecr_in_journal( + journal, context.container_engine, registry + ), + ) + _record( + destination_dir, + destination_profile, + { + "source_account": account, + "target_account": account, + "target": target.get("target_name"), + "role": None, + "boundary": None, + "policy": None, + "policy_provenance": "AWS-native login_session", + "expires_at": None, + "login_cache_files": sorted(cache_after - cache_before), + }, + journal, + method="browser-native", + ecr=ecr_registries, + ) + _commit() + except Exception: + _rollback(journal) + raise + return _configs.Result( + "BROWSER_LOGIN", + f"AWS-native browser login active for profile {destination_profile}.", + ) + + staging = _state.root() / "staging" / uuid.uuid4().hex + staging_config = staging / "config" + staging_credentials = staging / "credentials" + journal = _begin( + [ + destination_dir / "config", + destination_dir / "credentials", + _state.sessions_path(), + ], + cache_roots=[staging], + ) + ecr_registries = [] + try: + _aws_login( + staging_config, staging_credentials, source_profile, remote=args.remote + ) + env = _clean_env(staging_config, staging_credentials) + old = { + key: os.environ.get(key) + for key in ("AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE") + } + os.environ.update( + { + key: env[key] + for key in ("AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE") + } + ) + try: + intermediate = boto3.Session(profile_name=source_profile) + source_account, partition, _ = _identity( + intermediate, label="browser staging login" + ) + target = _target_details(args, source_account, partition) + role, policy, external_id, boundary_name = _role_details( + args, target, source_account, partition + ) + if not role: + raise _configs.OperationalError( + "Bounded browser login requires a role." + ) + if args.ecr: + aws_account = _configs.AwsAccount( + { + "Account": source_account, + "Arn": (f"arn:{partition}:iam::{source_account}:user/hacksaws"), + }, + intermediate.region_name or args.region or "us-east-1", + tuple(args.ecr_region or ()), + ) + ecr_registries = _ecr.login_with_session( + context, + aws_account, + intermediate, + on_success=lambda registry: _record_ecr_in_journal( + journal, context.container_engine, registry + ), + ) + credentials, metadata = _assume( + intermediate, + role, + policy=policy, + source_profile=source_profile, + args=args, + target=target, + external_id=external_id, + boundary_name=boundary_name, + ) + finally: + for key, value in old.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + _save_credentials( + destination_dir / "credentials", destination_profile, credentials + ) + _copy_region( + staging_config, + source_profile, + destination_dir / "config", + destination_profile, + args.region, + ) + config = _read_ini(destination_dir / "config") + section = _section(destination_profile, config=True) + if section in config: + config[section].pop("login_session", None) + _write_ini(destination_dir / "config", config) + metadata.update(source_account=source_account, target=target.get("target_name")) + _record( + destination_dir, + destination_profile, + metadata, + journal, + method="browser-boundary", + ecr=ecr_registries, + ) + _commit() + except Exception: + _rollback(journal) + raise + finally: + shutil.rmtree(staging, ignore_errors=True) + return _configs.Result( + "BROWSER_LOGIN", + f"Bounded browser login active for profile {destination_profile}.", + ) + + +def logout(context: _configs.Context) -> bool: + """Restore an expanded session locally, making no AWS logout call.""" + _source, _source_profile, destination, profile = _paths(context.args) + sessions = _state.load_sessions() + key = f"{destination.absolute()}::{profile}" + session = sessions.get(key) + if not session: + return False + for snapshot in reversed(session.get("backup", [])): + if Path(snapshot["path"]) == _state.sessions_path(): + continue + _restore(snapshot) + if session.get("auth_method") == "browser-native": + allowed_root = (destination / "login" / "cache").absolute() + for value in session.get("login_cache_files", []): + cache_file = Path(value).absolute() + if allowed_root in cache_file.parents: + cache_file.unlink(missing_ok=True) + if context.args.ecr: + for registry in session.get("ecr", []): + _ecr._run_container_engine( + context.container_engine, [context.container_engine, "logout", registry] + ) + del sessions[key] + elif session.get("ecr"): + sessions[key] = { + "destination": str(destination.absolute()), + "profile": profile, + "auth_method": "ecr-only", + "started_at": session.get("started_at"), + "backup": [], + "ecr": session["ecr"], + } + else: + del sessions[key] + _state.save_sessions(sessions) + return True + + +def status() -> list[dict[str, Any]]: + """Return secret-free active session status.""" + now = datetime.now(UTC) + result = [] + for session in _state.load_sessions().values(): + public = {key: value for key, value in session.items() if key != "backup"} + expiry = public.get("expires_at") + if expiry: + try: + public["remaining_seconds"] = max( + 0, int((datetime.fromisoformat(expiry) - now).total_seconds()) + ) + except ValueError: + public["remaining_seconds"] = None + result.append(public) + return result + + +def explain_target(value: str) -> dict[str, Any]: + """Resolve a target without authenticating.""" + data = _state.load_config() + name, target = _state.get_resource(data, "target", value.lstrip("+")) + account_name, account = _state.get_resource( + data, "account", target["source_account"] + ) + source_directory = target.get("source_directory") or str( + _state.aws_directory(target.get("source_location")) + ) + destination_directory = target.get("destination_directory") + if not destination_directory: + destination_directory = ( + str(_state.aws_directory(target["destination_location"])) + if target.get("destination_location") + else source_directory + ) + result: dict[str, Any] = { + "target": name, + "source": { + "account": account_name, + "account_id": account["id"], + "partition": account["partition"], + "profile": target.get("source_profile", "default"), + "directory": source_directory, + }, + "destination": { + "profile": target.get( + "destination_profile", target.get("source_profile", "default") + ), + "directory": destination_directory, + }, + "cache_max_age": data["cache"]["max_age"], + } + if target.get("boundary"): + boundary_name, boundary = _state.get_resource( + data, "boundary", target["boundary"] + ) + result["boundary"] = { + "name": boundary_name, + **boundary, + "duration": boundary.get("duration", 3600), + } + else: + result["boundary"] = None + return result + + +def check_config(args: Any) -> dict[str, Any]: + """Run config checks without leaking credential environment mutations.""" + previous = {key: os.environ.get(key) for key in _CONFLICTING_ENV} + try: + return _check_config(args) + finally: + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _check_config(args: Any) -> dict[str, Any]: + """Check local integrity and optionally verify account-scoped remote resources.""" + errors: list[str] = [] + warnings: list[str] = [] + try: + data = _state.load_config() + except _configs.OperationalError as error: + return {"ok": False, "errors": [str(error)], "warnings": []} + for name in data["policies"]: + try: + _policies.parse_policy(_policies.stored_directory() / f"{name}.yaml") + except _configs.OperationalError as error: + errors.append(str(error)) + if args.remote or args.probe: + profile = args.profile or "default" + if args.target: + target_name, target = _state.get_resource( + data, "target", args.target.lstrip("+") + ) + profile = target.get("source_profile", "default") + source_directory = ( + Path(target["source_directory"]) + if target.get("source_directory") + else _state.aws_directory(target.get("source_location")) + ) + os.environ["AWS_CONFIG_FILE"] = str(source_directory / "config") + os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str( + source_directory / "credentials" + ) + warnings.append(f"Using target {target_name} credential source.") + try: + check_session = boto3.Session(profile_name=profile) + account, partition, _ = _identity(check_session, label="config check") + if args.account: + _, configured = _state.get_resource(data, "account", args.account) + if configured["id"] != account or configured["partition"] != partition: + errors.append( + f"Selected account does not match caller {partition}:{account}." + ) + iam = check_session.client("iam") + scoped_boundaries: list[tuple[str, dict[str, Any]]] = [] + for name, boundary in data["boundaries"].items(): + _, configured = _state.get_resource( + data, "account", boundary["account"] + ) + if configured["id"] != account or configured["partition"] != partition: + continue + scoped_boundaries.append((name, boundary)) + role_name = boundary["role_arn"].split("role/", 1)[-1] + try: + iam.get_role(RoleName=role_name) + except ClientError as error: + code = error.response.get("Error", {}).get("Code") + label = "missing" if code == "NoSuchEntity" else "unverifiable" + errors.append(f"Boundary {name}: {label} ({error}).") + except BotoCoreError as error: + errors.append(f"Boundary {name}: unverifiable ({error}).") + if args.probe: + deny_all = json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Action": "*", + "Resource": "*", + } + ], + }, + separators=(",", ":"), + ) + sts = check_session.client("sts") + for name, boundary in scoped_boundaries: + request: dict[str, Any] = { + "RoleArn": boundary["role_arn"], + "RoleSessionName": _session_name( + boundary["role_arn"], name, "hacksaws-config-probe" + ), + "DurationSeconds": 900, + "Policy": deny_all, + } + if boundary.get("external_id"): + request["ExternalId"] = boundary["external_id"] + try: + sts.assume_role(**request) + except (BotoCoreError, ClientError) as error: + errors.append(f"Boundary {name} probe failed: {error}") + else: + warnings.append( + f"Boundary {name} deny-all AssumeRole probe succeeded; " + "credentials were discarded." + ) + except _configs.OperationalError as error: + errors.append(f"Remote resources unverifiable: {error}") + return {"ok": not errors, "errors": errors, "warnings": warnings} + + +def fix_config(args: Any) -> _configs.Result: + """Back up, then interactively repair/leave/remove local non-security issues.""" + data = _state.load_config() + scope_message = "" + if args.account: + account_name, _ = _state.get_resource(data, "account", args.account) + scope_message = f" for account {account_name}" + path = _state.root() / "config.json" + if path.exists(): + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + backup = _state.root() / "backups" / f"config-{stamp}.json" + _state.atomic_write(backup, path.read_bytes()) + issues: list[tuple[str, str, str]] = [] + scoped_policies: set[str] | None = None + if args.account: + scoped_policies = { + str(boundary["policy"]) + for boundary in data["boundaries"].values() + if str(boundary["account"]).casefold() == account_name.casefold() + and boundary.get("policy") + } + for name in data["policies"]: + if scoped_policies is not None and not any( + name.casefold() == policy.casefold() for policy in scoped_policies + ): + continue + try: + _policies.parse_policy(_policies.stored_directory() / f"{name}.yaml") + except _configs.OperationalError as error: + issues.append(("policy", name, str(error))) + unresolved: list[str] = [] + for kind, name, message in issues: + if args.yes or not sys.stdin.isatty(): + unresolved.append(message) + continue + dependents = _state.references(data, kind, name) + answer = ( + input( + f"Issue {kind}:{name}: {message}\nDependents: " + f"{', '.join(dependents) or '(none)'}\n" + "Choose repair, leave, or remove [r/l/x]: " + ) + .strip() + .casefold() + ) + if answer in {"r", "repair"}: + replacement = Path( + input("Replacement policy file path: ").strip() + ).expanduser() + try: + document, raw = _policies.parse_policy(replacement) + content = ( + raw + if replacement.suffix.lower() in {".yaml", ".yml"} + else yaml.safe_dump(document, sort_keys=False).encode() + ) + _state.atomic_write( + _policies.stored_directory() / f"{name}.yaml", content + ) + except _configs.OperationalError as error: + unresolved.append(f"Repair for policy {name!r} failed: {error}") + elif answer in {"x", "remove"} and not dependents: + del data[_state.collection_name(kind)][name] + (_policies.stored_directory() / f"{name}.yaml").unlink(missing_ok=True) + else: + unresolved.append(message) + _state.save_config(data) + if unresolved: + return _configs.Result( + "CONFIG_FIX_UNRESOLVED", + f"Configuration normalized{scope_message}; {len(unresolved)} issue(s) " + "remain unresolved and no security references were weakened.", + exit_code=1, + stream="stderr", + ) + return _configs.Result( + "CONFIG_FIX", + f"Configuration normalized{scope_message}; all local issues are resolved.", + ) + + +def export_config(destination: str | None) -> Path: + """Create a safe portable archive excluding credentials, sessions, and caches.""" + data = _state.load_config() + portable = json.loads(json.dumps(data)) + output = ( + Path(destination).expanduser().absolute() + if destination + else Path.cwd() / "hacksaws-config.zip" + ) + manifest: dict[str, Any] = {"schema_version": 1, "files": {}} + files: dict[str, bytes] = {} + for name in data["policies"]: + path = _policies.stored_directory() / f"{name}.yaml" + files[f"stored_session_policies/{name}.yaml"] = path.read_bytes() + for boundary in portable["boundaries"].values(): + policy = boundary.get("policy") + if not policy or any( + name.casefold() == str(policy).casefold() for name in data["policies"] + ): + continue + external = Path(policy).expanduser().absolute() + if not external.is_file(): + raise _configs.OperationalError( + f"Referenced external policy does not exist: {external}" + ) + member = ( + f"external_policies/{_state.digest(str(external).casefold().encode())[:12]}" + f"-{external.name}" + ) + files[member] = external.read_bytes() + boundary["policy"] = member + files["config.json"] = (json.dumps(portable, indent=2) + "\n").encode() + for name, content in files.items(): + manifest["files"][name] = _state.digest(content) + files["manifest.json"] = (json.dumps(manifest, indent=2) + "\n").encode() + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, content in files.items(): + archive.writestr(name, content) + return output + + +def import_config(source: Path, *, replace: bool, yes: bool) -> str: + """Validate entirely in memory, preview, then atomically merge an archive.""" + try: + with zipfile.ZipFile(source) as archive: + names = archive.namelist() + if len(names) != len(set(names)): + raise _configs.OperationalError("Archive contains duplicate members.") + if any( + Path(name).is_absolute() or ".." in Path(name).parts or "\\" in name + for name in names + ): + raise _configs.OperationalError("Archive contains an unsafe path.") + if "manifest.json" not in names or "config.json" not in names: + raise _configs.OperationalError( + "Archive is missing manifest.json or config.json." + ) + manifest = json.loads(archive.read("manifest.json")) + if ( + type(manifest) is not dict + or set(manifest) != {"schema_version", "files"} + or type(manifest.get("schema_version")) is not int + or manifest["schema_version"] != 1 + or type(manifest.get("files")) is not dict + ): + raise _configs.OperationalError("Archive manifest schema is invalid.") + if any( + type(name) is not str + or type(digest) is not str + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + for name, digest in manifest["files"].items() + ): + raise _configs.OperationalError( + "Archive manifest file names and SHA-256 digests must be exact strings." + ) + if "config.json" not in manifest["files"]: + raise _configs.OperationalError( + "Archive manifest does not declare config.json." + ) + config_bytes = archive.read("config.json") + if _state.digest(config_bytes) != manifest["files"]["config.json"]: + raise _configs.OperationalError( + "Archive checksum failed for config.json." + ) + imported = _state._validate_config(json.loads(config_bytes)) + imported_policy_names = { + name.casefold(): name for name in imported["policies"] + } + external_references: set[str] = set() + for boundary_name, boundary in imported["boundaries"].items(): + policy = boundary.get("policy") + if policy is None: + continue + if policy.casefold() in imported_policy_names: + continue + if ( + policy.startswith("external_policies/") + and "\\" not in policy + and ".." not in Path(policy).parts + and Path(policy).suffix.lower() + in {".json", ".yaml", ".yml", ".toml"} + ): + external_references.add(policy) + continue + raise _configs.OperationalError( + f"Imported boundary {boundary_name!r} has non-portable policy " + f"reference {policy!r}; only imported stored names or bundled " + "external_policies payloads are allowed." + ) + derived_payloads = {"config.json"} + derived_payloads.update( + f"stored_session_policies/{name}.yaml" for name in imported["policies"] + ) + derived_payloads.update(external_references) + manifest_payloads = set(manifest["files"]) + if manifest_payloads != derived_payloads: + extras = sorted(manifest_payloads - derived_payloads) + missing = sorted(derived_payloads - manifest_payloads) + raise _configs.OperationalError( + "Archive manifest contains payloads not referenced by config or " + f"omits required payloads; extras={extras}, missing={missing}." + ) + expected_names = {*derived_payloads, "manifest.json"} + if set(names) != expected_names: + extras = sorted(set(names) - expected_names) + missing = sorted(expected_names - set(names)) + raise _configs.OperationalError( + f"Archive member set differs from config; extras={extras}, " + f"missing={missing}." + ) + payloads = {name: archive.read(name) for name in derived_payloads} + for name, expected in manifest["files"].items(): + if ( + not isinstance(expected, str) + or _state.digest(payloads[name]) != expected + ): + raise _configs.OperationalError( + f"Archive checksum failed for {name}." + ) + policy_outputs: dict[str, bytes] = {} + for boundary in imported["boundaries"].values(): + policy = boundary.get("policy") + if not isinstance(policy, str) or not policy.startswith( + "external_policies/" + ): + continue + if policy not in payloads: + raise _configs.OperationalError( + f"Archive is missing external policy {policy}." + ) + raw = payloads[policy] + suffix = Path(policy).suffix.lower() + base = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(policy).stem) + promoted_name = f"imported-{base[:35]}-{_state.digest(raw)[:12]}"[:64] + document = _policies.parse_policy_bytes( + raw, kind=suffix.lstrip("."), source=policy + ) + if promoted_name.casefold() in imported_policy_names: + raise _configs.OperationalError( + f"External policy {policy!r} collides with imported stored " + f"policy {imported_policy_names[promoted_name.casefold()]!r}." + ) + policy_outputs[promoted_name] = yaml.safe_dump( + document, sort_keys=False + ).encode() + imported["policies"].setdefault( + promoted_name, + { + "file": f"stored_session_policies/{promoted_name}.yaml", + "description": f"Imported from {policy}", + }, + ) + boundary["policy"] = promoted_name + for name in imported["policies"]: + if name in policy_outputs: + continue + member = f"stored_session_policies/{name}.yaml" + if member not in payloads: + raise _configs.OperationalError( + f"Archive is missing stored policy {name}." + ) + policy_bytes = payloads[member] + _policies.parse_policy_bytes(policy_bytes, kind="yaml", source=member) + policy_outputs[name] = policy_bytes + imported = _state._validate_config(imported) + existing_config = _state.load_config() + current = copy.deepcopy(existing_config) + conflicts: list[str] = [] + for collection in ("accounts", "boundaries", "targets", "policies"): + for name, value in imported[collection].items(): + existing = next( + ( + key + for key in current[collection] + if key.casefold() == name.casefold() + ), + None, + ) + if existing and current[collection][existing] != value: + conflicts.append(f"{collection[:-1]}:{name}") + if not replace: + continue + del current[collection][existing] + current[collection][name] = value + for imported_name, imported_content in policy_outputs.items(): + existing_name = next( + ( + name + for name in existing_config["policies"] + if name.casefold() == imported_name.casefold() + ), + None, + ) + destination_path = _policies.stored_directory() / ( + f"{existing_name or imported_name}.yaml" + ) + if destination_path.exists(): + try: + existing_content = destination_path.read_bytes() + except OSError as error: + raise _configs.OperationalError( + f"Unable to inspect existing stored policy " + f"{existing_name or imported_name!r}: {error}" + ) from error + if _state.digest(existing_content) != _state.digest( + imported_content + ): + conflicts.append(f"policy-content:{imported_name}") + elif existing_name is not None: + conflicts.append(f"policy-content:{imported_name}") + conflicts = list(dict.fromkeys(conflicts)) + preview = ( + f"Import preview: {sum(len(imported[name]) for name in ('accounts', 'boundaries', 'targets', 'policies'))} " + f"resources; conflicts: {', '.join(conflicts) or '(none)'}." + ) + if conflicts and not replace: + raise _configs.OperationalError( + f"{preview} Conflicts require --replace." + ) + if conflicts and replace and not yes: + if not sys.stdin.isatty(): + raise _configs.OperationalError( + f"{preview} Noninteractive replacement requires --yes." + ) + answer = input(f"{preview} Replace these resources? [y/N] ").strip() + if answer.casefold() not in {"y", "yes"}: + raise _configs.OperationalError( + "Import cancelled; no files changed." + ) + _state._validate_config(current) + except (OSError, zipfile.BadZipFile, KeyError, json.JSONDecodeError) as error: + raise _configs.OperationalError( + f"Unable to import archive {source}: {error}" + ) from error + outputs = { + _policies.stored_directory() / f"{name}.yaml": content + for name, content in policy_outputs.items() + } + journal = _begin([_state.root() / "config.json", *outputs]) + try: + for path, content in outputs.items(): + _state.atomic_write(path, content) + _state.save_config(current) + _commit() + except Exception: + _rollback(journal) + raise + return f"{preview} Imported portable configuration." diff --git a/hacksaws/_state.py b/hacksaws/_state.py new file mode 100644 index 0000000..630e6c9 --- /dev/null +++ b/hacksaws/_state.py @@ -0,0 +1,501 @@ +"""Versioned Hacksaws configuration and local session state.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +from copy import deepcopy +from datetime import UTC +from datetime import datetime +from pathlib import Path +from typing import Any + +from hacksaws._configs import OperationalError + +SCHEMA_VERSION = 1 +NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +ROLE_ARN_RE = re.compile( + r"^arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/" + r"((?:[A-Za-z0-9_+=,.@-]+/)*[A-Za-z0-9_+=,.@-]{1,64})$" +) +PARTITIONS = {"aws", "aws-us-gov", "aws-cn"} +TOP_LEVEL = {"schema_version", "accounts", "boundaries", "targets", "policies", "cache"} + + +def collection_name(kind: str) -> str: + """Return the schema collection name for a singular resource kind.""" + return {"boundary": "boundaries", "policy": "policies"}.get(kind, f"{kind}s") + + +def root() -> Path: + """Return the one canonical Hacksaws state root.""" + override = os.environ.get("HACKSAWS_HOME") + return ( + Path(override).expanduser().absolute() + if override + else Path.home() / ".hacksaws" + ) + + +def default_config() -> dict[str, Any]: + """Return an empty schema-one configuration.""" + return { + "schema_version": SCHEMA_VERSION, + "accounts": {}, + "boundaries": {}, + "targets": {}, + "policies": {}, + "cache": {"max_age": 3600}, + } + + +def validate_name(value: str, *, kind: str = "resource") -> str: + """Validate a portable, case-insensitively unique resource name.""" + if type(value) is not str or not NAME_RE.fullmatch(value): + raise OperationalError( + f"Invalid {kind} name {value!r}; use 1-64 letters, digits, '.', '_', or " + "'-', beginning with a letter or digit." + ) + return value + + +def normalize_location(value: str | None) -> str: + """Normalize logical AWS locations.""" + if value is None or value in {".", "default"}: + return "default" + if type(value) is not str: + raise OperationalError("AWS location must be text.") + return validate_name(value, kind="AWS location") + + +def parse_role_arn(value: object) -> tuple[str, str, str]: + """Validate a canonical IAM role ARN and return partition/account/resource.""" + if type(value) is not str: + raise OperationalError("IAM role ARN must be text.") + match = ROLE_ARN_RE.fullmatch(value) + if match is None or len(match.group(3)) > 512: + raise OperationalError(f"Invalid canonical IAM role ARN {value!r}.") + return match.group(1), match.group(2), match.group(3) + + +def aws_directory(location: str | None) -> Path: + """Resolve a logical AWS location to its standard directory.""" + normalized = normalize_location(location) + return Path.home() / (".aws" if normalized == "default" else f".aws-{normalized}") + + +def _secure(path: Path) -> None: + """Harden a local state path where the platform supports POSIX-style modes.""" + try: + path.chmod(0o600 if path.is_file() else 0o700) + except OSError: + pass + + +def atomic_write(path: Path, data: bytes) -> None: + """Atomically replace a user-only file in a user-only directory.""" + path.parent.mkdir(parents=True, exist_ok=True) + _secure(path.parent) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary_path = Path(temporary) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + _secure(temporary_path) + os.replace(temporary_path, path) + _secure(path) + finally: + temporary_path.unlink(missing_ok=True) + + +def _validate_config(data: object) -> dict[str, Any]: + if type(data) is not dict: + raise OperationalError("Hacksaws config must be a JSON object.") + unknown = set(data) - TOP_LEVEL + if unknown: + raise OperationalError( + f"Unknown config field(s): {', '.join(sorted(unknown))}." + ) + version = data.get("schema_version") + if type(version) is not int or version != SCHEMA_VERSION: + raise OperationalError( + f"Unsupported Hacksaws config schema {version!r}; expected {SCHEMA_VERSION}." + ) + for collection in ("accounts", "boundaries", "targets", "policies"): + if type(data.get(collection)) is not dict: + raise OperationalError(f"Config field {collection!r} must be an object.") + cache = data.get("cache") + if type(cache) is not dict or set(cache) != {"max_age"}: + raise OperationalError("Config cache accepts only the max_age setting.") + if type(cache.get("max_age")) is not int or cache["max_age"] < 0: + raise OperationalError("Config cache.max_age must be non-negative seconds.") + _validate_resources(data) + return data + + +def _validate_resources(data: dict[str, Any]) -> None: + for collection in ("accounts", "boundaries", "targets", "policies"): + seen: set[str] = set() + for name, value in data[collection].items(): + validate_name(name, kind=collection[:-1]) + folded = name.casefold() + if folded in seen: + raise OperationalError( + f"Duplicate case-insensitive {collection[:-1]} {name!r}." + ) + seen.add(folded) + if type(value) is not dict: + raise OperationalError( + f"{collection[:-1].title()} {name!r} must be an object." + ) + for name, account in data["accounts"].items(): + unknown = set(account) - {"id", "partition", "description", "unverified"} + if unknown: + raise OperationalError( + f"Unknown account field(s) for {name}: {', '.join(unknown)}." + ) + if type(account.get("id")) is not str or not re.fullmatch( + r"\d{12}", account["id"] + ): + raise OperationalError(f"Account {name!r} must have a 12-digit id.") + if ( + type(account.get("partition")) is not str + or account["partition"] not in PARTITIONS + ): + raise OperationalError(f"Account {name!r} has an unsupported partition.") + if "description" in account and type(account["description"]) is not str: + raise OperationalError(f"Account {name!r} description must be text.") + if "unverified" in account and ( + type(account["unverified"]) is not bool or account["unverified"] is not True + ): + raise OperationalError( + f"Account {name!r} unverified must be true when set." + ) + for name, policy in data["policies"].items(): + unknown = set(policy) - {"file", "description"} + if unknown: + raise OperationalError( + f"Unknown policy field(s) for {name}: {', '.join(sorted(unknown))}." + ) + if type(policy.get("file")) is not str or policy["file"] != ( + f"stored_session_policies/{name}.yaml" + ): + raise OperationalError( + f"Policy {name!r} must use its canonical stored YAML path." + ) + if "description" in policy and type(policy["description"]) is not str: + raise OperationalError(f"Policy {name!r} description must be text.") + for name, boundary in data["boundaries"].items(): + allowed = { + "role_arn", + "account", + "policy", + "duration", + "external_id", + "description", + "verified", + } + if set(boundary) - allowed: + raise OperationalError(f"Unknown boundary field(s) for {name}.") + role_partition, role_account, _ = parse_role_arn(boundary.get("role_arn")) + if type(boundary.get("account")) is not str: + raise OperationalError(f"Boundary {name!r} account reference must be text.") + account_key = _find_key(data["accounts"], boundary["account"]) + if account_key is None: + raise OperationalError(f"Boundary {name!r} references a missing account.") + referenced_account = data["accounts"][account_key] + if ( + role_partition != referenced_account["partition"] + or role_account != referenced_account["id"] + ): + raise OperationalError( + f"Boundary {name!r} role ARN does not match referenced account " + f"{account_key!r}." + ) + policy = boundary.get("policy") + if policy is not None and type(policy) is not str: + raise OperationalError(f"Boundary {name!r} policy reference must be text.") + policy_path = Path(policy).expanduser() if policy else None + if ( + policy + and _find_key(data["policies"], policy) is None + and policy_path is not None + and policy_path.suffix.lower() not in {".json", ".yaml", ".yml", ".toml"} + ): + raise OperationalError( + f"Boundary {name!r} references neither a stored policy nor a policy file: {policy!r}." + ) + if "duration" in boundary and ( + type(boundary["duration"]) is not int + or not 900 <= boundary["duration"] <= 43200 + ): + raise OperationalError( + f"Boundary {name!r} duration must be integral seconds from 900 " + "through 43200." + ) + for field in ("external_id", "description"): + if field in boundary and type(boundary[field]) is not str: + raise OperationalError(f"Boundary {name!r} {field} must be text.") + if "verified" in boundary and type(boundary["verified"]) is not bool: + raise OperationalError(f"Boundary {name!r} verified must be boolean.") + for name, target in data["targets"].items(): + allowed = { + "source_account", + "source_profile", + "source_location", + "source_directory", + "destination_profile", + "destination_location", + "destination_directory", + "boundary", + "description", + } + if set(target) - allowed: + raise OperationalError(f"Unknown target field(s) for {name}.") + if type(target.get("source_account")) is not str: + raise OperationalError( + f"Target {name!r} source_account reference must be text." + ) + if _find_key(data["accounts"], target["source_account"]) is None: + raise OperationalError( + f"Target {name!r} references a missing source account." + ) + source_fields = [ + field + for field in ("source_location", "source_directory") + if field in target + ] + if len(source_fields) != 1: + raise OperationalError( + f"Target {name!r} requires exactly one source location or directory." + ) + destination_fields = [ + field + for field in ("destination_location", "destination_directory") + if field in target + ] + if len(destination_fields) > 1: + raise OperationalError( + f"Target {name!r} destination location/directory are exclusive." + ) + if "destination_profile" in target and not destination_fields: + raise OperationalError( + f"Target {name!r} destination_profile requires a destination." + ) + for field in ("source_profile", "destination_profile", "description"): + if field in target and ( + type(target[field]) is not str or not target[field] + ): + raise OperationalError(f"Target {name!r} {field} must be text.") + for field in ("source_directory", "destination_directory"): + if field in target and ( + type(target[field]) is not str or not Path(target[field]).is_absolute() + ): + raise OperationalError( + f"Target {name!r} {field} must be an absolute path." + ) + for field in ("source_location", "destination_location"): + if field in target: + if type(target[field]) is not str: + raise OperationalError(f"Target {name!r} {field} must be text.") + normalize_location(target[field]) + boundary = target.get("boundary") + if boundary is not None and type(boundary) is not str: + raise OperationalError(f"Target {name!r} boundary reference must be text.") + if boundary and _find_key(data["boundaries"], boundary) is None: + raise OperationalError( + f"Target {name!r} references missing boundary {boundary!r}." + ) + + +def load_config(*, create: bool = False) -> dict[str, Any]: + """Load and strictly validate config.json.""" + path = root() / "config.json" + if not path.exists(): + data = default_config() + if create: + save_config(data) + return data + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise OperationalError( + f"Unable to read Hacksaws config {path}: {error}" + ) from error + return _validate_config(data) + + +def save_config(data: dict[str, Any]) -> None: + """Validate and atomically save config.json.""" + validated = _validate_config(deepcopy(data)) + encoded = (json.dumps(validated, indent=2, sort_keys=False) + "\n").encode() + atomic_write(root() / "config.json", encoded) + + +def _find_key(collection: dict[str, Any], name: str) -> str | None: + folded = name.casefold() + return next((key for key in collection if key.casefold() == folded), None) + + +def get_resource( + data: dict[str, Any], kind: str, name: str +) -> tuple[str, dict[str, Any]]: + """Return a named resource with case-insensitive lookup.""" + collection = data[collection_name(kind)] + key = _find_key(collection, name) + if key is None: + raise OperationalError(f"{kind.title()} {name!r} does not exist.") + return key, collection[key] + + +def add_resource( + data: dict[str, Any], kind: str, name: str, value: dict[str, Any] +) -> None: + """Add a resource, failing on case-insensitive collision.""" + validate_name(name, kind=kind) + collection = data[collection_name(kind)] + if _find_key(collection, name) is not None: + raise OperationalError(f"{kind.title()} {name!r} already exists.") + collection[name] = value + _validate_config(data) + + +def update_resource( + data: dict[str, Any], kind: str, name: str, patch: dict[str, Any] +) -> None: + """Patch an existing resource.""" + key, value = get_resource(data, kind, name) + value.update(patch) + data[collection_name(kind)][key] = value + _validate_config(data) + + +def references(data: dict[str, Any], kind: str, name: str) -> list[str]: + """List configuration and active-session references to a resource.""" + canonical, _ = get_resource(data, kind, name) + found: list[str] = [] + if kind == "account": + found.extend( + f"boundary:{key}" + for key, item in data["boundaries"].items() + if str(item.get("account", "")).casefold() == canonical.casefold() + ) + found.extend( + f"target:{key}" + for key, item in data["targets"].items() + if str(item.get("source_account", "")).casefold() == canonical.casefold() + ) + elif kind == "boundary": + found.extend( + f"target:{key}" + for key, item in data["targets"].items() + if str(item.get("boundary", "")).casefold() == canonical.casefold() + ) + elif kind == "policy": + found.extend( + f"boundary:{key}" + for key, item in data["boundaries"].items() + if str(item.get("policy", "")).casefold() == canonical.casefold() + ) + for destination, session in load_sessions().items(): + field = { + "account": "target_account", + "boundary": "boundary", + "target": "target", + "policy": "policy", + }[kind] + if str(session.get(field, "")).casefold() == canonical.casefold(): + found.append(f"session:{destination}") + return found + + +def remove_resource(data: dict[str, Any], kind: str, name: str) -> None: + """Remove an unreferenced resource.""" + key, _ = get_resource(data, kind, name) + dependents = references(data, kind, name) + if dependents: + raise OperationalError( + f"Cannot remove {kind} {key!r}; referenced by {', '.join(dependents)}." + ) + del data[collection_name(kind)][key] + + +def rename_resource(data: dict[str, Any], kind: str, old: str, new: str) -> None: + """Rename a resource and atomically rewrite all references.""" + validate_name(new, kind=kind) + old_key, value = get_resource(data, kind, old) + collision = _find_key(data[collection_name(kind)], new) + if collision is not None and collision != old_key: + raise OperationalError(f"{kind.title()} {new!r} already exists.") + rebuilt: dict[str, Any] = {} + for key, item in data[collection_name(kind)].items(): + rebuilt[new if key == old_key else key] = item + data[collection_name(kind)] = rebuilt + fields = { + "account": (("boundaries", "account"), ("targets", "source_account")), + "boundary": (("targets", "boundary"),), + "policy": (("boundaries", "policy"),), + "target": (), + } + for collection, field in fields[kind]: + for item in data[collection].values(): + if str(item.get(field, "")).casefold() == old_key.casefold(): + item[field] = new + sessions = load_sessions() + field = { + "account": "target_account", + "boundary": "boundary", + "target": "target", + "policy": "policy", + }[kind] + changed = False + for session in sessions.values(): + if str(session.get(field, "")).casefold() == old_key.casefold(): + session[field] = new + changed = True + if changed: + save_sessions(sessions) + _validate_config(data) + + +def sessions_path() -> Path: + """Return active session metadata path.""" + return root() / "sessions.json" + + +def load_sessions() -> dict[str, dict[str, Any]]: + """Load non-secret active session metadata.""" + path = sessions_path() + if not path.exists(): + return {} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise OperationalError( + f"Unable to read session state {path}: {error}" + ) from error + if not isinstance(value, dict) or any( + not isinstance(item, dict) for item in value.values() + ): + raise OperationalError(f"Invalid session state in {path}.") + return value + + +def save_sessions(value: dict[str, dict[str, Any]]) -> None: + """Atomically save non-secret active session metadata.""" + atomic_write(sessions_path(), (json.dumps(value, indent=2) + "\n").encode()) + + +def iso_now() -> str: + """Return a stable UTC timestamp.""" + return datetime.now(UTC).isoformat() + + +def digest(data: bytes) -> str: + """Return a SHA-256 hex digest.""" + return hashlib.sha256(data).hexdigest() diff --git a/hacksaws/_test_runner.py b/hacksaws/_test_runner.py new file mode 100644 index 0000000..9b434ec --- /dev/null +++ b/hacksaws/_test_runner.py @@ -0,0 +1,25 @@ +"""Shared full-suite coverage entry point for local development.""" + +from __future__ import annotations + +import subprocess +import sys + +PYTEST_ARGUMENTS = [ + "--cov=hacksaws", + "--cov-report=term-missing", + "--cov-report=xml", + "--cov-fail-under=95", +] + + +def main() -> int: + """Run the full test suite and preserve pytest's process result.""" + completed = subprocess.run( + [sys.executable, "-m", "pytest", *PYTEST_ARGUMENTS], check=False + ) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hacksaws/tests/test_cli_state_coverage.py b/hacksaws/tests/test_cli_state_coverage.py new file mode 100644 index 0000000..93da7fa --- /dev/null +++ b/hacksaws/tests/test_cli_state_coverage.py @@ -0,0 +1,490 @@ +"""Behavior coverage for configuration state and its command-line controls.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _state + +ACCOUNT_ID = "123456789012" +ROLE_ARN = f"arn:aws:iam::{ACCOUNT_ID}:role/ReadOnly" + + +@pytest.fixture +def state_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Give every persisted configuration test an isolated state directory.""" + home = tmp_path / "state" + monkeypatch.setenv("HACKSAWS_HOME", str(home)) + return home + + +def _run(arguments: list[str]) -> _configs.Result: + return _cli.console_main(arguments) + + +def _add_account(name: str = "Prod") -> _configs.Result: + return _run( + ["account", "add", name, ACCOUNT_ID, "--partition", "aws", "--no-verify"] + ) + + +def _seed_connected(state_home: Path) -> None: + data = _state.default_config() + data["accounts"]["Prod"] = {"id": ACCOUNT_ID, "partition": "aws"} + data["boundaries"]["Guard"] = { + "role_arn": ROLE_ARN, + "account": "Prod", + "policy": "Guard", + "external_id": "original", + "duration": 900, + "verified": False, + } + data["targets"]["Deploy"] = { + "source_account": "Prod", + "source_profile": "source", + "source_directory": str((state_home / "source").absolute()), + "destination_location": "old", + "destination_profile": "destination", + "boundary": "Guard", + } + data["policies"]["Guard"] = { + "file": "stored_session_policies/Guard.yaml", + } + _state.save_config(data) + + +def test_resource_cli_add_get_list_update_and_clear(state_home: Path) -> None: + assert _add_account().code == "RESOURCE_SAVED" + assert ( + _run( + [ + "boundary", + "add", + "Guard", + "ReadOnly", + "--account", + "Prod", + "--no-verify", + "--external-id", + "initial", + "--duration", + "15m", + ] + ).exit_code + == 0 + ) + source = state_home / "source" + assert ( + _run( + [ + "target", + "add", + "Deploy", + "--source-account", + "Prod", + "--source-directory", + str(source), + "--to", + "west:release", + "--boundary", + "Guard", + "--description", + "deployment target", + ] + ).exit_code + == 0 + ) + + listing = _run(["target", "list", "--json"]) + fetched = _run(["target", "get", "deploy", "--json"]) + assert json.loads(listing.message)[0]["name"] == "Deploy" + assert json.loads(fetched.message)["destination_profile"] == "release" + + assert ( + _run( + [ + "boundary", + "update", + "Guard", + "--role", + "Updated", + "--no-verify", + "--policy", + "local-policy.yaml", + "--external-id", + "changed", + "--duration", + "1h", + "--description", + "guard boundary", + ] + ).exit_code + == 0 + ) + assert ( + _run( + [ + "boundary", + "update", + "Guard", + "--clear-policy", + "--clear-external-id", + "--clear-duration", + "--clear-description", + ] + ).exit_code + == 0 + ) + assert ( + _run( + [ + "target", + "update", + "Deploy", + "--source-location", + "build", + "--to-directory", + str(state_home / "destination"), + "--to-profile", + "writer", + "--clear-boundary", + ] + ).exit_code + == 0 + ) + assert ( + _run( + ["target", "update", "Deploy", "--clear-destination", "--clear-description"] + ).exit_code + == 0 + ) + + data = _state.load_config() + assert data["boundaries"]["Guard"] == { + "role_arn": f"arn:aws:iam::{ACCOUNT_ID}:role/Updated", + "account": "Prod", + "verified": False, + } + assert data["targets"]["Deploy"] == { + "source_account": "Prod", + "source_profile": "default", + "source_location": "build", + } + + +def test_verified_account_and_boundary_adds_use_authoritative_identity( + state_home: Path, +) -> None: + identity = (ACCOUNT_ID, "aws", "arn:aws:iam::123456789012:user/test") + iam = MagicMock() + iam.get_role.return_value = {"Role": {"Arn": ROLE_ARN}} + session = MagicMock() + session.client.return_value = iam + with ( + patch("hacksaws._sessions._identity", return_value=identity) as get_identity, + patch("hacksaws._cli.boto3.Session", return_value=session), + ): + assert _run(["account", "add", "Verified", ACCOUNT_ID]).exit_code == 0 + assert ( + _run( + ["boundary", "add", "Read", "ReadOnly", "--account", "Verified"] + ).exit_code + == 0 + ) + get_identity.assert_called_once() + iam.get_role.assert_called_once_with(RoleName="ReadOnly") + assert _state.load_config()["boundaries"]["Read"]["verified"] is True + + +@pytest.mark.parametrize( + ("arguments", "message"), + [ + (["account", "add", "Prod", ACCOUNT_ID, "--no-verify"], "--partition"), + ( + [ + "boundary", + "add", + "Bad", + "arn:aws:iam::999999999999:role/Wrong", + "--account", + "Prod", + "--no-verify", + ], + "conflicts", + ), + ( + [ + "target", + "add", + "Bad", + "--source-account", + "Prod", + "--to-directory", + "somewhere", + ], + "requires --to-profile", + ), + ], +) +def test_resource_cli_reports_validation_errors( + state_home: Path, + arguments: list[str], + message: str, + capsys: pytest.CaptureFixture[str], +) -> None: + if arguments[0] != "account": + _add_account() + result = _run(arguments) + assert result.code == "OPERATIONAL_ERROR" + assert message in capsys.readouterr().err + + +def test_resource_rename_updates_references_and_active_session( + state_home: Path, +) -> None: + _seed_connected(state_home) + _state.save_sessions( + { + "destination": { + "target_account": "Prod", + "boundary": "Guard", + "target": "Deploy", + "policy": "Guard", + } + } + ) + for kind, old, new in ( + ("account", "Prod", "Production"), + ("boundary", "Guard", "Boundary"), + ("target", "Deploy", "Release"), + ): + assert _run([kind, "rename", old, new]).exit_code == 0 + + data = _state.load_config() + session = _state.load_sessions()["destination"] + assert data["boundaries"]["Boundary"]["account"] == "Production" + assert data["boundaries"]["Boundary"]["policy"] == "Guard" + assert data["targets"]["Release"]["boundary"] == "Boundary" + assert session == { + "target_account": "Production", + "boundary": "Boundary", + "target": "Release", + "policy": "Guard", + } + + +def test_state_references_block_remove_then_allow_it(state_home: Path) -> None: + _seed_connected(state_home) + _state.save_sessions({"destination": {"target": "Deploy"}}) + data = _state.load_config() + assert _state.references(data, "account", "prod") == [ + "boundary:Guard", + "target:Deploy", + ] + with pytest.raises(_configs.OperationalError, match="referenced by target:Deploy"): + _state.remove_resource(data, "boundary", "Guard") + with pytest.raises(_configs.OperationalError, match="session:destination"): + _state.remove_resource(data, "target", "Deploy") + + _state.save_sessions({}) + _state.remove_resource(data, "target", "Deploy") + _state.remove_resource(data, "boundary", "Guard") + _state.remove_resource(data, "policy", "Guard") + _state.remove_resource(data, "account", "Prod") + assert all( + not data[name] for name in ("accounts", "boundaries", "targets", "policies") + ) + + +def test_cascade_requires_confirmation_and_rejects_active_sessions( + state_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_connected(state_home) + data = _state.load_config() + with pytest.raises(_configs.OperationalError, match="requires --yes"): + _cli._cascade_remove(data, "account", "Prod", yes=False) + + _state.save_sessions({"destination": {"target_account": "Prod"}}) + with pytest.raises(_configs.OperationalError, match="Log out first"): + _cli._cascade_remove(data, "account", "Prod", yes=True) + + _state.save_sessions({}) + monkeypatch.setattr(_cli.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _: "no") + with pytest.raises(_configs.OperationalError, match="cancelled"): + _cli._cascade_remove(data, "account", "Prod", yes=False) + + +def test_cascade_removes_policy_file_and_rolls_back_on_save_error( + state_home: Path, +) -> None: + _seed_connected(state_home) + policy = state_home / "stored_session_policies" / "Guard.yaml" + policy.parent.mkdir(parents=True) + policy.write_text("Version: '2012-10-17'\nStatement: []\n", encoding="utf-8") + original = (state_home / "config.json").read_bytes() + data = _state.load_config() + with ( + patch("hacksaws._state.save_config", side_effect=OSError("no disk")), + pytest.raises(OSError, match="no disk"), + ): + _cli._cascade_remove(data, "policy", "Guard", yes=True) + assert (state_home / "config.json").read_bytes() == original + assert policy.exists() + + assert "Guard" in _cli._cascade_remove( + _state.load_config(), "policy", "Guard", yes=True + ) + assert not policy.exists() + + +def test_policy_cache_config_status_and_logout_dispatch(state_home: Path) -> None: + document = state_home / "document.yaml" + document.parent.mkdir(parents=True) + document.write_text("Version: '2012-10-17'\nStatement: []\n", encoding="utf-8") + assert ( + _run( + ["policy", "add", "Read", str(document), "--description", "read"] + ).exit_code + == 0 + ) + assert ( + json.loads(_run(["policy", "get", "read", "--json"]).message)["name"] == "Read" + ) + with patch("hacksaws._policies.rename_stored") as rename: + assert _run(["policy", "rename", "Read", "Reader"]).exit_code == 0 + rename.assert_called_once_with("Read", "Reader") + assert _run(["policy", "list"]).code == "POLICY_LIST" + assert _run(["policy", "remove", "Read"]).exit_code == 0 + + assert _run(["cache", "set", "max-age", "0s"]).code == "CACHE_SET" + cache_root = state_home / "policy-cache" + cache_root.mkdir() + (cache_root / "one.json").write_text("{}", encoding="utf-8") + assert json.loads(_run(["cache", "get", "--json"]).message) == { + "max_age": 0, + "entries": 1, + } + assert _run(["cache", "clear", "--yes"]).code == "CACHE_CLEAR" + + _add_account() + assert ( + json.loads(_run(["config", "show", "--account", "prod", "--json"]).message)[ + "account" + ]["name"] + == "Prod" + ) + with ( + patch("hacksaws._sessions.status", return_value={"sessions": []}), + patch("hacksaws._cli._run_logout", return_value=_configs.Result("OUT", "ok")), + ): + assert json.loads(_run(["status", "--json"]).message) == {"sessions": []} + assert _run(["logout"]).code == "OUT" + + +def test_console_dispatches_config_branches_and_known_errors( + state_home: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _state.save_config(_state.default_config()) + with ( + patch("hacksaws._sessions.explain_target", return_value={"target": "Prod"}), + patch("hacksaws._sessions.check_config", return_value={"errors": ["bad"]}), + patch( + "hacksaws._sessions.fix_config", + return_value=_configs.Result("FIX", "fixed"), + ), + patch("hacksaws._sessions.export_config", return_value=state_home / "out.zip"), + patch("hacksaws._sessions.import_config", return_value="imported"), + ): + assert json.loads(_run(["config", "explain", "Prod", "--json"]).message) == { + "target": "Prod" + } + assert _run(["config", "check"]).exit_code == 1 + assert _run(["config", "fix", "--yes"]).code == "FIX" + assert _run(["config", "export"]).code == "CONFIG_EXPORT" + assert ( + _run(["config", "import", "in.zip", "--replace", "--yes"]).message + == "imported" + ) + assert _run(["account", "get", "Missing"]).code == "OPERATIONAL_ERROR" + assert "does not exist" in capsys.readouterr().err + assert _run(["not-a-command"]).code == "ARGUMENT_ERROR" + + +@pytest.mark.parametrize( + ("change", "message"), + [ + (lambda data: data.update(extra=True), "Unknown config"), + (lambda data: data.update(schema_version=True), "Unsupported"), + (lambda data: data.update(cache={"max_age": True}), "non-negative"), + ( + lambda data: data["accounts"].update( + {"Prod": {"id": "short", "partition": "moon"}} + ), + "12-digit", + ), + ( + lambda data: data["targets"].update( + {"Bad": {"source_account": "missing", "source_location": "default"}} + ), + "missing source account", + ), + ( + lambda data: data["accounts"].update( + { + "Prod": {"id": ACCOUNT_ID, "partition": "aws"}, + "prod": {"id": ACCOUNT_ID, "partition": "aws"}, + } + ), + "Duplicate case-insensitive", + ), + ], +) +def test_state_strict_schema_rejects_invalid_resources( + change: object, message: str +) -> None: + data = _state.default_config() + change(data) # type: ignore[operator] + with pytest.raises(_configs.OperationalError, match=message): + _state.save_config(data) + + +def test_state_helpers_cover_locations_arns_and_io_errors( + state_home: Path, +) -> None: + assert _state.collection_name("boundary") == "boundaries" + assert _state.normalize_location(None) == "default" + assert _state.parse_role_arn(ROLE_ARN)[:2] == ("aws", ACCOUNT_ID) + with pytest.raises(_configs.OperationalError, match="Invalid resource"): + _state.validate_name("bad name") + with pytest.raises(_configs.OperationalError, match="AWS location must be text"): + _state.normalize_location(7) # type: ignore[arg-type] + with pytest.raises(_configs.OperationalError, match="canonical IAM"): + _state.parse_role_arn("not-an-arn") + + (state_home / "config.json").parent.mkdir(parents=True) + (state_home / "config.json").write_text("not json", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="Unable to read"): + _state.load_config() + (state_home / "sessions.json").write_text("[]", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="Invalid session state"): + _state.load_sessions() + + +def test_state_rename_rejects_collisions_without_rewriting(state_home: Path) -> None: + data = _state.default_config() + data["accounts"] = { + "Prod": {"id": ACCOUNT_ID, "partition": "aws"}, + "Other": {"id": "999999999999", "partition": "aws"}, + } + before = json.loads(json.dumps(data)) + with pytest.raises(_configs.OperationalError, match="already exists"): + _state.rename_resource(data, "account", "Prod", "Other") + assert data == before diff --git a/hacksaws/tests/test_coverage_closure.py b/hacksaws/tests/test_coverage_closure.py new file mode 100644 index 0000000..0054ab6 --- /dev/null +++ b/hacksaws/tests/test_coverage_closure.py @@ -0,0 +1,338 @@ +"""High-value public and error-path coverage closure tests.""" + +from __future__ import annotations + +import argparse +import configparser +import importlib +import tomllib +from copy import deepcopy +from importlib import metadata +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from botocore.exceptions import ClientError +from coverage.results import should_fail_under + +import hacksaws +from hacksaws import _aws +from hacksaws import _configs +from hacksaws import _duration +from hacksaws import _ecr +from hacksaws import _policies +from hacksaws import _state +from hacksaws import _test_runner + +ACCOUNT = "123456789012" +ROLE = f"arn:aws:iam::{ACCOUNT}:role/Guard" +POLICY = b'Version: "2012-10-17"\nStatement: []\n' + + +def _context(directory: Path, **overrides: object) -> _configs.Context: + values: dict[str, object] = { + "directory": str(directory), + "profile": "dev", + "aws_account_name": None, + "podman": False, + "ecr_region": [], + "lifespan": 3600, + "mfa_code": "123456", + } + values.update(overrides) + return _configs.Context(argparse.Namespace(**values)) + + +def _base_config() -> dict[str, Any]: + return _state.default_config() + + +def _account_config() -> dict[str, Any]: + data = _state.default_config() + data["accounts"]["Prod"] = {"id": ACCOUNT, "partition": "aws"} + return data + + +def _boundary_config() -> dict[str, Any]: + data = _account_config() + data["boundaries"]["Guard"] = { + "role_arn": ROLE, + "account": "Prod", + "verified": False, + } + return data + + +def _target_config() -> dict[str, Any]: + data = _account_config() + data["targets"]["Prod"] = { + "source_account": "Prod", + "source_directory": str(Path.cwd().absolute()), + } + return data + + +def test_package_version_falls_back_to_pyproject() -> None: + with patch("importlib.metadata.version", side_effect=metadata.PackageNotFoundError): + reloaded = importlib.reload(hacksaws) + assert reloaded.__version__ == "0.4.0" + + +def test_coverage_gate_uses_two_decimal_precision() -> None: + project = Path(__file__).parents[2] / "pyproject.toml" + with project.open("rb") as stream: + configuration = tomllib.load(stream) + precision = configuration["tool"]["coverage"]["report"]["precision"] + + assert precision == 2 + assert should_fail_under(94.99, 95, precision) is True + assert should_fail_under(95.00, 95, precision) is False + assert "--cov-fail-under=95" in _test_runner.PYTEST_ARGUMENTS + + +def test_aws_ini_helpers_translate_parser_write_and_profile_errors( + tmp_path: Path, +) -> None: + invalid = tmp_path / "invalid" + invalid.write_text("not-an-ini", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="Unable to parse"): + _aws._read_config(invalid, description="test") + + parser = configparser.ConfigParser() + with ( + patch.object(Path, "open", side_effect=OSError("read only")), + pytest.raises(_configs.OperationalError, match="Unable to write"), + ): + _aws._write_config(tmp_path / "output", parser, description="test") + with pytest.raises(_configs.OperationalError, match="missing"): + _aws._profile_section(parser, "dev", description="test") + + +def test_aws_logout_translates_backup_removal_failure(tmp_path: Path) -> None: + context = _context(tmp_path) + (tmp_path / "credentials").write_text( + "[dev]\naws_access_key_id = current\naws_secret_access_key = current\n" + ) + context.storage_path.write_text( + "[dev]\naws_access_key_id = original\naws_secret_access_key = original\n" + ) + real_unlink = Path.unlink + + def fail_backup(path: Path, *, missing_ok: bool = False) -> None: + if path == context.storage_path: + raise OSError("locked") + real_unlink(path, missing_ok=missing_ok) + + with ( + patch.object(Path, "unlink", autospec=True, side_effect=fail_backup), + pytest.raises(_configs.OperationalError, match="Unable to remove"), + ): + _aws.logout(context) + restored = configparser.ConfigParser() + restored.read(context.credentials_path) + assert restored["dev"]["aws_access_key_id"] == "original" + + +def test_aws_login_translates_sts_client_failure(tmp_path: Path) -> None: + context = _context(tmp_path) + (tmp_path / "config").write_text( + "[profile dev]\nmfa_serial = arn:aws:iam::123456789012:mfa/dev\n" + ) + client_error = ClientError( + {"Error": {"Code": "Denied", "Message": "bad token"}}, "GetSessionToken" + ) + session = MagicMock() + session.client.return_value.get_session_token.side_effect = client_error + with ( + patch("hacksaws._aws.boto3.Session", return_value=session), + pytest.raises(_configs.OperationalError, match="Unable to start MFA"), + ): + _aws.login(context) + + +def test_config_models_cover_identity_defaults_and_aws_failure(tmp_path: Path) -> None: + context = _context(tmp_path, profile=None, aws_account_name="team", podman=True) + assert context.profile == "default" + assert context.container_engine == "podman" + assert context.aws_directory.name == ".aws-team" + + account = _configs.AwsAccount( + {"Account": ACCOUNT, "Arn": "x", "UserId": 123}, "us-east-1", () + ) + assert account.user_id is None + assert account.partition == "aws" + with ( + patch( + "hacksaws._configs.boto3.Session", + side_effect=ClientError( + {"Error": {"Code": "Denied", "Message": "no"}}, "Session" + ), + ), + pytest.raises(_configs.OperationalError, match="Unable to load AWS profile"), + ): + _configs.AwsAccount.from_context(context) + + +def test_duration_rejects_quantization_overflow_and_subsecond_count() -> None: + huge = f"{'9' * 10000}h" + with pytest.raises(_configs.OperationalError, match="Invalid duration"): + _duration.parse_duration(huge) + with pytest.raises(_configs.OperationalError, match="round to a positive"): + _duration.parse_count("0.1", 1) + + +def test_ecr_client_error_is_operational() -> None: + context = _context(Path.cwd()) + session = MagicMock() + session.client.side_effect = ClientError( + {"Error": {"Code": "Denied", "Message": "no"}}, "GetAuthorizationToken" + ) + with pytest.raises(_configs.OperationalError, match="Unable to get an ECR token"): + _ecr._do_login( + context, + account_id=ACCOUNT, + region_name="us-east-1", + session=session, + ) + + +def _invalid_cases() -> list[tuple[dict[str, Any] | object, str]]: + policy = _base_config() + policy["policies"]["Read"] = {"file": "wrong", "description": 3} + + cases: list[tuple[dict[str, Any] | object, str]] = [ + ([], "JSON object"), + ({**_base_config(), "accounts": []}, "must be an object"), + ({**_base_config(), "cache": {"other": 1}}, "accepts only"), + ({**_base_config(), "accounts": {"Prod": "bad"}}, "must be an object"), + ( + { + **_base_config(), + "accounts": {"Prod": {"id": ACCOUNT, "partition": "aws", "other": 1}}, + }, + "Unknown account", + ), + ( + { + **_base_config(), + "accounts": { + "Prod": { + "id": ACCOUNT, + "partition": "aws", + "description": 1, + } + }, + }, + "description must be text", + ), + ( + { + **_base_config(), + "accounts": { + "Prod": {"id": ACCOUNT, "partition": "aws", "unverified": False} + }, + }, + "unverified must be true", + ), + (policy, "canonical stored YAML"), + ] + + boundary_mutations: list[tuple[dict[str, Any], str]] = [ + ({"other": 1}, "Unknown boundary"), + ({"account": 7}, "account reference must be text"), + ({"account": "Missing"}, "missing account"), + ({"policy": 7}, "policy reference must be text"), + ({"policy": "not-a-policy.txt"}, "neither a stored policy"), + ({"external_id": 7}, "external_id must be text"), + ({"verified": "yes"}, "verified must be boolean"), + ] + for mutation, message in boundary_mutations: + data = _boundary_config() + data["boundaries"]["Guard"].update(mutation) + cases.append((data, message)) + + target_mutations: list[tuple[dict[str, Any], str]] = [ + ({"other": 1}, "Unknown target"), + ({"source_account": 7}, "source_account reference must be text"), + ({"source_directory": None}, "exactly one source"), + ( + {"destination_location": "a", "destination_directory": str(Path.cwd())}, + "are exclusive", + ), + ({"destination_profile": "out"}, "requires a destination"), + ({"source_profile": 7}, "source_profile must be text"), + ({"source_directory": "relative"}, "must be an absolute path"), + ({"boundary": 7}, "boundary reference must be text"), + ({"boundary": "Missing"}, "references missing boundary"), + ] + for mutation, message in target_mutations: + data = _target_config() + data["targets"]["Prod"].update(mutation) + if "source_directory" in mutation and mutation["source_directory"] is None: + data["targets"]["Prod"].pop("source_directory") + cases.append((data, message)) + return cases + + +@pytest.mark.parametrize(("data", "message"), _invalid_cases()) +def test_state_validation_rejects_malformed_resource_shapes( + data: dict[str, Any] | object, message: str +) -> None: + with pytest.raises(_configs.OperationalError, match=message): + _state._validate_config(deepcopy(data)) + + +def test_state_path_create_secure_duplicate_and_session_read_failures( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + assert _state.aws_directory("team").name == ".aws-team" + with patch.object(Path, "chmod", side_effect=OSError("unsupported")): + _state._secure(tmp_path) + created = _state.load_config(create=True) + assert created == _state.default_config() + assert (_state.root() / "config.json").exists() + + duplicate = _account_config() + with pytest.raises(_configs.OperationalError, match="already exists"): + _state.add_resource( + duplicate, + "account", + "prod", + {"id": ACCOUNT, "partition": "aws"}, + ) + _state.sessions_path().write_text("not-json", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="Unable to read session"): + _state.load_sessions() + + +def test_stored_policy_rename_updates_file_metadata_references_and_sessions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + source = tmp_path / "read.yaml" + source.write_bytes(POLICY) + _policies.add_stored("Read", source) + config = _state.load_config() + config["accounts"]["Prod"] = {"id": ACCOUNT, "partition": "aws"} + config["boundaries"]["Guard"] = { + "role_arn": ROLE, + "account": "Prod", + "policy": "Read", + "verified": False, + } + _state.save_config(config) + _state.save_sessions({"destination": {"policy": "Read", "backup": [], "ecr": []}}) + + _policies.rename_stored("Read", "View") + + renamed = _state.load_config() + assert renamed["policies"] == { + "View": {"file": "stored_session_policies/View.yaml"} + } + assert renamed["boundaries"]["Guard"]["policy"] == "View" + assert _state.load_sessions()["destination"]["policy"] == "View" + assert not (_policies.stored_directory() / "Read.yaml").exists() + assert (_policies.stored_directory() / "View.yaml").read_bytes() == POLICY diff --git a/hacksaws/tests/test_hacksaws.py b/hacksaws/tests/test_hacksaws.py index 1e4d77f..44f86b5 100644 --- a/hacksaws/tests/test_hacksaws.py +++ b/hacksaws/tests/test_hacksaws.py @@ -145,8 +145,8 @@ def _temporary_credentials() -> dict[str, object]: def test_version_and_main_exit_status() -> None: """Expose the project version and pass the result status to the shell.""" with Path(__file__).parents[2].joinpath("pyproject.toml").open("rb") as stream: - assert tomllib.load(stream)["project"]["version"] == "0.3.2" - assert hacksaws.__version__ == "0.3.2" + assert tomllib.load(stream)["project"]["version"] == "0.4.0" + assert hacksaws.__version__ == "0.4.0" with patch( "hacksaws.console_main", return_value=_configs.Result("ERROR", "", exit_code=7), @@ -542,13 +542,8 @@ def test_explicit_ecr_logout_is_strict( ): result = hacksaws.console_main(arguments) - registry = f"{ACCOUNT_ID}.dkr.ecr.us-west-2.amazonaws.com" assert result.exit_code == 0 - subprocess_run.assert_called_once_with( - [engine, "logout", registry], - input=None, - check=True, - ) + subprocess_run.assert_not_called() def test_known_configuration_failure_is_concise( @@ -628,9 +623,9 @@ def test_aws_failure_is_concise( ) captured = capsys.readouterr() - assert result.code == "OPERATIONAL_ERROR" - assert result.exit_code == 1 - assert captured.err.startswith(f"Error: Unable to load AWS profile {PROFILE!r}:") + assert result.code == "MFA_LOGOUT" + assert result.exit_code == 0 + assert captured.err == "" assert "Traceback" not in captured.err @@ -796,9 +791,9 @@ def test_container_engine_launch_os_error_is_concise_through_cli( result = hacksaws.console_main(arguments) captured = capsys.readouterr() - assert result.code == "OPERATIONAL_ERROR" - assert result.exit_code == 1 - assert captured.err == f"Error: Unable to run {engine.title()}: access denied\n" + assert result.code == "MFA_LOGOUT" + assert result.exit_code == 0 + assert captured.err == "" assert "Traceback" not in captured.err diff --git a/hacksaws/tests/test_policy_leaf_coverage.py b/hacksaws/tests/test_policy_leaf_coverage.py new file mode 100644 index 0000000..d12b944 --- /dev/null +++ b/hacksaws/tests/test_policy_leaf_coverage.py @@ -0,0 +1,514 @@ +"""Offline behavior coverage for policy, AWS configuration, and ECR leaf modules.""" + +from __future__ import annotations + +import argparse +import base64 +import configparser +import io +import json +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from functools import partial +from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from botocore.exceptions import ClientError + +import hacksaws +from hacksaws import _aws +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _duration +from hacksaws import _ecr +from hacksaws import _policies +from hacksaws import _state + +ACCOUNT = "123456789012" +DOCUMENT = {"Version": "2012-10-17", "Statement": []} + + +def policy_file(path: Path, suffix: str = ".json", contents: str | None = None) -> Path: + path = path.with_suffix(suffix) + path.write_text(contents or json.dumps(DOCUMENT), encoding="utf-8") + return path + + +def context(directory: Path, *, podman: bool = False) -> _configs.Context: + return _configs.Context( + argparse.Namespace( + profile="dev", + directory=str(directory), + aws_account_name=None, + podman=podman, + lifespan=900, + mfa_code="123456", + ecr_region=None, + ) + ) + + +def write_ini(path: Path, sections: dict[str, dict[str, str]]) -> None: + parser = configparser.ConfigParser() + parser.read_dict(sections) + with path.open("w", encoding="utf-8") as stream: + parser.write(stream) + + +def record_engine_call( + calls: list[list[str]], _engine: str, command: list[str], **_kwargs: object +) -> None: + calls.append(command) + + +@pytest.mark.parametrize( + ("raw", "kind", "message"), + [ + (b"{bad", "json", "Invalid JSON"), + (b"[broken", "yaml", "Invalid YAML"), + (b"Version =", "toml", "Invalid TOML"), + (b"[]", "json", "must be an object"), + (b'{"Version":"1"}', "json", "requires Version"), + (b'{"Version":"1","Statement":"no"}', "json", "Statement"), + ], +) +def test_policy_parsing_reports_format_and_schema_failures( + raw: bytes, kind: str, message: str +) -> None: + with pytest.raises(_configs.OperationalError, match=message): + _policies.parse_policy_bytes(raw, kind=kind, source="inline") + with pytest.raises(_configs.OperationalError, match="Unsupported"): + _policies.parse_policy_bytes(b"{}", kind="ini", source="inline") + with pytest.raises(_configs.OperationalError, match="UTF-8"): + _policies.parse_policy_bytes(b"\xff", kind="json", source="inline") + + +def test_stored_policy_crud_rename_and_rollback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + source = policy_file(tmp_path / "read") + changed = policy_file( + tmp_path / "changed", ".toml", 'Version = "2012-10-17"\nStatement = []' + ) + _policies.add_stored("Read", source, "initial") + _policies.update_stored("read", changed, "changed") + data = _state.load_config() + assert data["policies"]["Read"]["description"] == "changed" + assert ( + _policies.parse_policy(_policies.stored_directory() / "Read.yaml")[0] + == DOCUMENT + ) + + def rename_in_memory( + data: dict[str, object], kind: str, old: str, new: str + ) -> None: + policies = data["policies"] + assert kind == "policy" + assert isinstance(policies, dict) + policies[new] = policies.pop(old) + + with patch( + "hacksaws._policies._state.rename_resource", side_effect=rename_in_memory + ): + _policies.rename_stored("Read", "Renamed") + assert (_policies.stored_directory() / "Renamed.yaml").exists() + _policies.remove_stored("renamed") + assert not (_policies.stored_directory() / "Renamed.yaml").exists() + + _policies.add_stored("One", source) + with ( + patch( + "hacksaws._policies._state.rename_resource", side_effect=rename_in_memory + ), + patch( + "hacksaws._policies._state.save_config", side_effect=OSError("disk full") + ), + pytest.raises(OSError, match="disk full"), + ): + _policies.rename_stored("One", "Two") + assert (_policies.stored_directory() / "One.yaml").exists() + assert "One" in _state.load_config()["policies"] + + +def test_policy_resolution_local_stored_and_explicit_arns( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + source = policy_file(tmp_path / "local") + local = _policies.resolve(str(source), account_id=ACCOUNT, partition="aws") + assert local.origin == "local" + assert local.document == _policies.minify(DOCUMENT) + _policies.add_stored("Read", source) + stored = _policies.resolve("read", account_id=ACCOUNT, partition="aws") + assert stored.origin == "stored" + assert stored.identity == "Read" + arn = f"arn:aws:iam::{ACCOUNT}:policy/Read" + assert _policies.resolve(arn, account_id=ACCOUNT, partition="aws").arn == arn + with pytest.raises(_configs.OperationalError, match="target role account"): + _policies.resolve( + "arn:aws:iam::999999999999:policy/X", account_id=ACCOUNT, partition="aws" + ) + with pytest.raises(_configs.OperationalError, match="does not exist"): + _policies.resolve( + str(tmp_path / "missing.json"), account_id=ACCOUNT, partition="aws" + ) + with pytest.raises(_configs.OperationalError, match="2048"): + _policies.enforce_inline_limit("x" * 2049) + + +def test_policy_cache_fresh_expired_corrupt_and_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + _policies.cache_write( + "fresh", DOCUMENT, origin="local", resolver="file", source_identity="x" + ) + assert _policies.cache_read("fresh", 60) is not None + assert _policies.cache_read("fresh", 0) is None + path = _policies._cache_path("fresh") + record = json.loads(path.read_text()) + record["fetched_at"] = (datetime.now(UTC) - timedelta(hours=2)).isoformat() + path.write_text(json.dumps(record), encoding="utf-8") + assert _policies.cache_read("fresh", 1) is None + path.write_text("not json", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="Invalid policy cache"): + _policies.cache_read("fresh", 10) + + +def test_policy_storage_cleanup_and_read_failures( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + source = policy_file( + tmp_path / "source", ".yaml", "Version: '2012-10-17'\nStatement: []\n" + ) + with ( + patch("hacksaws._policies._state.save_config", side_effect=OSError("full")), + pytest.raises(OSError, match="full"), + ): + _policies.add_stored("Temporary", source) + assert not (_policies.stored_directory() / "Temporary.yaml").exists() + with ( + patch.object(Path, "read_bytes", side_effect=OSError("denied")), + pytest.raises(_configs.OperationalError, match="Unable to read"), + ): + _policies.parse_policy(source) + + +def test_remote_resolution_name_collisions_fetch_and_cache( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + session = MagicMock() + sts = MagicMock() + sts.get_caller_identity.return_value = { + "Account": ACCOUNT, + "Arn": f"arn:aws:iam::{ACCOUNT}:user/me", + } + iam = MagicMock() + session.client.side_effect = lambda service: {"sts": sts, "iam": iam}[service] + local = { + "PolicyName": "Same", + "Arn": f"arn:aws:iam::{ACCOUNT}:policy/Same", + "DefaultVersionId": "v1", + } + aws = { + "PolicyName": "Same", + "Arn": "arn:aws:iam::aws:policy/Same", + "DefaultVersionId": "v1", + } + local_paginator = MagicMock() + aws_paginator = MagicMock() + local_paginator.paginate.return_value = [{"Policies": [local]}] + aws_paginator.paginate.return_value = [{"Policies": [aws]}] + iam.get_paginator.side_effect = [local_paginator, aws_paginator] + with pytest.raises(_configs.OperationalError, match="ambiguous"): + _policies.resolve("Same", account_id=ACCOUNT, partition="aws", session=session) + + iam.get_paginator.side_effect = [local_paginator, MagicMock()] + iam.get_paginator.return_value.paginate.return_value = [{"Policies": []}] + iam.get_policy_version.return_value = {"PolicyVersion": {"Document": DOCUMENT}} + resolved = _policies.resolve( + "Same", account_id=ACCOUNT, partition="aws", session=session + ) + assert resolved.arn == local["Arn"] + assert resolved.origin == "remote-customer" + cached = _policies.resolve( + "Same", account_id=ACCOUNT, partition="aws", session=session + ) + assert cached.cached + assert cached.arn == local["Arn"] + + +def test_remote_policy_account_and_list_failure_limits( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + session = MagicMock() + sts = MagicMock() + sts.get_caller_identity.return_value = { + "Account": "999999999999", + "Arn": "arn:aws-us-gov:iam::999999999999:user/me", + } + session.client.return_value = sts + with pytest.raises(_configs.OperationalError, match="authenticated resolver"): + _policies.resolve("X", account_id=ACCOUNT, partition="aws", session=session) + + sts.get_caller_identity.return_value = { + "Account": ACCOUNT, + "Arn": f"arn:aws:iam::{ACCOUNT}:user/me", + } + iam = MagicMock() + iam.get_paginator.side_effect = ClientError( + {"Error": {"Code": "Denied", "Message": "no"}}, "ListPolicies" + ) + session.client.side_effect = lambda service: {"sts": sts, "iam": iam}[service] + result = _policies.resolve( + "X", account_id=ACCOUNT, partition="aws", session=session + ) + assert result.arn == f"arn:aws:iam::{ACCOUNT}:policy/X" + + iam.get_paginator.side_effect = None + paginator = MagicMock() + paginator.paginate.return_value = [{"Policies": []}] + iam.get_paginator.return_value = paginator + with pytest.raises(_configs.OperationalError, match="does not exist"): + _policies.resolve( + "Absent", account_id=ACCOUNT, partition="aws", session=session + ) + sts.get_caller_identity.side_effect = ClientError( + {"Error": {"Code": "Bad", "Message": "no"}}, "GetCallerIdentity" + ) + with pytest.raises(_configs.OperationalError, match="Unable to verify"): + _policies.resolve( + "Absent", account_id=ACCOUNT, partition="aws", max_age=0, session=session + ) + + +def test_aws_managed_fetch_handles_cached_and_service_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" + client = MagicMock() + client.get_policy.return_value = {"Policy": {"DefaultVersionId": "v1"}} + client.get_policy_version.return_value = { + "PolicyVersion": {"Document": json.dumps(DOCUMENT)} + } + session = MagicMock() + session.client.return_value = client + first = _policies.resolve( + arn, account_id=ACCOUNT, partition="aws", max_age=60, session=session + ) + assert first.origin == "aws-managed" + assert not first.cached + second = _policies.resolve( + arn, account_id=ACCOUNT, partition="aws", max_age=60, session=session + ) + assert second.cached + client.get_policy.side_effect = ClientError( + {"Error": {"Code": "No", "Message": "bad"}}, "GetPolicy" + ) + with pytest.raises(_configs.OperationalError, match="Unable to fetch"): + _policies.resolve( + arn, account_id=ACCOUNT, partition="aws", max_age=0, session=session + ) + + +def test_remote_aws_name_and_customer_inspection_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + session = MagicMock() + sts = MagicMock() + sts.get_caller_identity.return_value = { + "Account": ACCOUNT, + "Arn": f"arn:aws:iam::{ACCOUNT}:user/me", + } + iam = MagicMock() + session.client.side_effect = lambda service: {"sts": sts, "iam": iam}[service] + customer = { + "PolicyName": "Customer", + "Arn": f"arn:aws:iam::{ACCOUNT}:policy/Customer", + "DefaultVersionId": "v1", + } + local_pages = MagicMock() + local_pages.paginate.return_value = [{"Policies": [customer]}] + aws_pages = MagicMock() + aws_pages.paginate.return_value = [{"Policies": []}] + iam.get_paginator.side_effect = [local_pages, aws_pages] + iam.get_policy_version.side_effect = ClientError( + {"Error": {"Code": "Bad", "Message": "no"}}, "GetPolicyVersion" + ) + with pytest.raises(_configs.OperationalError, match="Unable to inspect"): + _policies.resolve( + "Customer", account_id=ACCOUNT, partition="aws", session=session + ) + + aws_item = { + "PolicyName": "Aws", + "Arn": "arn:aws:iam::aws:policy/Aws", + "DefaultVersionId": "v1", + } + local_pages.paginate.return_value = [{"Policies": []}] + aws_pages.paginate.return_value = [{"Policies": [aws_item]}] + iam.get_paginator.side_effect = [local_pages, aws_pages] + iam.get_policy.side_effect = None + iam.get_policy.return_value = {"Policy": {"DefaultVersionId": "v1"}} + iam.get_policy_version.side_effect = None + iam.get_policy_version.return_value = {"PolicyVersion": {"Document": DOCUMENT}} + assert ( + _policies.resolve( + "Aws", account_id=ACCOUNT, partition="aws", session=session + ).origin + == "aws-managed" + ) + + +@pytest.mark.parametrize( + ("value", "expected"), [(".5m", 30), ("1hr", 3600), ("1.5s", 2)] +) +def test_duration_aliases_rounding_and_errors(value: str, expected: int) -> None: + assert _duration.parse_duration(value) == expected + assert _duration.parse_count("1.5", 60) == 90 + assert _duration.session_duration(htl="1") == 3600 + assert _duration.session_duration(mtl="1") == 60 + assert _duration.session_duration(stl="2") == 2 + assert _duration.session_duration(default=42) == 42 + with pytest.raises(_configs.OperationalError): + _duration.parse_duration("1fortnight") + with pytest.raises(_configs.OperationalError): + _duration.parse_count("NaN", 1) + with pytest.raises(_configs.OperationalError): + _duration.parse_count("not-a-number", 1) + with pytest.raises(_configs.OperationalError, match="Only one"): + _duration.session_duration(duration="1h", mtl="1") + + +def test_context_account_and_result_leaf_behavior( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + args = argparse.Namespace( + profile=None, directory="~/custom", aws_account_name=None, podman=True + ) + ctx = _configs.Context(args) + assert ctx.profile == "default" + assert ctx.container_engine == "podman" + assert ctx.aws_directory == Path("~/custom").expanduser().absolute() + account = _configs.AwsAccount( + {"Account": ACCOUNT, "Arn": f"arn:aws-cn:iam::{ACCOUNT}:root"}, + "cn-north-1", + ("cn-north-1", "cn-northwest-1"), + ) + assert account.ecr_registries == [ + f"{ACCOUNT}.dkr.ecr.cn-north-1.amazonaws.com.cn", + f"{ACCOUNT}.dkr.ecr.cn-northwest-1.amazonaws.com.cn", + ] + assert _configs.AwsAccount({}, "us-east-1", ()).partition == "aws" + with pytest.raises(_configs.OperationalError, match="account ID"): + _ = _configs.AwsAccount({}, "us-east-1", ()).id + assert _configs.Result("X", "hello", stream="stderr").echo().code == "X" + assert capsys.readouterr().err == "hello\n" + + +def test_aws_login_logout_and_configuration_errors(tmp_path: Path) -> None: + aws_dir = tmp_path / "aws" + aws_dir.mkdir() + ctx = context(aws_dir) + write_ini(aws_dir / "config", {"profile dev": {"mfa_serial": "serial"}}) + write_ini( + aws_dir / "credentials", + {"dev": {"aws_access_key_id": "old", "aws_secret_access_key": "secret"}}, + ) + session = MagicMock() + session.client.return_value.get_session_token.return_value = { + "Credentials": { + "AccessKeyId": "new", + "SecretAccessKey": "newsecret", + "SessionToken": "token", + } + } + with patch("hacksaws._aws.boto3.Session", return_value=session): + _aws.login(ctx) + assert ctx.storage_path.exists() + _aws.logout(ctx) + restored = configparser.ConfigParser() + restored.read(ctx.credentials_path) + assert restored["dev"]["aws_access_key_id"] == "old" + with pytest.raises(_configs.OperationalError, match="does not exist"): + _aws._read_config(tmp_path / "none", description="credentials") + write_ini(aws_dir / "config", {"profile dev": {}}) + with pytest.raises(_configs.OperationalError, match="mfa_serial"): + _aws.login(ctx) + + +def test_ecr_engine_login_errors_and_partitioned_logout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ctx = context(tmp_path) + account = _configs.AwsAccount( + {"Account": ACCOUNT, "Arn": f"arn:aws-cn:iam::{ACCOUNT}:root"}, + "cn-north-1", + ("cn-northwest-1",), + ) + token = base64.b64encode(b"AWS:password").decode() + session = MagicMock() + session.client.return_value.get_authorization_token.return_value = { + "authorizationData": [ + { + "authorizationToken": token, + "expiresAt": datetime.now(UTC) + timedelta(hours=12), + } + ] + } + calls: list[list[str]] = [] + real_run_engine = _ecr._run_container_engine + monkeypatch.setattr( + _ecr, + "_run_container_engine", + partial(record_engine_call, calls), + ) + logged = _ecr.login_with_session(ctx, account, session) + assert logged == account.ecr_registries + assert all("amazonaws.com.cn" in item[-1] for item in calls) + _ecr.logout(ctx, account) + assert calls[-1][:2] == ["docker", "logout"] + with ( + patch("hacksaws._ecr.subprocess.run", side_effect=FileNotFoundError), + pytest.raises(_configs.OperationalError, match="not installed"), + ): + real_run_engine("podman", ["podman", "login"]) + session.client.return_value.get_authorization_token.return_value = { + "authorizationData": [ + {"authorizationToken": "%%", "expiresAt": datetime.now(UTC)} + ] + } + with pytest.raises(_configs.OperationalError, match="invalid ECR token"): + _ecr._do_login( + ctx, account_id=ACCOUNT, region_name="us-east-1", session=session + ) + + +def test_cli_operational_error_stdin_helper_and_main( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + args = argparse.Namespace(file="-", format="json") + fake_stdin = MagicMock() + fake_stdin.buffer = io.BytesIO(json.dumps(DOCUMENT).encode()) + with patch("hacksaws._cli.sys.stdin", fake_stdin): + path = _cli._stdin_policy(args) + assert _policies.parse_policy(path)[0] == DOCUMENT + with pytest.raises(_configs.OperationalError, match="requires --format"): + _cli._stdin_policy(argparse.Namespace(file="-", format=None)) + with patch( + "hacksaws._cli._sessions.recover_journal", + side_effect=_configs.OperationalError("broken"), + ): + assert _cli.console_main(["status"]).exit_code == 1 + with patch("hacksaws.console_main", return_value=_configs.Result("OK", "", 7)): + assert hacksaws.main() == 7 diff --git a/hacksaws/tests/test_sessions_coverage.py b/hacksaws/tests/test_sessions_coverage.py new file mode 100644 index 0000000..19d0659 --- /dev/null +++ b/hacksaws/tests/test_sessions_coverage.py @@ -0,0 +1,1100 @@ +"""Behavioral coverage for transactional session security workflows.""" + +from __future__ import annotations + +import argparse +import configparser +import json +import os +import subprocess +import zipfile +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from botocore.exceptions import ClientError + +from hacksaws import _configs +from hacksaws import _policies +from hacksaws import _sessions +from hacksaws import _state + +ACCOUNT = "123456789012" +OTHER_ACCOUNT = "210987654321" +ROLE = f"arn:aws:iam::{ACCOUNT}:role/Guard" +POLICY = b'Version: "2012-10-17"\nStatement: []\n' +RESTORE_ERROR = "restore warning" +INJECTED_ERROR = "injected after config write" + + +def _args(**overrides: object) -> argparse.Namespace: + values: dict[str, object] = { + "target": None, + "directory": ".", + "profile": "default", + "aws_account_name": None, + "to": None, + "to_directory": None, + "to_profile": "default", + "boundary": None, + "role": None, + "policy": None, + "external_id": None, + "account": None, + "session_name": None, + "region": None, + "duration": None, + "htl": None, + "mtl": None, + "stl": None, + "mfa_code": "123456", + "lifespan": 3600, + "ecr": False, + "ecr_region": None, + "podman": False, + "remote": False, + "probe": False, + "yes": False, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def _home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + root = tmp_path / "state" + monkeypatch.setenv("HACKSAWS_HOME", str(root)) + _state.save_config(_state.default_config()) + return root + + +def _configured( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + boundary: bool = True, +) -> tuple[Path, dict[str, object]]: + root = _home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + data = _state.default_config() + data["accounts"]["Prod"] = {"id": ACCOUNT, "partition": "aws"} + if boundary: + data["boundaries"]["Guard"] = { + "role_arn": ROLE, + "account": "Prod", + "duration": 3600, + "verified": False, + } + data["targets"]["Prod"] = { + "source_account": "Prod", + "source_profile": "dev", + "source_directory": str(aws), + "destination_directory": str(aws), + "destination_profile": "out", + **({"boundary": "Guard"} if boundary else {}), + } + _state.save_config(data) + return root, data + + +def _credentials(*, expiration: datetime | None = None) -> dict[str, object]: + return { + "AccessKeyId": "ASIAFINAL", + "SecretAccessKey": "secret", + "SessionToken": "token", + "Expiration": expiration or datetime.now(UTC) + timedelta(hours=1), + } + + +def _identity(account: str = ACCOUNT, partition: str = "aws") -> dict[str, str]: + return { + "Account": account, + "Arn": f"arn:{partition}:iam::{account}:user/test", + } + + +def _write_source(aws: Path) -> None: + aws.mkdir(parents=True, exist_ok=True) + (aws / "credentials").write_text( + "[dev]\naws_access_key_id = AKIAORIGINAL\n" + "aws_secret_access_key = original-secret\n", + encoding="utf-8", + ) + (aws / "config").write_text( + f"[profile dev]\nregion = us-west-2\noutput = json\n" + f"mfa_serial = arn:aws:iam::{ACCOUNT}:mfa/dev\n", + encoding="utf-8", + ) + + +def _archive(path: Path, config: dict[str, object], files: dict[str, bytes]) -> Path: + config_bytes = (json.dumps(config, indent=2) + "\n").encode() + payloads = {"config.json": config_bytes, **files} + manifest = { + "schema_version": 1, + "files": {name: _state.digest(content) for name, content in payloads.items()}, + } + with zipfile.ZipFile(path, "w") as output: + for name, content in payloads.items(): + output.writestr(name, content) + output.writestr("manifest.json", json.dumps(manifest)) + return path + + +def test_journal_commit_and_crash_recovery_restore_files_and_cache( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + original = tmp_path / "credentials" + created = tmp_path / "config" + cache = tmp_path / "cache" + old_cache = cache / "old.json" + original.write_bytes(b"original") + old_cache.parent.mkdir() + old_cache.write_bytes(b"cached") + + journal = _sessions._begin([original, created], cache_roots=[cache]) + assert _sessions._journal_path().exists() + original.write_bytes(b"changed") + created.write_bytes(b"new") + old_cache.write_bytes(b"changed-cache") + (cache / "new.json").write_bytes(b"new-cache") + + _sessions.recover_journal() + assert original.read_bytes() == b"original" + assert not created.exists() + assert old_cache.read_bytes() == b"cached" + assert not (cache / "new.json").exists() + assert not _sessions._journal_path().exists() + + _sessions._begin([]) + _sessions._commit() + assert not _sessions._journal_path().exists() + assert journal["safe_to_rollback"] is True + + +def test_recovery_rejects_corrupt_or_unsafe_journal( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = _home(tmp_path, monkeypatch) + journal = root / "transaction.json" + journal.write_text("not-json", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="unreadable"): + _sessions.recover_journal() + journal.write_text(json.dumps({"safe_to_rollback": False, "files": []})) + with pytest.raises(_configs.OperationalError, match="unsafe"): + _sessions.recover_journal() + + +def test_rollback_reports_ecr_cache_and_file_cleanup_failures( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + path = tmp_path / "credential" + path.write_bytes(b"before") + journal = _sessions._begin([path]) + journal.update(ecr_engine="docker", ecr_created=["one", "two"]) + path.write_bytes(b"after") + real_restore = _sessions._restore + + def fail_file(snapshot: dict[str, object]) -> None: + real_restore(snapshot) + raise OSError(RESTORE_ERROR) + + with ( + patch( + "hacksaws._ecr._run_container_engine", + side_effect=_configs.OperationalError("logout warning"), + ) as engine, + patch("hacksaws._sessions._restore", side_effect=fail_file), + pytest.raises( + _configs.OperationalError, match=r"automatic recovery.*incomplete" + ), + ): + _sessions._rollback(journal) + assert engine.call_args_list[0].args[1][-1] == "two" + assert path.read_bytes() == b"before" + assert _sessions._journal_path().exists() + + +def test_ini_snapshot_and_parser_errors_are_operational( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + path = tmp_path / "config" + path.write_text("[broken", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="Unable to parse AWS file"): + _sessions._read_ini(path) + with pytest.raises(_configs.OperationalError, match="original AWS file"): + _sessions._parser_from_bytes(b"\xff", path) + assert _sessions._section("default", config=True) == "default" + assert _sessions._section("dev", config=True) == "profile dev" + + +@pytest.mark.parametrize("arn", ["nope", "arn:moon:iam::123:user/x"]) +def test_identity_rejects_invalid_arn_partition(arn: str) -> None: + sts = MagicMock() + sts.get_caller_identity.return_value = {"Account": ACCOUNT, "Arn": arn} + session = MagicMock() + session.client.return_value = sts + with pytest.raises(_configs.OperationalError, match="invalid ARN"): + _sessions._identity(session, label="source") + + +def test_identity_rejects_client_and_account_errors() -> None: + session = MagicMock() + session.client.side_effect = ClientError( + {"Error": {"Code": "Denied", "Message": "no"}}, "GetCallerIdentity" + ) + with pytest.raises(_configs.OperationalError, match="Unable to verify"): + _sessions._identity(session, label="source") + session.client.side_effect = None + session.client.return_value.get_caller_identity.return_value = { + "Account": "12", + "Arn": "arn:aws:iam::12:user/x", + } + with pytest.raises(_configs.OperationalError, match="invalid account"): + _sessions._identity(session, label="source") + + +def test_path_resolution_supports_presets_locations_and_raw_destinations( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch, boundary=False) + source, profile, destination, out_profile = _sessions._paths(_args(target="+prod")) + assert (source, profile, destination, out_profile) == ( + tmp_path / "aws", + "dev", + tmp_path / "aws", + "out", + ) + with patch( + "hacksaws._state.aws_directory", side_effect=lambda value: tmp_path / str(value) + ): + assert _sessions._paths(_args(directory=".", to="backup:out"))[2:] == ( + tmp_path / "backup", + "out", + ) + assert ( + _sessions._paths(_args(aws_account_name="named"))[0] == tmp_path / "named" + ) + raw = _sessions._paths( + _args( + directory=str(tmp_path / "raw"), + to_directory=str(tmp_path / "dest"), + to_profile="x", + ) + ) + assert raw[2:] == ((tmp_path / "dest").absolute(), "x") + with pytest.raises(_configs.OperationalError, match="LOCATION:PROFILE"): + _sessions._paths(_args(to="invalid")) + + +def test_target_identity_and_role_account_partition_checks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + with pytest.raises(_configs.OperationalError, match="Source identity"): + _sessions._target_details(_args(target="Prod"), OTHER_ACCOUNT, "aws") + + target = _sessions._target_details(_args(target="Prod"), ACCOUNT, "aws") + assert target["boundary_name"] == "Guard" + role, _, _, boundary = _sessions._role_details( + _args(target="Prod"), target, ACCOUNT, "aws" + ) + assert role == ROLE + assert boundary == "Guard" + + data = _state.load_config() + data["accounts"]["Other"] = {"id": OTHER_ACCOUNT, "partition": "aws-cn"} + _state.save_config(data) + role, *_ = _sessions._role_details( + _args(account="Other", role="Worker"), {}, ACCOUNT, "aws" + ) + assert role == f"arn:aws-cn:iam::{OTHER_ACCOUNT}:role/Worker" + with pytest.raises(_configs.OperationalError, match="conflicts with --account"): + _sessions._role_details(_args(account="Other", role=ROLE), {}, ACCOUNT, "aws") + + target["boundary_data"]["role_arn"] = f"arn:aws:iam::{OTHER_ACCOUNT}:role/Guard" + target["boundary_data"]["account"] = "Prod" + with pytest.raises(_configs.OperationalError, match="Boundary role account"): + _sessions._role_details(_args(), target, ACCOUNT, "aws") + + +@pytest.mark.parametrize( + ("role", "message"), + [("arn:invalid", "Invalid role ARN"), (None, "require a concrete role")], +) +def test_role_validation_rejects_invalid_or_missing_operands( + role: str | None, message: str +) -> None: + if role: + with pytest.raises(_configs.OperationalError, match=message): + _sessions._role_details(_args(role=role), {}, ACCOUNT, "aws") + else: + with pytest.raises(_configs.OperationalError, match=message): + _sessions._require_concrete_role(_args(policy="Read"), role) + _sessions._require_concrete_role(_args(), None) + + +def test_configured_role_and_session_name_resolution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + assert _sessions._configured_role_before_auth(_args(role=ROLE)) == ROLE + assert _sessions._configured_role_before_auth(_args(target="Prod")) == ROLE + assert _sessions._configured_role_before_auth(_args()) is None + assert _sessions._session_name(ROLE, None, " ! ").startswith("hacksaws-") + assert len(_sessions._session_name(ROLE, "Guard", "x" * 100)) == 64 + + +@pytest.mark.parametrize( + ("duration", "token", "message"), + [("10m", None, "at least 900"), ("2h", "chained", "at most 3600")], +) +def test_assume_duration_security_limits( + duration: str, token: str | None, message: str +) -> None: + session = MagicMock() + session.get_credentials.return_value.token = token + with pytest.raises(_configs.OperationalError, match=message): + _sessions._assume( + session, + ROLE, + policy=None, + source_profile="default", + args=_args(duration=duration), + target={}, + external_id=None, + boundary_name=None, + ) + + +@pytest.mark.parametrize("document_policy", [False, True]) +def test_assume_builds_policy_request_and_verifies_final_identity( + document_policy: bool, +) -> None: + source = MagicMock() + source.get_credentials.return_value.token = None + iam = MagicMock() + iam.get_role.return_value = {"Role": {"MaxSessionDuration": "7200"}} + sts = MagicMock() + response = {"Credentials": _credentials()} + sts.assume_role.return_value = response + source.client.side_effect = lambda service: {"iam": iam, "sts": sts}[service] + resolved = SimpleNamespace( + arn=None if document_policy else f"arn:aws:iam::{ACCOUNT}:policy/Read", + document='{"Version":"2012-10-17","Statement":[]}' if document_policy else None, + identity="Read", + provenance="stored", + ) + final = MagicMock() + with ( + patch("hacksaws._policies.resolve", return_value=resolved), + patch("hacksaws._sessions.boto3.Session", return_value=final) as factory, + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + ): + credentials, metadata = _sessions._assume( + source, + ROLE, + policy="Read", + source_profile="dev", + args=_args(duration="1h", session_name="session"), + target={}, + external_id="external", + boundary_name="Guard", + ) + request = sts.assume_role.call_args.kwargs + expected_policy_key = "Policy" if document_policy else "PolicyArns" + assert expected_policy_key in request + assert request["ExternalId"] == "external" + assert credentials == response["Credentials"] + assert metadata["policy_provenance"] == "stored" + assert factory.call_args.kwargs["aws_access_key_id"] == "ASIAFINAL" + + +def test_assume_wraps_sts_error_and_rejects_final_identity() -> None: + source = MagicMock() + source.get_credentials.return_value.token = None + source.client.return_value.get_role.side_effect = ValueError("unknown maximum") + source.client.return_value.assume_role.side_effect = ClientError( + {"Error": {"Code": "Denied", "Message": "no"}}, "AssumeRole" + ) + with pytest.raises(_configs.OperationalError, match="Unable to assume"): + _sessions._assume( + source, + ROLE, + policy=None, + source_profile="dev", + args=_args(), + target={}, + external_id=None, + boundary_name=None, + ) + + source.client.return_value.assume_role.side_effect = None + source.client.return_value.assume_role.return_value = { + "Credentials": _credentials() + } + with ( + patch("hacksaws._sessions.boto3.Session"), + patch( + "hacksaws._sessions._identity", + return_value=(OTHER_ACCOUNT, "aws", "arn"), + ), + pytest.raises(_configs.OperationalError, match="identity mismatch"), + ): + _sessions._assume( + source, + ROLE, + policy=None, + source_profile="dev", + args=_args(), + target={}, + external_id=None, + boundary_name=None, + ) + + +def test_persistent_source_and_mfa_session_validate_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + aws.mkdir() + with pytest.raises(_configs.OperationalError, match="missing"): + _sessions._persistent_source(aws, "dev") + (aws / "credentials").write_text("[dev]\naws_access_key_id=x\n") + with pytest.raises(_configs.OperationalError, match="readable access keys"): + _sessions._persistent_source(aws, "dev") + + source = MagicMock(region_name="us-east-1") + config = configparser.ConfigParser() + with pytest.raises(_configs.OperationalError, match="mfa_serial"): + _sessions._mfa_session(source, config, "dev", "123456", 3600) + config.read_dict({"profile dev": {"mfa_serial": "arn:mfa"}}) + source.client.return_value.get_session_token.side_effect = ClientError( + {"Error": {"Code": "Denied", "Message": "bad code"}}, "GetSessionToken" + ) + with pytest.raises(_configs.OperationalError, match="Unable to start MFA"): + _sessions._mfa_session(source, config, "dev", "000000", 3600) + + +def test_mfa_session_and_raw_login_success( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + _write_source(aws) + raw = MagicMock(region_name="us-west-2") + sts = raw.client.return_value + sts.get_session_token.return_value = {"Credentials": _credentials()} + intermediate = MagicMock(region_name="us-west-2") + frozen = ( + intermediate.get_credentials.return_value.get_frozen_credentials.return_value + ) + frozen.access_key = "ASIAMFA" + frozen.secret_key = "mfa-" + "secret" + frozen.token = "mfa-" + "token" + with patch( + "hacksaws._sessions.boto3.Session", return_value=intermediate + ) as factory: + config = _sessions._read_ini(aws / "config") + assert ( + _sessions._mfa_session(raw, config, "dev", "123456", 3600) is intermediate + ) + assert factory.call_args.kwargs["region_name"] == "us-west-2" + + with ( + patch("hacksaws._sessions._persistent_source", return_value=(raw, config)), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch("hacksaws._sessions._mfa_session", return_value=intermediate), + ): + result = _sessions.mfa_login( + _configs.Context(_args(directory=str(aws), profile="dev")) + ) + assert result.code == "MFA_LOGIN" + parser = _sessions._read_ini(aws / "credentials") + assert parser["dev"]["aws_access_key_id"] == "ASIAMFA" + assert _state.load_sessions()[f"{aws.absolute()}::dev"]["auth_method"] == "mfa" + assert not _sessions._journal_path().exists() + + +def test_bounded_mfa_login_records_ecr_and_final_tier( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + aws = tmp_path / "aws" + _write_source(aws) + raw = MagicMock() + intermediate = MagicMock(region_name="us-west-2") + final = _credentials() + registry = f"{ACCOUNT}.dkr.ecr.us-west-2.amazonaws.com" + + def login(*args: object, on_success: object, **kwargs: object) -> list[str]: + on_success(registry) # type: ignore[operator] + return [registry] + + with ( + patch("hacksaws._sessions._persistent_source", return_value=(raw, MagicMock())), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch("hacksaws._sessions._mfa_session", return_value=intermediate), + patch( + "hacksaws._sessions._assume", + return_value=(final, {"target_account": ACCOUNT, "expires_at": None}), + ) as assume, + patch("hacksaws._ecr.login_with_session", side_effect=login), + ): + result = _sessions.mfa_login( + _configs.Context(_args(target="Prod", ecr=True, ecr_region=["us-east-1"])) + ) + assert result.code == "MFA_LOGIN" + assume.assert_called_once() + saved = _state.load_sessions()[f"{aws.absolute()}::out"] + assert saved["ecr"] == [registry] + assert saved["target"] == "Prod" + + +@pytest.mark.parametrize( + ("output", "error"), + [ + ("aws-cli/2.32.0 Python/3", None), + ("aws-cli/2.31.9 Python/3", "newer"), + ("aws-cli/1.99.0 Python/3", "newer"), + ("garbage", "unknown version"), + ], +) +def test_aws_cli_version_validation(output: str, error: str | None) -> None: + completed = subprocess.CompletedProcess(["aws"], 0, stdout=output, stderr="") + with patch("hacksaws._sessions.subprocess.run", return_value=completed): + if error: + with pytest.raises(_configs.OperationalError, match=error): + _sessions._aws_cli_version() + else: + assert _sessions._aws_cli_version() == (2, 32, 0) + with ( + patch( + "hacksaws._sessions.subprocess.run", side_effect=FileNotFoundError("aws") + ), + pytest.raises(_configs.OperationalError, match="required for browser"), + ): + _sessions._aws_cli_version() + + +def test_aws_environment_scrubs_and_restores_all_conflicts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + for key in _sessions._CONFLICTING_ENV: + monkeypatch.setenv(key, f"old-{key}") + config = tmp_path / "config" + credentials = tmp_path / "credentials" + cleaned = _sessions._clean_env(config, credentials) + assert cleaned["AWS_CONFIG_FILE"] == str(config) + assert "AWS_PROFILE" not in cleaned + with _sessions._aws_environment(config, credentials): + assert os.environ["AWS_CONFIG_FILE"] == str(config) + assert "AWS_PROFILE" not in os.environ + for key in _sessions._CONFLICTING_ENV: + assert os.environ[key] == f"old-{key}" + + +def test_aws_login_passes_remote_and_wraps_subprocess_errors(tmp_path: Path) -> None: + config = tmp_path / "nested" / "config" + credentials = tmp_path / "nested" / "credentials" + with ( + patch("hacksaws._sessions._aws_cli_version"), + patch("hacksaws._sessions.subprocess.run") as run, + ): + _sessions._aws_login(config, credentials, "dev", remote=True) + assert run.call_args.args[0] == ["aws", "login", "--profile", "dev", "--remote"] + assert run.call_args.kwargs["env"]["AWS_CONFIG_FILE"] == str(config) + with ( + patch("hacksaws._sessions._aws_cli_version"), + patch( + "hacksaws._sessions.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "aws"), + ), + pytest.raises(_configs.OperationalError, match="browser login failed"), + ): + _sessions._aws_login(config, credentials, "dev", remote=False) + + +def test_native_browser_remote_cache_ecr_success_and_logout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch, boundary=False) + aws = tmp_path / "aws" + old_cache = aws / "login" / "cache" / "old.json" + old_cache.parent.mkdir(parents=True) + old_cache.write_text("old") + new_cache = old_cache.with_name("new.json") + native = MagicMock(region_name="us-west-2") + registry = f"{ACCOUNT}.dkr.ecr.us-west-2.amazonaws.com" + + def login(*args: object, **kwargs: object) -> None: + new_cache.write_text("new") + + with ( + patch("hacksaws._sessions._aws_login", side_effect=login) as aws_login, + patch("hacksaws._sessions.boto3.Session", return_value=native), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch("hacksaws._ecr.login_with_session", return_value=[registry]), + ): + result = _sessions.browser_login( + _configs.Context(_args(target="Prod", remote=True, ecr=True)) + ) + assert result.code == "BROWSER_LOGIN" + assert aws_login.call_args.kwargs["remote"] is True + saved = _state.load_sessions()[f"{aws.absolute()}::out"] + assert saved["login_cache_files"] == [str(new_cache.absolute())] + assert old_cache.exists() + + with patch("hacksaws._ecr._run_container_engine") as engine: + assert ( + _sessions.logout(_configs.Context(_args(target="Prod", ecr=True))) is True + ) + assert not new_cache.exists() + assert old_cache.exists() + engine.assert_called_once() + + +def test_bounded_browser_success_removes_login_session_and_staging( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, _ = _configured(tmp_path, monkeypatch) + aws = tmp_path / "aws" + intermediate = MagicMock(region_name="us-west-2") + + def login(config: Path, credentials: Path, *args: object, **kwargs: object) -> None: + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text("[profile dev]\nregion=us-west-2\nlogin_session=x\n") + credentials.write_text("[dev]\na=x\n") + + with ( + patch("hacksaws._sessions._aws_login", side_effect=login), + patch("hacksaws._sessions.boto3.Session", return_value=intermediate), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch( + "hacksaws._sessions._assume", + return_value=(_credentials(), {"target_account": ACCOUNT}), + ), + ): + result = _sessions.browser_login(_configs.Context(_args(target="Prod"))) + assert result.code == "BROWSER_LOGIN" + config = _sessions._read_ini(aws / "config") + assert "login_session" not in config["profile out"] + assert config["profile out"]["region"] == "us-west-2" + assert not (root / "staging").exists() or not any((root / "staging").iterdir()) + + +def test_bounded_browser_restores_environment_when_assume_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + monkeypatch.setenv("AWS_CONFIG_FILE", "original-config") + monkeypatch.delenv("AWS_SHARED_CREDENTIALS_FILE", raising=False) + with ( + patch("hacksaws._sessions._aws_login"), + patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch("hacksaws._sessions._assume", side_effect=RuntimeError("after-auth")), + pytest.raises(RuntimeError, match="after-auth"), + ): + _sessions.browser_login(_configs.Context(_args(target="Prod"))) + assert os.environ["AWS_CONFIG_FILE"] == "original-config" + assert "AWS_SHARED_CREDENTIALS_FILE" not in os.environ + + +def test_record_preserves_original_backup_and_merges_cache_and_ecr( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + destination = tmp_path / "aws" + key = f"{destination.absolute()}::dev" + old_backup = [{"path": "old", "exists": False}] + _state.save_sessions( + { + key: { + "backup": old_backup, + "ecr": ["old-registry"], + "login_cache_files": ["old-cache"], + } + } + ) + _sessions._record( + destination, + "dev", + {"login_cache_files": ["new-cache"]}, + {"files": [{"path": "new", "exists": False}]}, + method="browser-native", + ecr=["old-registry", "new-registry"], + ) + saved = _state.load_sessions()[key] + assert saved["backup"] == old_backup + assert saved["login_cache_files"] == ["old-cache", "new-cache"] + assert saved["ecr"] == ["old-registry", "new-registry"] + + +def test_logout_missing_snapshot_ecr_only_and_cache_path_guard( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + outside = tmp_path / "outside.json" + outside.write_text("keep") + cache = aws / "login" / "cache" / "owned.json" + cache.parent.mkdir(parents=True) + cache.write_text("delete") + key = f"{aws.absolute()}::dev" + _state.save_sessions( + { + key: { + "destination": str(aws.absolute()), + "profile": "dev", + "auth_method": "browser-native", + "backup": [{"path": str(_state.sessions_path()), "exists": False}], + "login_cache_files": [str(cache), str(outside)], + "ecr": [], + } + } + ) + context = _configs.Context(_args(directory=str(aws), profile="dev")) + assert _sessions.logout(context) is True + assert not cache.exists() + assert outside.exists() + assert _sessions.logout(context) is False + + +def test_status_is_secret_free_and_handles_expiry_values( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + future = (datetime.now(UTC) + timedelta(minutes=5)).isoformat() + _state.save_sessions( + { + "a": {"backup": ["secret"], "expires_at": future, "profile": "a"}, + "b": {"backup": ["secret"], "expires_at": "bad", "profile": "b"}, + } + ) + result = _sessions.status() + assert "backup" not in result[0] + assert result[0]["remaining_seconds"] > 0 + assert result[1]["remaining_seconds"] is None + + +def test_explain_target_resolves_locations_defaults_and_boundary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + explained = _sessions.explain_target("+prod") + assert explained["source"]["account_id"] == ACCOUNT + assert explained["destination"]["profile"] == "out" + assert explained["boundary"]["duration"] == 3600 + data = _state.load_config() + data["targets"]["Prod"].pop("boundary") + data["targets"]["Prod"].pop("source_directory") + data["targets"]["Prod"].pop("destination_directory") + data["targets"]["Prod"]["source_location"] = "source" + data["targets"]["Prod"]["destination_location"] = "destination" + _state.save_config(data) + with patch( + "hacksaws._state.aws_directory", side_effect=lambda value: tmp_path / str(value) + ): + explained = _sessions.explain_target("Prod") + assert explained["boundary"] is None + assert explained["destination"]["directory"] == str(tmp_path / "destination") + + +def test_check_config_local_parse_error_and_load_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + with patch( + "hacksaws._state.load_config", + side_effect=_configs.OperationalError("bad config"), + ): + assert _sessions.check_config(_args())["errors"] == ["bad config"] + data = _state.default_config() + data["policies"]["Bad"] = {"file": "stored_session_policies/Bad.yaml"} + _state.save_config(data) + with patch( + "hacksaws._policies.parse_policy", + side_effect=_configs.OperationalError("bad policy"), + ): + report = _sessions.check_config(_args()) + assert report == {"ok": False, "errors": ["bad policy"], "warnings": []} + + +def test_remote_check_scopes_roles_probes_and_restores_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + data = _state.load_config() + data["accounts"]["Other"] = {"id": OTHER_ACCOUNT, "partition": "aws"} + data["boundaries"]["Other"] = { + "role_arn": f"arn:aws:iam::{OTHER_ACCOUNT}:role/Other", + "account": "Other", + "verified": False, + } + data["boundaries"]["Guard"]["external_id"] = "external" + _state.save_config(data) + monkeypatch.setenv("AWS_PROFILE", "original") + session = MagicMock() + iam = MagicMock() + iam.get_role.side_effect = ClientError( + {"Error": {"Code": "NoSuchEntity", "Message": "missing"}}, "GetRole" + ) + sts = MagicMock() + session.client.side_effect = lambda service: {"iam": iam, "sts": sts}[service] + with ( + patch("hacksaws._sessions.boto3.Session", return_value=session), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + ): + report = _sessions.check_config( + _args(target="Prod", account="Prod", remote=True, probe=True) + ) + assert any("Using target Prod" in warning for warning in report["warnings"]) + assert any("deny-all" in warning for warning in report["warnings"]) + assert any("Boundary Guard: missing" in error for error in report["errors"]) + assert iam.get_role.call_count == 1 + assert sts.assume_role.call_args.kwargs["ExternalId"] == "external" + assert os.environ["AWS_PROFILE"] == "original" + + +def test_remote_check_account_mismatch_role_and_probe_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + session = MagicMock() + iam = MagicMock() + iam.get_role.side_effect = ClientError( + {"Error": {"Code": "Denied", "Message": "no"}}, "GetRole" + ) + sts = MagicMock() + sts.assume_role.side_effect = ClientError( + {"Error": {"Code": "Denied", "Message": "no"}}, "AssumeRole" + ) + session.client.side_effect = lambda service: {"iam": iam, "sts": sts}[service] + with ( + patch("hacksaws._sessions.boto3.Session", return_value=session), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + ): + report = _sessions.check_config(_args(account="Prod", remote=True, probe=True)) + assert any("unverifiable" in error for error in report["errors"]) + assert any("probe failed" in error for error in report["errors"]) + + +@pytest.mark.parametrize( + ("answers", "expected_code", "exists"), + [(["l"], "CONFIG_FIX_UNRESOLVED", True), (["x"], "CONFIG_FIX", False)], +) +def test_fix_config_leave_or_remove_orphan_policy( + answers: list[str], + expected_code: str, + exists: bool, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _home(tmp_path, monkeypatch) + data = _state.load_config() + data["policies"]["Bad"] = {"file": "stored_session_policies/Bad.yaml"} + _state.save_config(data) + policy = _policies.stored_directory() / "Bad.yaml" + policy.parent.mkdir(parents=True, exist_ok=True) + policy.write_text("bad") + with ( + patch("hacksaws._sessions.sys.stdin.isatty", return_value=True), + patch("builtins.input", side_effect=answers), + patch( + "hacksaws._policies.parse_policy", + side_effect=_configs.OperationalError("invalid"), + ), + ): + result = _sessions.fix_config(_args()) + assert result.code == expected_code + assert policy.exists() is exists + assert bool(_state.load_config()["policies"]) is exists + assert list((_state.root() / "backups").glob("config-*.json")) + + +def test_fix_config_repairs_policy_and_scopes_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + data = _state.load_config() + data["policies"]["Bad"] = {"file": "stored_session_policies/Bad.yaml"} + data["policies"]["Ignored"] = {"file": "stored_session_policies/Ignored.yaml"} + data["boundaries"]["Guard"]["policy"] = "Bad" + _state.save_config(data) + replacement = tmp_path / "replacement.json" + replacement.write_text('{"Version":"2012-10-17","Statement":[]}') + + def parse(path: Path) -> tuple[dict[str, object], bytes]: + if path == replacement: + return {"Version": "2012-10-17", "Statement": []}, path.read_bytes() + raise _configs.OperationalError("invalid") + + with ( + patch("hacksaws._sessions.sys.stdin.isatty", return_value=True), + patch("builtins.input", side_effect=["r", str(replacement)]), + patch("hacksaws._policies.parse_policy", side_effect=parse), + ): + result = _sessions.fix_config(_args(account="Prod")) + assert result.code == "CONFIG_FIX" + repaired = (_policies.stored_directory() / "Bad.yaml").read_text() + assert "Version" in repaired + + +def test_fix_config_failed_repair_is_unresolved( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + data = _state.load_config() + data["policies"]["Bad"] = {"file": "stored_session_policies/Bad.yaml"} + _state.save_config(data) + with ( + patch("hacksaws._sessions.sys.stdin.isatty", return_value=True), + patch("builtins.input", side_effect=["r", "missing.yaml"]), + patch( + "hacksaws._policies.parse_policy", + side_effect=_configs.OperationalError("still invalid"), + ), + ): + result = _sessions.fix_config(_args()) + assert result.exit_code == 1 + assert "remain unresolved" in result.message + + +def test_export_import_stored_and_external_policies( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + stored = _policies.stored_directory() / "Stored.yaml" + stored.parent.mkdir(parents=True, exist_ok=True) + stored.write_bytes(POLICY) + external = tmp_path / "external.json" + external.write_text('{"Version":"2012-10-17","Statement":[]}') + data = _state.load_config() + data["policies"]["Stored"] = {"file": "stored_session_policies/Stored.yaml"} + data["boundaries"]["Guard"]["policy"] = str(external) + _state.save_config(data) + archive = _sessions.export_config(str(tmp_path / "portable.zip")) + with zipfile.ZipFile(archive) as zipped: + names = zipped.namelist() + assert "stored_session_policies/Stored.yaml" in names + assert any(name.startswith("external_policies/") for name in names) + + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "imported")) + _state.save_config(_state.default_config()) + message = _sessions.import_config(archive, replace=False, yes=False) + imported = _state.load_config() + promoted = imported["boundaries"]["Guard"]["policy"] + assert message.endswith("Imported portable configuration.") + assert promoted.startswith("imported-") + assert (_policies.stored_directory() / f"{promoted}.yaml").exists() + + +def test_export_rejects_missing_external_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configured(tmp_path, monkeypatch) + data = _state.load_config() + data["boundaries"]["Guard"]["policy"] = str(tmp_path / "missing.json") + _state.save_config(data) + with pytest.raises(_configs.OperationalError, match="does not exist"): + _sessions.export_config(None) + + +def test_import_conflict_cancel_and_noninteractive_guards( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = _state.default_config() + source["accounts"]["Prod"] = {"id": ACCOUNT, "partition": "aws"} + archive = _archive(tmp_path / "config.zip", source, {}) + _home(tmp_path, monkeypatch) + current = _state.load_config() + current["accounts"]["prod"] = {"id": OTHER_ACCOUNT, "partition": "aws"} + _state.save_config(current) + with pytest.raises(_configs.OperationalError, match="require --replace"): + _sessions.import_config(archive, replace=False, yes=False) + with ( + patch("hacksaws._sessions.sys.stdin.isatty", return_value=False), + pytest.raises(_configs.OperationalError, match="requires --yes"), + ): + _sessions.import_config(archive, replace=True, yes=False) + with ( + patch("hacksaws._sessions.sys.stdin.isatty", return_value=True), + patch("builtins.input", return_value="n"), + pytest.raises(_configs.OperationalError, match="cancelled"), + ): + _sessions.import_config(archive, replace=True, yes=False) + + +@pytest.mark.parametrize( + ("members", "message"), + [ + ({"../config.json": b"{}"}, "unsafe path"), + ({"config.json": b"{}"}, "missing manifest"), + ], +) +def test_import_rejects_unsafe_or_incomplete_archives( + members: dict[str, bytes], + message: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _home(tmp_path, monkeypatch) + archive = tmp_path / "bad.zip" + with zipfile.ZipFile(archive, "w") as output: + for name, content in members.items(): + output.writestr(name, content) + with pytest.raises(_configs.OperationalError, match=message): + _sessions.import_config(archive, replace=False, yes=False) + + +def test_import_wraps_bad_zip_and_rolls_back_after_policy_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + bad = tmp_path / "bad.zip" + bad.write_text("not zip") + with pytest.raises(_configs.OperationalError, match="Unable to import archive"): + _sessions.import_config(bad, replace=False, yes=False) + + config = _state.default_config() + config["policies"]["Read"] = {"file": "stored_session_policies/Read.yaml"} + archive = _archive( + tmp_path / "policy.zip", + config, + {"stored_session_policies/Read.yaml": POLICY}, + ) + original = (_state.root() / "config.json").read_bytes() + real_save = _state.save_config + + def fail_after_write(data: dict[str, object]) -> None: + real_save(data) # side effect that rollback must undo + raise RuntimeError(INJECTED_ERROR) + + with ( + patch("hacksaws._state.save_config", side_effect=fail_after_write), + pytest.raises(RuntimeError, match="injected"), + ): + _sessions.import_config(archive, replace=False, yes=False) + assert (_state.root() / "config.json").read_bytes() == original + assert not (_policies.stored_directory() / "Read.yaml").exists() + + +def test_shared_test_runner_preserves_pytest_exit_code() -> None: + from hacksaws import _test_runner + + completed: subprocess.CompletedProcess[str] = subprocess.CompletedProcess( + ["pytest"], 7 + ) + with patch("hacksaws._test_runner.subprocess.run", return_value=completed) as run: + assert _test_runner.main() == 7 + assert run.call_args.args[0][1:3] == ["-m", "pytest"] + assert "--cov-fail-under=95" in run.call_args.args[0] diff --git a/hacksaws/tests/test_v04.py b/hacksaws/tests/test_v04.py new file mode 100644 index 0000000..59ee973 --- /dev/null +++ b/hacksaws/tests/test_v04.py @@ -0,0 +1,981 @@ +"""Focused offline coverage for the v0.4 configuration and grammar contract.""" + +from __future__ import annotations + +import argparse +import json +import zipfile +from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from botocore.exceptions import ClientError + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _duration +from hacksaws import _ecr +from hacksaws import _policies +from hacksaws import _sessions +from hacksaws import _state + + +@pytest.mark.parametrize( + ("value", "seconds"), + [("1s", 1), ("1.5minutes", 90), (".5h", 1800), ("2 HR", 7200)], +) +def test_exact_duration_parser(value: str, seconds: int) -> None: + assert _duration.parse_duration(value) == seconds + + +@pytest.mark.parametrize("value", ["1h30m", "1", "-1h", "0s", "infinityh"]) +def test_duration_rejects_compound_or_nonpositive(value: str) -> None: + with pytest.raises(_configs.OperationalError): + _duration.parse_duration(value) + + +def test_cache_duration_uniquely_allows_zero() -> None: + assert _duration.parse_duration("0s", allow_zero=True) == 0 + + +def test_parser_supports_target_shorthand_and_web_alias() -> None: + mfa = _cli._create_parser().parse_args(["mfa", "in", "+prod", "123456"]) + web = _cli._create_parser().parse_args(["web", "login", "--remote"]) + assert mfa.profile == "+prod" + assert web.access_type == "web" + assert web.remote is True + + +def _minimal_target(home: Path, *, boundary: bool = False) -> None: + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + if boundary: + data["boundaries"]["Guard"] = { + "role_arn": "arn:aws:iam::123456789012:role/guard", + "account": "Prod", + "verified": False, + } + data["targets"]["Prod"] = { + "source_account": "Prod", + "source_profile": "default", + "source_directory": str(home / "aws"), + **({"boundary": "Guard"} if boundary else {}), + } + _state.save_config(data) + + +def test_remote_name_listing_failure_is_only_same_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + session = MagicMock() + sts = MagicMock() + sts.get_caller_identity.return_value = { + "Account": "123456789012", + "Arn": "arn:aws:iam::123456789012:user/test", + } + iam = MagicMock() + iam.get_paginator.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "no list"}}, "ListPolicies" + ) + session.client.side_effect = lambda service: {"sts": sts, "iam": iam}[service] + resolved = _policies.resolve( + "Named", + account_id="123456789012", + partition="aws", + session=session, + ) + assert resolved.arn == "arn:aws:iam::123456789012:policy/Named" + with pytest.raises(_configs.OperationalError, match="authenticated resolver"): + _policies.resolve( + "Other", + account_id="999999999999", + partition="aws", + session=session, + ) + + +def test_explicit_policy_arn_partition_must_match_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + with pytest.raises(_configs.OperationalError, match="partition"): + _policies.resolve( + "arn:aws-cn:iam::123456789012:policy/X", + account_id="123456789012", + partition="aws", + ) + + +@pytest.mark.parametrize( + ("flag", "value"), + [ + ("--policy", "Read"), + ("--duration", "1h"), + ("--account", "Prod"), + ("--external-id", "id"), + ("--session-name", "name"), + ], +) +def test_unbounded_target_role_operands_fail_before_browser_auth( + flag: str, + value: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + _minimal_target(tmp_path) + with patch("hacksaws._sessions._aws_login") as aws_login: + result = _cli.console_main(["web", "in", "+Prod", flag, value]) + assert result.exit_code == 1 + aws_login.assert_not_called() + + +@pytest.mark.parametrize( + "arguments", + [ + ["--role", "Other"], + ["--policy", "Read"], + ["--account", "Prod"], + ["--external-id", "id"], + ["--session-name", "name"], + ["--to", "default:other"], + ["--boundary", "Other"], + ], +) +def test_bounded_target_rejects_security_overrides( + arguments: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + _minimal_target(tmp_path, boundary=True) + result = _cli.console_main(["web", "in", "+Prod", *arguments]) + assert result.exit_code == 1 + + +def test_unbounded_target_may_add_named_boundary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + _minimal_target(tmp_path, boundary=True) + data = _state.load_config() + data["targets"]["Prod"].pop("boundary") + _state.save_config(data) + namespace = _cli._create_parser().parse_args( + ["web", "in", "+Prod", "--boundary", "Guard"] + ) + _cli._validate_login(namespace) + + +def test_ecr_partial_success_is_journaled_and_rolled_back( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + journal = _sessions._begin([]) + context = MagicMock() + context.container_engine = "docker" + account = _configs.AwsAccount( + {"Account": "123456789012"}, "us-east-1", ("us-west-2",) + ) + first = "123456789012.dkr.ecr.us-east-1.amazonaws.com" + with ( + patch( + "hacksaws._ecr._do_login", + side_effect=[first, _configs.OperationalError("second failed")], + ), + patch("hacksaws._ecr._run_container_engine") as engine, + ): + try: + with pytest.raises(_configs.OperationalError, match="second failed"): + _ecr.login_with_session( + context, + account, + MagicMock(), + on_success=lambda registry: _sessions._record_ecr_in_journal( + journal, "docker", registry + ), + ) + finally: + _sessions._rollback(journal) + engine.assert_called_once_with("docker", ["docker", "logout", first], check=False) + + +def test_relogin_reads_original_source_snapshot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + aws_dir = tmp_path / "aws" + aws_dir.mkdir() + original_credentials = ( + b"[dev]\naws_access_key_id = ORIGINAL\naws_secret_access_key = secret\n" + ) + original_config = b"[profile dev]\nmfa_serial = arn:aws:iam::123456789012:mfa/dev\n" + (aws_dir / "credentials").write_text( + "[dev]\naws_access_key_id = CURRENT\naws_secret_access_key = boundary\n", + encoding="utf-8", + ) + (aws_dir / "config").write_bytes(original_config) + _state.save_sessions( + { + f"{aws_dir.absolute()}::dev": { + "backup": [ + { + "path": str(aws_dir / "credentials"), + "exists": True, + "data": __import__("base64") + .b64encode(original_credentials) + .decode(), + }, + { + "path": str(aws_dir / "config"), + "exists": True, + "data": __import__("base64") + .b64encode(original_config) + .decode(), + }, + ] + } + } + ) + with patch("boto3.Session") as session_factory: + _sessions._persistent_source(aws_dir, "dev") + assert session_factory.call_args.kwargs["aws_access_key_id"] == "ORIGINAL" + + +def test_native_browser_cache_is_removed_after_identity_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + _minimal_target(tmp_path) + cache_file = tmp_path / "aws" / "login" / "cache" / "new.json" + + def fake_login(*args: object, **kwargs: object) -> None: + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text("{}", encoding="utf-8") + + namespace = _cli._create_parser().parse_args(["web", "in", "+Prod"]) + _cli._validate_login(namespace) + with ( + patch("hacksaws._sessions._aws_login", side_effect=fake_login), + patch("boto3.Session", return_value=MagicMock()), + patch( + "hacksaws._sessions._identity", + side_effect=_configs.OperationalError("identity failed"), + ), + pytest.raises(_configs.OperationalError, match="identity failed"), + ): + _sessions.browser_login(_configs.Context(namespace)) + assert not cache_file.exists() + + +def test_import_rejects_extra_and_corrupt_members( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + _state.save_config(_state.default_config()) + archive = _sessions.export_config(str(tmp_path / "export.zip")) + with zipfile.ZipFile(archive, "a") as zipped: + zipped.writestr("unexpected.txt", b"extra") + with pytest.raises(_configs.OperationalError, match="member set"): + _sessions.import_config(archive, replace=False, yes=False) + + clean = _sessions.export_config(str(tmp_path / "clean.zip")) + with zipfile.ZipFile(clean, "a") as zipped: + zipped.writestr("config.json", b"{}") + with pytest.raises(_configs.OperationalError, match="duplicate"): + _sessions.import_config(clean, replace=False, yes=False) + + +def _archive_payloads(archive: Path) -> dict[str, bytes]: + with zipfile.ZipFile(archive) as zipped: + return {name: zipped.read(name) for name in zipped.namelist()} + + +def _write_archive(archive: Path, payloads: dict[str, bytes]) -> None: + with zipfile.ZipFile(archive, "w") as zipped: + for name, content in payloads.items(): + zipped.writestr(name, content) + + +def test_import_manifest_schema_version_rejects_boolean( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + _state.save_config(_state.default_config()) + payloads = _archive_payloads(_sessions.export_config(str(tmp_path / "clean.zip"))) + manifest = json.loads(payloads["manifest.json"]) + manifest["schema_version"] = True + payloads["manifest.json"] = json.dumps(manifest).encode() + attack = tmp_path / "boolean-version.zip" + _write_archive(attack, payloads) + + with pytest.raises(_configs.OperationalError, match="manifest schema"): + _sessions.import_config(attack, replace=False, yes=False) + + +def test_import_rejects_ambient_absolute_boundary_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + _state.save_config(_state.default_config()) + payloads = _archive_payloads(_sessions.export_config(str(tmp_path / "clean.zip"))) + imported = json.loads(payloads["config.json"]) + imported["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + imported["boundaries"]["Ambient"] = { + "role_arn": "arn:aws:iam::123456789012:role/ambient", + "account": "Prod", + "policy": "C:/preexisting/ambient-policy.yaml", + "verified": False, + } + payloads["config.json"] = json.dumps(imported).encode() + manifest = json.loads(payloads["manifest.json"]) + manifest["files"]["config.json"] = _state.digest(payloads["config.json"]) + payloads["manifest.json"] = json.dumps(manifest).encode() + attack = tmp_path / "ambient-policy.zip" + _write_archive(attack, payloads) + + with pytest.raises(_configs.OperationalError, match="non-portable policy"): + _sessions.import_config(attack, replace=False, yes=False) + + +def test_import_policy_content_conflict_is_atomic_and_replaceable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + allow = tmp_path / "allow.yaml" + allow.write_text( + 'Version: "2012-10-17"\nStatement:\n- Effect: Allow\n' + ' Action: s3:GetObject\n Resource: "*"\n', + encoding="utf-8", + ) + deny = tmp_path / "deny.yaml" + deny.write_text( + 'Version: "2012-10-17"\nStatement:\n- Effect: Deny\n' + ' Action: s3:GetObject\n Resource: "*"\n', + encoding="utf-8", + ) + + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "source")) + _policies.add_stored("Guard", allow) + archive = _sessions.export_config(str(tmp_path / "policies.zip")) + imported_policy = _archive_payloads(archive)["stored_session_policies/Guard.yaml"] + + destination = tmp_path / "destination" + monkeypatch.setenv("HACKSAWS_HOME", str(destination)) + _policies.add_stored("Guard", deny) + destination_policy = _policies.stored_directory() / "Guard.yaml" + original_policy = destination_policy.read_bytes() + original_config = (destination / "config.json").read_bytes() + + with pytest.raises(_configs.OperationalError, match="policy-content:Guard"): + _sessions.import_config(archive, replace=False, yes=False) + assert destination_policy.read_bytes() == original_policy + assert (destination / "config.json").read_bytes() == original_config + + with ( + patch("hacksaws._state.save_config", side_effect=OSError("injected")), + pytest.raises(OSError, match="injected"), + ): + _sessions.import_config(archive, replace=True, yes=True) + assert destination_policy.read_bytes() == original_policy + assert (destination / "config.json").read_bytes() == original_config + + _sessions.import_config(archive, replace=True, yes=True) + assert destination_policy.read_bytes() == imported_policy + assert _state.load_config()["policies"]["Guard"] == { + "file": "stored_session_policies/Guard.yaml" + } + + +def test_import_write_failure_rolls_back_config_and_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source_home = tmp_path / "source" + monkeypatch.setenv("HACKSAWS_HOME", str(source_home)) + policy = tmp_path / "p.yaml" + policy.write_text('Version: "2012-10-17"\nStatement: []\n', encoding="utf-8") + _policies.add_stored("Read", policy) + archive = _sessions.export_config(str(tmp_path / "export.zip")) + + destination = tmp_path / "destination" + monkeypatch.setenv("HACKSAWS_HOME", str(destination)) + _state.save_config(_state.default_config()) + original = (destination / "config.json").read_bytes() + real_write = _state.atomic_write + failed = False + + def fail_config_once(path: Path, data: bytes) -> None: + nonlocal failed + if path.name == "config.json" and not failed: + failed = True + raise OSError("injected") + real_write(path, data) + + with ( + patch("hacksaws._state.atomic_write", side_effect=fail_config_once), + pytest.raises(OSError, match="injected"), + ): + _sessions.import_config(archive, replace=False, yes=False) + assert (destination / "config.json").read_bytes() == original + assert not (_policies.stored_directory() / "Read.yaml").exists() + + +def test_remote_check_restores_environment_and_fails_unverifiable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + monkeypatch.setenv("AWS_CONFIG_FILE", "original-config") + _minimal_target(tmp_path) + args = argparse.Namespace( + remote=True, + probe=False, + profile="default", + target="+Prod", + account=None, + ) + with ( + patch( + "hacksaws._sessions._identity", + side_effect=_configs.OperationalError("offline"), + ), + patch("boto3.Session", return_value=MagicMock()), + ): + report = _sessions.check_config(args) + assert report["ok"] is False + assert "unverifiable" in report["errors"][0] + assert __import__("os").environ["AWS_CONFIG_FILE"] == "original-config" + + +def test_cascade_yes_deletes_dependents_atomically( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + _minimal_target(tmp_path, boundary=True) + result = _cli.console_main(["account", "remove", "Prod", "--cascade", "--yes"]) + assert result.exit_code == 0 + data = _state.load_config() + assert data["accounts"] == {} + assert data["boundaries"] == {} + assert data["targets"] == {} + + +def test_schema_rejects_unknown_policy_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + data = _state.default_config() + data["policies"]["Read"] = { + "file": "stored_session_policies/Read.yaml", + "injected": True, + } + with pytest.raises(_configs.OperationalError, match="Unknown policy"): + _state.save_config(data) + + +def test_resource_rename_rolls_back_session_on_config_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + _state.save_config(data) + _state.save_sessions({"dest": {"target_account": "Prod", "backup": [], "ecr": []}}) + with ( + patch("hacksaws._state.save_config", side_effect=OSError("injected")), + pytest.raises(OSError, match="injected"), + ): + _cli._run_resource( + argparse.Namespace( + access_type="account", + resource_action="rename", + resource_name="Prod", + new_name="Production", + ) + ) + assert "Prod" in _state.load_config()["accounts"] + assert _state.load_sessions()["dest"]["target_account"] == "Prod" + + +def test_assume_role_enforces_known_role_maximum() -> None: + session = MagicMock() + session.get_credentials.return_value = MagicMock(token=None) + iam = MagicMock() + iam.get_role.return_value = {"Role": {"MaxSessionDuration": 1800}} + session.client.return_value = iam + args = argparse.Namespace( + duration="1h", + htl=None, + mtl=None, + stl=None, + session_name=None, + ) + with pytest.raises(_configs.OperationalError, match="MaxSessionDuration"): + _sessions._assume( + session, + "arn:aws:iam::123456789012:role/read", + policy=None, + source_profile="default", + args=args, + target={}, + external_id=None, + boundary_name=None, + ) + + +def test_expanded_mfa_write_failure_restores_existing_destination( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + aws_dir = tmp_path / "aws" + aws_dir.mkdir() + credentials = aws_dir / "credentials" + config = aws_dir / "config" + credentials.write_text( + "[dev]\naws_access_key_id = ORIGINAL\naws_secret_access_key = secret\n", + encoding="utf-8", + ) + config.write_text( + "[profile dev]\nmfa_serial = arn:aws:iam::123456789012:mfa/dev\n", + encoding="utf-8", + ) + original_credentials = credentials.read_bytes() + original_config = config.read_bytes() + namespace = _cli._create_parser().parse_args( + [ + "mfa", + "in", + "dev", + "123456", + "--directory", + str(aws_dir), + "--role", + "arn:aws:iam::123456789012:role/read", + ] + ) + _cli._validate_login(namespace) + raw = MagicMock() + intermediate = MagicMock(region_name="us-east-1") + final = { + "AccessKeyId": "FINAL", + "SecretAccessKey": "final-secret", + "SessionToken": "final-token", + } + with ( + patch("hacksaws._sessions._persistent_source", return_value=(raw, MagicMock())), + patch( + "hacksaws._sessions._identity", + return_value=("123456789012", "aws", "arn:aws:iam::123456789012:user/dev"), + ), + patch("hacksaws._sessions._mfa_session", return_value=intermediate), + patch( + "hacksaws._sessions._assume", + return_value=(final, {"target_account": "123456789012"}), + ), + patch( + "hacksaws._sessions._copy_region", + side_effect=_configs.OperationalError("config write failed"), + ), + pytest.raises(_configs.OperationalError, match="config write failed"), + ): + _sessions.mfa_login(_configs.Context(namespace)) + assert credentials.read_bytes() == original_credentials + assert config.read_bytes() == original_config + assert not _sessions._journal_path().exists() + + +def test_bounded_browser_ecr_is_cleaned_when_assume_role_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + _minimal_target(tmp_path, boundary=True) + namespace = _cli._create_parser().parse_args(["web", "in", "+Prod", "--ecr"]) + _cli._validate_login(namespace) + registry = "123456789012.dkr.ecr.us-east-1.amazonaws.com" + + def ecr_login( + context: object, + account: object, + session: object, + *, + on_success: object, + ) -> list[str]: + on_success(registry) # type: ignore[operator] + return [registry] + + with ( + patch("hacksaws._sessions._aws_login"), + patch("boto3.Session", return_value=MagicMock(region_name="us-east-1")), + patch( + "hacksaws._sessions._identity", + return_value=("123456789012", "aws", "arn:aws:iam::123456789012:user/dev"), + ), + patch("hacksaws._ecr.login_with_session", side_effect=ecr_login), + patch( + "hacksaws._sessions._assume", + side_effect=_configs.OperationalError("assume failed"), + ), + patch("hacksaws._ecr._run_container_engine") as engine, + pytest.raises(_configs.OperationalError, match="assume failed"), + ): + _sessions.browser_login(_configs.Context(namespace)) + engine.assert_called_once_with( + "docker", ["docker", "logout", registry], check=False + ) + + +def test_plain_logout_retains_ecr_record_then_explicit_logout_cleans_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + aws_dir = tmp_path / "aws" + registry = "123456789012.dkr.ecr.us-east-1.amazonaws.com" + key = f"{aws_dir.absolute()}::dev" + _state.save_sessions( + { + key: { + "destination": str(aws_dir.absolute()), + "profile": "dev", + "auth_method": "mfa", + "backup": [], + "ecr": [registry], + } + } + ) + args = argparse.Namespace( + target=None, + directory=str(aws_dir), + profile="dev", + aws_account_name=None, + to=None, + to_directory=None, + ecr=False, + podman=False, + ) + assert _sessions.logout(_configs.Context(args)) is True + assert _state.load_sessions()[key]["auth_method"] == "ecr-only" + args.ecr = True + with patch("hacksaws._ecr._run_container_engine") as engine: + assert _sessions.logout(_configs.Context(args)) is True + engine.assert_called_once_with("docker", ["docker", "logout", registry]) + assert _state.load_sessions() == {} + + +@pytest.mark.parametrize( + ("mutator", "match"), + [ + (lambda data: data["cache"].update(max_age=True), "max_age"), + ( + lambda data: data["accounts"].update( + Prod={"id": 123456789012, "partition": "aws"} + ), + "12-digit", + ), + ( + lambda data: data["accounts"].update( + Prod={"id": "123456789012", "partition": 7} + ), + "partition", + ), + ( + lambda data: data.update( + accounts={"Prod": {"id": "123456789012", "partition": "aws"}}, + targets={ + "Bad": { + "source_account": "Prod", + "source_profile": "default", + "source_location": 7, + } + }, + ), + "source_location", + ), + ( + lambda data: data.update( + accounts={"Prod": {"id": "123456789012", "partition": "aws"}}, + boundaries={ + "Bad": { + "role_arn": "arn:aws:iam::123456789012:role/read", + "account": "Prod", + "duration": True, + } + }, + ), + "duration", + ), + ], +) +def test_schema_rejects_coerced_or_boolean_types( + mutator: object, + match: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + data = _state.default_config() + mutator(data) # type: ignore[operator] + with pytest.raises(_configs.OperationalError, match=match): + _state.save_config(data) + + +def test_boundary_role_arn_must_match_referenced_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + data = _state.default_config() + data["accounts"]["China"] = { + "id": "123456789012", + "partition": "aws-cn", + } + data["boundaries"]["Bad"] = { + "role_arn": "arn:aws:iam::123456789012:role/team/read", + "account": "China", + } + with pytest.raises(_configs.OperationalError, match="does not match"): + _state.save_config(data) + data["boundaries"]["Bad"]["role_arn"] = ( + "arn:aws-cn:iam::123456789012:role/team/read" + ) + _state.save_config(data) + data["accounts"]["Other"] = {"id": "999999999999", "partition": "aws-cn"} + with pytest.raises(_configs.OperationalError, match="does not match"): + _state.update_resource(data, "boundary", "Bad", {"account": "Other"}) + + +def test_remote_check_does_not_scope_same_id_other_partition( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + data = _state.default_config() + data["accounts"]["China"] = { + "id": "123456789012", + "partition": "aws-cn", + } + data["boundaries"]["ChinaRole"] = { + "role_arn": "arn:aws-cn:iam::123456789012:role/read", + "account": "China", + } + _state.save_config(data) + session = MagicMock() + iam = MagicMock() + session.client.return_value = iam + args = argparse.Namespace( + remote=True, probe=False, profile="default", target=None, account=None + ) + with ( + patch("boto3.Session", return_value=session), + patch( + "hacksaws._sessions._identity", + return_value=( + "123456789012", + "aws", + "arn:aws:iam::123456789012:user/test", + ), + ), + ): + report = _sessions.check_config(args) + assert report["ok"] is True + iam.get_role.assert_not_called() + + +def test_cache_rollback_restores_modified_deleted_and_removes_created( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + cache = tmp_path / "cache" + cache.mkdir() + modified = cache / "modified.json" + deleted = cache / "deleted.json" + created = cache / "created.json" + modified.write_bytes(b"original-modified") + deleted.write_bytes(b"original-deleted") + journal = _sessions._begin([], cache_roots=[cache]) + modified.write_bytes(b"changed") + deleted.unlink() + created.write_bytes(b"new") + _sessions._rollback(journal) + assert modified.read_bytes() == b"original-modified" + assert deleted.read_bytes() == b"original-deleted" + assert not created.exists() + + +def test_import_rejects_manifest_declared_unused_junk( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + _state.save_config(_state.default_config()) + clean = _sessions.export_config(str(tmp_path / "clean.zip")) + with zipfile.ZipFile(clean) as archive: + payloads = {name: archive.read(name) for name in archive.namelist()} + manifest = json.loads(payloads["manifest.json"]) + payloads["junk.bin"] = b"attacker controlled" + manifest["files"]["junk.bin"] = _state.digest(payloads["junk.bin"]) + payloads["manifest.json"] = json.dumps(manifest).encode() + attack = tmp_path / "attack.zip" + with zipfile.ZipFile(attack, "w") as archive: + for name, content in payloads.items(): + archive.writestr(name, content) + with pytest.raises(_configs.OperationalError, match="not referenced"): + _sessions.import_config(attack, replace=False, yes=False) + + +def test_config_fix_repairs_policy_from_user_selected_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + data = _state.default_config() + data["policies"]["Read"] = {"file": "stored_session_policies/Read.yaml"} + _state.save_config(data) + replacement = tmp_path / "replacement.yaml" + replacement.write_text( + '# preserved\nVersion: "2012-10-17"\nStatement: []\n', encoding="utf-8" + ) + args = argparse.Namespace(account=None, yes=False) + with ( + patch("sys.stdin.isatty", return_value=True), + patch("builtins.input", side_effect=["repair", str(replacement)]), + ): + result = _sessions.fix_config(args) + assert result.exit_code == 0 + assert ( + _policies.stored_directory() / "Read.yaml" + ).read_bytes() == replacement.read_bytes() + + +@pytest.mark.parametrize( + ("partition", "suffix"), + [ + ("aws", "amazonaws.com"), + ("aws-us-gov", "amazonaws.com"), + ("aws-cn", "amazonaws.com.cn"), + ], +) +def test_ecr_registry_dns_suffix_is_partition_aware( + partition: str, suffix: str +) -> None: + account = _configs.AwsAccount( + { + "Account": "123456789012", + "Arn": f"arn:{partition}:iam::123456789012:user/test", + }, + "cn-north-1" if partition == "aws-cn" else "us-east-1", + (), + ) + assert account.ecr_registries[0].endswith(suffix) + + +def test_direct_policy_requires_role(capsys: pytest.CaptureFixture[str]) -> None: + result = _cli.console_main(["mfa", "in", "dev", "123456", "--policy", "x"]) + assert result.exit_code == 1 + assert "requires --role" in capsys.readouterr().err + + +def test_schema_crud_rename_and_ref_integrity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + data = _state.default_config() + _state.add_resource( + data, "account", "Prod", {"id": "123456789012", "partition": "aws"} + ) + _state.add_resource( + data, + "boundary", + "ReadOnly", + { + "role_arn": "arn:aws:iam::123456789012:role/read", + "account": "Prod", + "verified": False, + }, + ) + _state.add_resource( + data, + "target", + "Main", + { + "source_account": "Prod", + "source_profile": "dev", + "source_location": "default", + "boundary": "ReadOnly", + }, + ) + _state.rename_resource(data, "account", "prod", "Production") + _state.rename_resource(data, "boundary", "readonly", "Audit") + _state.save_config(data) + loaded = _state.load_config() + assert loaded["targets"]["Main"]["source_account"] == "Production" + assert loaded["targets"]["Main"]["boundary"] == "Audit" + with pytest.raises(_configs.OperationalError, match="referenced"): + _state.remove_resource(loaded, "account", "Production") + + +def test_unknown_schema_fields_fail_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + (tmp_path / "config.json").write_text( + '{"schema_version":1,"surprise":true}', encoding="utf-8" + ) + with pytest.raises(_configs.OperationalError, match="Unknown config"): + _state.load_config() + + +@pytest.mark.parametrize("suffix", ["json", "yaml", "toml"]) +def test_policy_formats_and_inline_size( + suffix: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) + values = { + "json": '{"Version":"2012-10-17","Statement":[]}', + "yaml": 'Version: "2012-10-17"\nStatement: []\n', + "toml": 'Version = "2012-10-17"\nStatement = []\n', + } + path = tmp_path / f"policy.{suffix}" + path.write_text(values[suffix], encoding="utf-8") + document, _ = _policies.parse_policy(path) + assert document["Version"] == "2012-10-17" + with pytest.raises(_configs.OperationalError, match="2048"): + _policies.enforce_inline_limit("x" * 2049) + + +def test_export_import_promotes_external_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source_home = tmp_path / "source" + monkeypatch.setenv("HACKSAWS_HOME", str(source_home)) + external = tmp_path / "external.json" + external.write_text('{"Version":"2012-10-17","Statement":[]}', encoding="utf-8") + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + data["boundaries"]["Read"] = { + "role_arn": "arn:aws:iam::123456789012:role/read", + "account": "Prod", + "policy": str(external), + "verified": False, + } + _state.save_config(data) + archive = _sessions.export_config(str(tmp_path / "portable.zip")) + with zipfile.ZipFile(archive) as zipped: + assert any(name.startswith("external_policies/") for name in zipped.namelist()) + + destination_home = tmp_path / "destination" + monkeypatch.setenv("HACKSAWS_HOME", str(destination_home)) + _sessions.import_config(archive, replace=False, yes=False) + imported = _state.load_config() + policy_name = imported["boundaries"]["Read"]["policy"] + assert policy_name in imported["policies"] + assert (_policies.stored_directory() / f"{policy_name}.yaml").is_file() + + +def test_status_never_exposes_backup_credentials( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + _state.save_sessions( + { + "dest": { + "profile": "prod", + "auth_method": "mfa", + "backup": [{"data": "SECRET"}], + } + } + ) + assert "SECRET" not in json.dumps(_sessions.status()) diff --git a/pyproject.toml b/pyproject.toml index 7b626f6..87a7091 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hacksaws" -version = "0.3.2" +version = "0.4.0" description = "A command-line utility for AWS profiles using dynamic authentication methods such as MFA." authors = [ { name = "Scott Ernst", email = "swernst@gmail.com" }, @@ -21,6 +21,7 @@ classifiers = [ ] dependencies = [ "boto3>=1.40,<2", + "pyyaml>=6.0,<7", ] [dependency-groups] @@ -36,6 +37,7 @@ dev = [ [project.scripts] hacksaws = "hacksaws:main" +test = "hacksaws._test_runner:main" [project.urls] Homepage = "https://github.com/rocketboosters/hacksaws" @@ -102,6 +104,26 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] +"hacksaws/_cli.py" = [ + "C901", "E501", "FBT001", "PLR0911", "PLR0912", "PLR0915", "SLF001", + "TRY003", "TRY203", +] +"hacksaws/_duration.py" = ["TRY003"] +"hacksaws/_ecr.py" = ["ANN401"] +"hacksaws/_policies.py" = [ + "ANN401", "C901", "E501", "PERF102", "PLR0912", "PLR0913", "PLR0915", + "PLR1714", "PLR2004", "RUF059", "TRY003", +] +"hacksaws/_sessions.py" = [ + "ANN401", "ARG001", "B007", "C901", "E501", "PLC0415", "PLR0912", + "PLR0913", "PLR0915", "PLR2004", "RUF059", "S603", "S607", "SIM105", + "SLF001", "TRY003", +] +"hacksaws/_state.py" = [ + "C901", "E501", "PLR0912", "PLR0915", "PLR2004", "PTH105", "RUF059", + "SIM105", "TRY003", +] +"hacksaws/_test_runner.py" = ["S603"] "**/tests/**" = [ "ARG001", "ARG002", @@ -113,6 +135,8 @@ ignore = [ "RET504", "S101", "SLF001", + "TC003", + "D103", ] [tool.ruff.lint.isort] @@ -136,6 +160,9 @@ cache_dir = ".cache/pytest" source = ["hacksaws"] omit = ["hacksaws/tests/*"] +[tool.coverage.report] +precision = 2 + [tool.taskipy.tasks] format_ruff = "ruff format ." format_prettier = "npm run format" @@ -145,7 +172,7 @@ lint_ruff_format = "ruff format --check ." lint_mypy = "mypy hacksaws" lint_prettier = "npm run format:check" lint = "task lint_ruff_format && task lint_ruff && task lint_mypy && task lint_prettier" -test = "pytest --cov=hacksaws --cov-report=term-missing --cov-report=xml" +test = "python -m hacksaws._test_runner" check = "task lint && task test" build = "uv build" diff --git a/uv.lock b/uv.lock index cd74696..04ed6f2 100644 --- a/uv.lock +++ b/uv.lock @@ -169,10 +169,11 @@ wheels = [ [[package]] name = "hacksaws" -version = "0.3.2" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "boto3" }, + { name = "pyyaml" }, ] [package.dev-dependencies] @@ -187,7 +188,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "boto3", specifier = ">=1.40,<2" }] +requires-dist = [ + { name = "boto3", specifier = ">=1.40,<2" }, + { name = "pyyaml", specifier = ">=6.0,<7" }, +] [package.metadata.requires-dev] dev = [ @@ -434,6 +438,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.16.0" From 41581186c0127946cbfb728947cf2af154c8b140 Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Sat, 1 Aug 2026 18:03:46 -0500 Subject: [PATCH 2/8] Expand Secure AWS Workflows - **Bounded Authentication** - Add browser and MFA login flows with named accounts, targets, role boundaries, session-policy caching, transactional logout, and ECR-safe credential handling so users can grant agents only the AWS access required for a task. - **IAM Lifecycle** - Add managed-policy, role, inventory, cleanup, and durable recovery commands with ownership checks, drift detection, dry-run planning, and compensation so remote changes remain auditable and recoverable. - **Operator Experience** - Add stable JSON and human-readable output, portable configuration, profile and cache inspection, comprehensive documentation, and wheel-ready dependencies so the CLI works predictably for people and automation. - **Quality Gates** - Align formatting, linting, packaging, live smoke tooling, and the test runner with repository conventions while enforcing warning-free tests and at least 95% coverage. --- .gitignore | 1 + .prettierignore | 29 +- CHEATSHEET.md | 362 ++- README.md | 513 +-- docs/automation-and-json.md | 25 + docs/cache.md | 22 + docs/cleanup.md | 41 + docs/configuration.md | 31 + docs/development-and-smoke-tests.md | 42 + docs/iam-policies.md | 29 + docs/iam-roles-and-trust.md | 52 + docs/login.md | 51 + docs/profiles-and-sessions.md | 30 + docs/security-model.md | 25 + docs/troubleshooting.md | 55 + hacksaws/_cli.py | 1237 ++++++- hacksaws/_configs.py | 104 +- hacksaws/_iam_cleanup.py | 1272 ++++++++ hacksaws/_iam_cli.py | 783 +++++ hacksaws/_iam_managed_policies.py | 2204 +++++++++++++ hacksaws/_iam_policy_cli.py | 2867 +++++++++++++++++ hacksaws/_iam_policy_documents.py | 308 ++ hacksaws/_iam_recovery.py | 753 +++++ hacksaws/_iam_role_cli.py | 2285 +++++++++++++ hacksaws/_iam_roles.py | 1359 ++++++++ hacksaws/_output.py | 109 + hacksaws/_policies.py | 220 +- hacksaws/_sessions.py | 1168 ++++++- hacksaws/_state.py | 333 +- hacksaws/_test_runner.py | 18 +- hacksaws/tests/scripts/__init__.py | 1 + hacksaws/tests/scripts/live_iam_smoke.py | 243 ++ hacksaws/tests/test_cli_state_coverage.py | 212 +- hacksaws/tests/test_coverage_closure.py | 120 + hacksaws/tests/test_hacksaws.py | 4 +- hacksaws/tests/test_iam_cleanup.py | 861 +++++ hacksaws/tests/test_iam_cli_scaffold.py | 818 +++++ hacksaws/tests/test_iam_managed_policies.py | 2064 ++++++++++++ hacksaws/tests/test_iam_policy_cli.py | 1539 +++++++++ hacksaws/tests/test_iam_recovery_security.py | 592 ++++ hacksaws/tests/test_iam_role_cli.py | 1494 +++++++++ hacksaws/tests/test_iam_roles.py | 599 ++++ hacksaws/tests/test_live_iam_smoke_harness.py | 72 + hacksaws/tests/test_local_lifecycle.py | 1047 ++++++ hacksaws/tests/test_output_foundation.py | 387 +++ hacksaws/tests/test_sessions_coverage.py | 266 +- hacksaws/tests/test_v04.py | 11 +- package.json | 4 +- pyproject.toml | 45 +- scripts/__init__.py | 1 + scripts/prettier.py | 89 + uv.lock | 82 +- 52 files changed, 26204 insertions(+), 675 deletions(-) create mode 100644 docs/automation-and-json.md create mode 100644 docs/cache.md create mode 100644 docs/cleanup.md create mode 100644 docs/configuration.md create mode 100644 docs/development-and-smoke-tests.md create mode 100644 docs/iam-policies.md create mode 100644 docs/iam-roles-and-trust.md create mode 100644 docs/login.md create mode 100644 docs/profiles-and-sessions.md create mode 100644 docs/security-model.md create mode 100644 docs/troubleshooting.md create mode 100644 hacksaws/_iam_cleanup.py create mode 100644 hacksaws/_iam_cli.py create mode 100644 hacksaws/_iam_managed_policies.py create mode 100644 hacksaws/_iam_policy_cli.py create mode 100644 hacksaws/_iam_policy_documents.py create mode 100644 hacksaws/_iam_recovery.py create mode 100644 hacksaws/_iam_role_cli.py create mode 100644 hacksaws/_iam_roles.py create mode 100644 hacksaws/_output.py create mode 100644 hacksaws/tests/scripts/__init__.py create mode 100644 hacksaws/tests/scripts/live_iam_smoke.py create mode 100644 hacksaws/tests/test_iam_cleanup.py create mode 100644 hacksaws/tests/test_iam_cli_scaffold.py create mode 100644 hacksaws/tests/test_iam_managed_policies.py create mode 100644 hacksaws/tests/test_iam_policy_cli.py create mode 100644 hacksaws/tests/test_iam_recovery_security.py create mode 100644 hacksaws/tests/test_iam_role_cli.py create mode 100644 hacksaws/tests/test_iam_roles.py create mode 100644 hacksaws/tests/test_live_iam_smoke_harness.py create mode 100644 hacksaws/tests/test_local_lifecycle.py create mode 100644 hacksaws/tests/test_output_foundation.py create mode 100644 scripts/__init__.py create mode 100644 scripts/prettier.py diff --git a/.gitignore b/.gitignore index 33f577e..cebe4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ .pytest_cache/ .ruff_cache/ .cache/ +.tmp-pytest-*/ .coverage coverage.xml htmlcov/ diff --git a/.prettierignore b/.prettierignore index 907954a..96406d2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,24 @@ -.git/ -.venv/ -build/ -dist/ -htmlcov/ -node_modules/ +/.git +/.cache +/.cache/** +/.tmp-pytest-* +/.tmp-pytest-*/** +/.mypy_cache +/.mypy_cache/** +/.pytest_cache +/.pytest_cache/** +/.ruff_cache +/.ruff_cache/** +/.venv +/venv +/build +/dist +/htmlcov +/node_modules +.coverage +.coverage.* +coverage.xml +*.egg-info +__pycache__ +*.py[cod] uv.lock diff --git a/CHEATSHEET.md b/CHEATSHEET.md index d117b17..9d42e79 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -63,6 +63,10 @@ destination, role, policy, account, external-ID, and session-name overrides. Only duration may override a saved boundary; an unbounded target may add one named `--boundary`/`--as`. +`+NAME` is the documented target shorthand, but any leading non-alphanumeric +character selects target `NAME`; choose a prefix that is convenient in your +shell. `--target NAME` is always the unambiguous flag form. + ## Named resources All of account, boundary, and target support: @@ -118,16 +122,25 @@ can exceed STS’s 2,048-character limit. ## Cache, inspection, and portability ```shell +hacksaws cache status [--json] +hacksaws cache list [PATTERN]... [--fresh|--stale|--invalid] [--origin ORIGIN] +hacksaws cache show ENTRY [--json] hacksaws cache get [max-age] [--json] hacksaws cache set max-age DURATION -hacksaws cache clear [--yes] +hacksaws cache clear [PATTERN]... [--stale|--all] [--yes] hacksaws status [--json] +hacksaws status [--verify] +hacksaws profile list [PATTERN]... [--verify] [--wide] [--json] +hacksaws logout PROFILE [--name LOCATION] [--force] [--keep-ecr] +hacksaws logout --all [--except PATTERN]... [--force] [--keep-ecr] hacksaws config show [--account ACCOUNT] [--json] hacksaws config explain +TARGET [--json] hacksaws config check [--profile PROFILE|--target +TARGET] [--remote] [--probe] \ [--account ACCOUNT] [--no-verify] [--json] -hacksaws config fix [--account ACCOUNT] [--yes] +hacksaws config fix [--account ACCOUNT] \ + [--profile PROFILE|--target +TARGET] [--location LOCATION|-d DIRECTORY] \ + [--remote] [--probe] [--no-verify] [--yes] hacksaws config export [ARCHIVE.zip] hacksaws config import ARCHIVE.zip [--replace] [--yes] ``` @@ -143,6 +156,335 @@ and offers repair/leave/remove interactively without weakening boundaries. Import validates an exact checksummed archive and previews conflicts; noninteractive replacement requires `--replace --yes`. +## Output and configuration foundations + +Every command accepts these global output flags before or after command words +(but never after `--`): + +```shell +hacksaws status --color auto +hacksaws config show --no-color +hacksaws status --json +``` + +`--color` is `auto`, `always`, or `never`; `--no-color` is `never`. Auto mode +requires a TTY and honours `NO_COLOR` and `TERM=dumb`. JSON writes one stable +envelope with `schemaVersion: 1`, `ok`, `code`, and either `data` or `error`. +Human diagnostics use stderr; JSON diagnostics do too. Exit status is 0 for +success, 1 for an operational error, 2 for invalid syntax or an incomplete or +dependency-blocked cleanup, 3 for a policy/safety refusal, 4 for a declined +confirmation, and 130 for interruption. + +Schema version remains **1**. Inspect and set portable configuration values: + +```shell +hacksaws config options +hacksaws config option get output.color --json +hacksaws config set output.color never +hacksaws config set naming.resources.role.prefix managed- +hacksaws config reset naming.resources.role.prefix +``` + +Naming resolves in this order: built-in defaults, `naming.global`, a resource +override, an account override, an account/resource override, then an explicit +command value. Defaults are Pascal case with empty prefix/suffix and `off` +enforcement. `iam.path` defaults to `/hacksaws/`; packed-policy warning defaults +to 80 percent with enforcement `off`. Accounts may carry a `credential_target`, +but selecting it never logs in implicitly. + +## Remote IAM + +`iam` is canonical and `remote` is an exact alias. The local `policy` command +above remains separate from remote IAM managed policies. The parser and verified +credential context are shared by the registered policy and role adapters: + +```text +hacksaws iam|remote policy TERMINAL_COMMAND ... [selectors] [safety] +hacksaws iam|remote role TERMINAL_COMMAND ... [selectors] [safety] +hacksaws iam|remote list [PATTERN]... [type/origin/smoke filters] [selectors] +hacksaws cleanup SELECTION [type/origin/smoke filters] [selectors] [safety] +hacksaws iam|remote cleanup SELECTION [same filters/selectors/safety] +hacksaws iam|remote recovery list +hacksaws iam|remote [same selectors] recovery get JOURNAL_ID +hacksaws iam|remote [same selectors] recovery continue|rollback JOURNAL_ID +``` + +Selectors are visible on and should normally be placed on each terminal command: + +```text +--profile PROFILE AWS profile (default: default) +--location NAME ~/.aws-NAME (`default` and `.` mean ~/.aws) +-d, --directory PATH explicit AWS config directory +--target NAME saved source; conflicts with profile/location/directory +--account NAME_OR_ID account assertion; may combine with --target +--region REGION regional clients and console links +--dry-run normal mutation: validate/plan, never mutate or journal +--yes exact noninteractive approval (mutations only) +``` + +Selector abbreviations and duplicates are rejected. `--location` conflicts with +`--directory`; `--profile` composes with either one. + +### Inventory and Leave No Trace cleanup + +```shell +hacksaws iam list [PATTERN]... [--roles] [--policies] [--group-grants] \ + [--created] [--adopted] [--smoke] [--smoke-run RUN_ID] [--compact|--wide] + +hacksaws cleanup PATTERN... [--roles] [--policies] [--group-grants] \ + [--created] [--adopted] [--cascade] [--remove-boundaries] \ + [--remove-from-instance-profiles] [--dry-run|--yes] +hacksaws cleanup --all [same filters and safety options] +hacksaws cleanup --smoke [same filters and safety options] +hacksaws cleanup --smoke-run RUN_ID [same filters and safety options] +``` + +Patterns are case-insensitive fnmatch expressions and are ORed. `--all` +conflicts with patterns. No type flags means all supported types. No origin +flags means created and adopted resources. Smoke selectors further narrow +matches. Cleanup orders group grants, roles, then policies; blocked/transient +work does not prevent independent resources from being attempted. + +Cleanup exit codes: `0` complete/executable plan, `1` input/auth/planning +failure, `2` partial or dependency-blocked, `3` safety refusal. + +The shared IAM context resolves and freezes credentials while `AWS_CONFIG_FILE` +and `AWS_SHARED_CREDENTIALS_FILE` are bound to the selected +profile/location/directory. It creates IAM, STS, and Access Analyzer clients and +verifies `sts:GetCallerIdentity` inside that same scope, restores every ambient +credential-provider variable exactly afterward, and rejects a caller +account/partition mismatch. Use a saved target only when its source account is +the intended management account; no selector performs an implicit login. + +Normal remote mutations use schema-one journals under +`~/.hacksaws/iam-recovery/`, separate from the login transaction journal. Each +step is written atomically before its AWS mutation and contains only a +whitelisted handler name plus forward and compensation payloads—never +credentials. `continue` resumes pending forward steps in order; `rollback` +reconciles and compensates pending or completed steps in reverse order. Both +require credentials for the journal's recorded account. Corrupt journals are +reported and preserved for inspection. `--json` is always noninteractive and +emits exactly one result envelope; destructive machine-mode commands still +require `--yes`. + +Recovery `continue` and `rollback` do not accept `--dry-run`: they resume an +existing journal. Every journal is bound to both AWS account and partition. +Completed cleanup receipts have their recovery payloads scrubbed, so they are +diagnostic records and cannot be rolled back. After an irreversible IAM identity +deletion, recovery fails closed and reports manual rebuild requirements instead +of recreating a same-named resource with a different principal ID. + +Available policy leaves are `create`/`publish`, `list`, `get`, `export`, +`update`/`edit`, `versions`, `rollback`, `delete`/`remove`, `check`, `tag`, +`adopt`, and `release`. Role leaves are `create`, `get`, `list`, `update`, +`delete`, `attach`, `detach`, `adopt`, `release`, `tag`, `inline-policy`, and +`trust`. IAM is the source of truth for those remote objects. Stored Hacksaws +policies are local documents; managed IAM policies are versioned IAM resources; +inline role policies are bound to one role; STS session policies are ephemeral +and subject to packed-policy limits. Do not treat these as interchangeable. + +### Role command reference + +```text +hacksaws iam role create ROLE [--trust-caller|--trust-policy FILE] + [--description TEXT] [--path IAM_PATH] [--permissions-boundary POLICY] + [--tag KEY=VALUE]... [naming/metadata/duration options] [--replace] + [selectors] [--dry-run] [--yes] +hacksaws iam role get ROLE [selectors] +hacksaws iam role list [PATTERN]... [--custom|--all|--service] + [--wide] [--probe] [selectors] +hacksaws iam role update ROLE [--description TEXT|--clear-description] + [--permissions-boundary POLICY|--clear-permissions-boundary] + [--trust-policy FILE] [metadata/duration options] [selectors] [--dry-run] [--yes] +hacksaws iam role delete ROLE [--cascade] [--remove-from-instance-profiles] + [--unmanaged] [--service-role] [selectors] [--dry-run] [--yes] +hacksaws iam role attach ROLE POLICY [--inline] [--policy-name NAME] + [--path IAM_PATH] [metadata options] [selectors] [--dry-run] [--yes] +hacksaws iam role detach ROLE POLICY [selectors] [--dry-run] [--yes] +hacksaws iam role adopt ROLE [--owner NAME] [--audit-id ID] + [selectors] [--dry-run] [--yes] +hacksaws iam role release ROLE [selectors] [--dry-run] [--yes] +hacksaws iam role tag list ROLE [selectors] +hacksaws iam role tag set ROLE KEY=VALUE... [selectors] [--dry-run] [--yes] +hacksaws iam role tag remove ROLE KEY... [selectors] [--dry-run] [--yes] +hacksaws iam role inline-policy list ROLE [selectors] +hacksaws iam role inline-policy get ROLE POLICY [selectors] +hacksaws iam role inline-policy export ROLE POLICY [--output|-o OUTPUT] + [format/metadata options] +hacksaws iam role inline-policy put ROLE POLICY FILE [metadata options] + [selectors] [--dry-run] [--yes] +hacksaws iam role inline-policy edit|delete ROLE POLICY + [selectors] [--dry-run] [--yes] +hacksaws iam role trust get ROLE [selectors] +hacksaws iam role trust set ROLE FILE [metadata options] [selectors] [--dry-run] [--yes] +hacksaws iam role trust edit ROLE [selectors] [--dry-run] [--yes] +hacksaws iam role trust export ROLE [--output|-o OUTPUT] + [format/metadata options] [selectors] +hacksaws iam role trust check ROLE [--probe] [selectors] +hacksaws iam role trust add|remove user|role|account|principal ROLE PRINCIPAL + [principal/condition options] [selectors] [--dry-run] [--yes] +hacksaws iam role trust add|remove group-members GROUP MEMBER... + [selectors] [--dry-run] [--yes] +hacksaws iam role trust sync group-members GROUP [MEMBER]... + [selectors] [--dry-run] [--yes] +hacksaws iam role trust grant|revoke group ROLE GROUP + [selectors] [--dry-run] [--yes] +``` + +### Managed-policy command reference + +```text +hacksaws iam policy create|publish FILE [NAME] [selectors] + [--description TEXT] [--path IAM_PATH] [--replace] + [--format json|yaml|toml] [--metadata none|nested|sidecar] + [--metadata-file FILE] [--tag KEY=VALUE]... [--local-validation-only] + [--replace] [--dry-run] [--yes] +hacksaws iam policy list [PATTERN]... [--custom|--aws|--all] [selectors] + [--compact|--wide] +hacksaws iam policy get POLICY [selectors] +hacksaws iam policy export POLICY [OUTPUT] [selectors] + [--format json|yaml|toml] [--metadata none|nested|sidecar] + [--metadata-file FILE] [--all-versions] +hacksaws iam policy update [POLICY] FILE [selectors] + [--from-stored NAME] [input options] [--dry-run] [--yes] +hacksaws iam policy edit POLICY [selectors] [--format json|yaml|toml] + [--local-validation-only] [--dry-run] [--yes] +hacksaws iam policy versions POLICY [selectors] +hacksaws iam policy rollback POLICY VERSION [selectors] [--dry-run] [--yes] +hacksaws iam policy delete|remove POLICY [selectors] [--cascade] + [--remove-boundaries] [--allow-unmanaged] [--dry-run] [--yes] +hacksaws iam policy check POLICY [selectors] [--role ARN_OR_NAME] + [--local-validation-only] +hacksaws iam policy tag list POLICY [selectors] +hacksaws iam policy tag set POLICY --tag KEY=VALUE... [selectors] [--dry-run] [--yes] +hacksaws iam policy tag remove POLICY KEY... [selectors] [--dry-run] [--yes] +hacksaws iam policy adopt POLICY [--tag KEY=VALUE]... [selectors] [--dry-run] [--yes] +hacksaws iam policy release POLICY [selectors] [--dry-run] [--yes] +``` + +`POLICY` accepts the adapter's account- and partition-safe ARN/name resolution. +AWS-managed policies may be inspected, exported, validated, and checked, but +cannot be created, updated, tagged, adopted, released, rolled back, or deleted. +Hacksaws preserves a customer-managed policy's description and never rewrites or +"optimizes" policy statements. `export --all-versions` serializes every retained +document with its version ID, default marker, creation time, active policy, and +optional metadata; treat exports and recovery journals as security-sensitive +configuration even though neither contains AWS credentials. + +`check --role` submits the selected policy document exactly as an inline STS +session policy using STS's minimum 900-second role session, reports AWS's real +`PackedPolicySize`, and discards returned credentials. It does not substitute a +deny-all probe. The call still creates a short-lived AWS session and requires +`sts:AssumeRole`; use `--local-validation-only` to skip AWS-side validation and +omit `--role` to skip the STS probe. + +Every managed-policy mutation records its complete forward and compensation +state before the first AWS call. That includes retained version documents and +default selection, tags, policy and ownership IDs, dependency principal IDs, and +deletion restoration data. Continue/rollback accepts only the exact predecessor, +exact intended result, or an exact ordered AWS-call stage left by an interrupted +mutation; partial atomic tag batches and out-of-order dependency removals are +drift, not recovery checkpoints. A create's actual AWS PolicyId is receipted +before destructive compensation; a crash before that receipt fails closed and +preserves the present policy for manual recovery. Restoring an attachment or +permissions boundary also requires the same current IAM principal ID, not merely +the same user/group/role name. If a process is interrupted, inspect the journal +before choosing `continue` or `rollback`; both operations are account-bound and +idempotent. + +Deletion prints the exact user/group/role attachments, user/role permissions +boundaries, and retained versions before its stronger confirmation. `--cascade` +authorizes attachment removal, but permissions-boundary assignments additionally +require `--remove-boundaries`; unmanaged policies additionally require +`--allow-unmanaged`. Interactive deletion requires typing the exact policy name. +Noninteractive and JSON invocations never prompt and therefore require `--yes`. + +`DeletePolicy` is the irreversible managed-policy identity commit point because +AWS cannot recreate the original PolicyId. Rollback repairs partial deletion +only before that call succeeds. Afterward it leaves the ARN absent, performs no +`CreatePolicy` or dependency restoration, and reports the manual rebuild +requirement; `continue` remains an idempotent absent-state verification. + +Tag changes use an optimistic tag snapshot and fail if tags drift before +publish. `tag set` and `tag remove` reject every `hacksaws:` key; use `adopt` +and `release` for the reserved ownership/audit tags. Editor updates likewise +fail if either the exported default version or its document digest changes while +the editor is open. + +Role names are IAM role names (up to 64 characters) and ARNs include partition, +account, path, and role name, for example +`arn:aws:iam::123456789012:role/hacksaws/Agent`. Trust principals must be +durable IAM users, roles, services, or explicit account roots—never wildcards or +an STS assumed-role session where a durable principal is required. Group changes +affect membership only; they do not silently replace role trust or policy +attachments. Every role mutation shows its resource/action plan and requires +typing `yes`; CI, JSON, and non-TTY use must explicitly pass `--yes`. Trust +input rejects wildcard principals and `NotPrincipal`. Named same-account roles +are resolved with `iam:GetRole` so paths are retained; cross-account roles +require an exact ARN. Generic tag commands cannot change reserved `hacksaws:` +tags. + +Role creation journals the immutable AWS `RoleId` as its effect receipt. +Recovery never adopts or deletes a same-name role from matching +tags/configuration alone. If AWS creation succeeds but the process stops before +the receipt is durable, preserve the role and recover manually; automated +continuation and rollback fail closed. A receipt-backed rollback deletes only +the exact matching `RoleId`. + +Role deletion is the exception to the ordinary interactive confirmation text: +type the exact role name because deletion crosses an irreversible AWS principal- +identity commit point. `--yes` remains the explicit automation form. Recovery +can restore dependency removals if the role still has its original IAM role ID, +but it will stop for manual recovery after deletion rather than recreate a +same-named, different principal and claim success. + +```shell +# Core role lifecycle +hacksaws iam role create Agent --trust-caller +hacksaws iam role update Agent --trust-policy trust.yaml --yes +hacksaws iam role get Agent +hacksaws iam role list --wide +hacksaws iam role delete Agent --cascade --yes + +# Managed and inline permissions +hacksaws iam role attach Agent ReadOnlyAccess --yes +hacksaws iam role attach Agent ./agent-policy.yaml --policy-name AgentPolicy --yes +hacksaws iam role detach Agent ReadOnlyAccess --yes +hacksaws iam role inline-policy put Agent LocalRead ./read.yaml --yes +hacksaws iam role inline-policy edit Agent LocalRead --yes +hacksaws iam role inline-policy delete Agent LocalRead --yes + +# Exact trust and durable IAM-group grants +hacksaws iam role trust add role Agent Operator --yes +hacksaws iam role trust add role Agent arn:aws:iam::210987654321:role/team/Operator --yes +hacksaws iam role trust grant group Agent Agents --yes +hacksaws iam role trust revoke group Agent Agents --yes +hacksaws iam role trust sync group-members Agents Agent DebugAgent --yes +``` + +Local managed-policy attachment and group grants publish only tagged, +Hacksaws-owned policies whose resource kind and resource ID exactly match the +intended attachment or group. They refuse every other ARN collision, preserve +unrelated aggregate-policy statements, snapshot all version documents/default +state for durable reconciliation, and are rerunnable. Group trust uses the +distinct `HacksawsGroupAccount` statement and preserves unrelated account-root +trust. Group revoke retains that owned statement only while another exact, live, +attached group aggregate still references the role. + +For `iam role list --probe`, `denied` means STS explicitly rejected +authorization. Network, throttling, expired-credential, and other operational +failures are shown as `indeterminate`, with details, rather than being +mislabeled as denials. + +Remote IAM leaves require only the needed IAM actions on the `/hacksaws/` path +plus `sts:GetCallerIdentity`; add `iam:PassRole` only when a workflow actually +needs it. Permissions boundaries, service-control policies, cross-account trust, +IAM limits (including managed-policy version count and STS packed policy size), +and eventual consistency are AWS constraints, not bypassed by Hacksaws. Run +`hacksaws iam recovery list` before retrying an interrupted remote mutation; +inspect with `get JOURNAL_ID`, then choose `continue JOURNAL_ID` or +`rollback JOURNAL_ID`. + ## Common compact workflows ```shell @@ -164,5 +506,17 @@ hacksaws config import hacksaws-config.zip ## Development tests -`uv run test` and `uv run task test` run the same full pytest suite. Both fail -unless aggregate line coverage is at least 95%. +```shell +mpx --me check +uv run task check +uv run test +uv run task test +uvx --from . hacksaws --help +``` + +The MPX check and direct task check run format, lint, then test. Literal +`uv run test` is development-only; it and `uv run task test` forward additional +pytest arguments and fail unless aggregate line coverage is at least 95.00%. +Built packages provide the `hacksaws` command and the `py.typed` marker. Use +`uvx --from . hacksaws ...` for a source checkout; `uvx hacksaws ...` is the +package-index form once a release has been published. diff --git a/README.md b/README.md index a46b521..1e0e579 100644 --- a/README.md +++ b/README.md @@ -1,438 +1,237 @@ # Hacksaws -[![Checks](https://github.com/rocketboosters/hacksaws/actions/workflows/checks.yaml/badge.svg)](https://github.com/rocketboosters/hacksaws/actions/workflows/checks.yaml) -[![PyPI version](https://img.shields.io/pypi/v/hacksaws.svg)](https://pypi.org/project/hacksaws/) -[![License](https://img.shields.io/pypi/l/hacksaws.svg)](https://github.com/rocketboosters/hacksaws/blob/main/LICENSE) +Hacksaws is an AWS login and IAM lifecycle CLI built for humans working beside +agents. It can authenticate with MFA or AWS browser login, then optionally +assume a role with a session policy so the credentials left on disk have a +smaller blast radius than the credentials used to obtain them. -Hacksaws is an AWS credential switcher and **agentic blast-radius manager**. It -can obtain credentials with MFA or AWS CLI browser sign-in, then optionally -assumes one deliberately constrained role and installs those credentials only at -an explicit destination profile. The intended contract is simple: automation -receives the smallest practical permission set, for a bounded time, in a -location you chose. A saved target is a secure preset, not a loose collection of -defaults; it cannot be overridden at login time. +It also manages the accounts, targets, boundaries, reusable policies, IAM roles, +and customer-managed policies used by that workflow. Every remote mutation is +account-scoped, previewed, and recoverable where AWS permits it. -The only supported executable is `hacksaws`. See [CHEATSHEET.md](CHEATSHEET.md) -for the compact command reference. +## Install and run -## Install +Python 3.12 or newer is required. Run the published CLI without installing it: ```shell -uv tool install hacksaws -# or -python -m pip install hacksaws +uvx hacksaws --help ``` -Hacksaws requires Python 3.13 or 3.14. Browser sign-in requires AWS CLI **v2.32 -or newer** on `PATH`. - -## Security model and local secrets - -Hacksaws has three useful credential tiers: - -1. **Native/source credentials** — the profile’s original static, SSO, - credential-process, or AWS CLI browser-login credentials. These are broad - enough to start the flow and may have an unbounded provider-controlled - lifetime. -2. **MFA intermediate credentials** — `hacksaws mfa login` exchanges static - source keys for an STS session (`--lifespan`, default 12 hours). When a role - boundary is requested, these are only an intermediate credential tier. -3. **Boundary credentials** — an STS `AssumeRole` session for the selected role, - optionally reduced further by a session policy and bounded by the selected - duration. This is what is written to the destination profile for the agent or - tool. - -Browser login has two related lifecycles. Without a role, `pk`/`web` leaves AWS -CLI’s native browser credentials in their normal, provider-controlled lifecycle; -Hacksaws cannot truthfully shorten or attest their lifetime. With a -role/boundary, it first performs native browser login and then writes a -**staged, bounded AssumeRole session** to the destination. `pk` and `web` wrap -`aws login` rather than implement a browser or passkey protocol; they cannot -prove that a passkey was used. - -The source credential entry and its `PROFILE.store.credentials` backup can be -read by the same OS user while a legacy MFA login is active. Treat the source -AWS directory and `~/.hacksaws` as sensitive user data. Hacksaws uses -user-scoped files where the platform supports it, but it is not a vault and -cannot prevent another process running as the same OS user from reading -credentials. Configuration, exports, and status deliberately never print access -keys, secret keys, session tokens, or ECR passwords. An external ID is not an -AWS credential, but it is configuration data and is visible to that same OS user -and in configuration exports. - -## Quick start: a constrained agent identity - -Create a target-account identity and a narrow stored session policy: +For development: ```shell -hacksaws account add prod 123456789012 --description "production" -hacksaws policy add deploy-readonly policies/deploy-readonly.yaml \ - --description "agent's production scope" -hacksaws boundary add prod-readonly AgentReadOnly \ - --account prod --policy deploy-readonly --duration 45m \ - --description "production role with a 45-minute ceiling" - -hacksaws target add prod-agent \ - --source-account prod --source-profile human \ - --source-location default --to agent:default --boundary prod-readonly - -hacksaws pk login --target +prod-agent +git clone https://github.com/rocketboosters/hacksaws.git +cd hacksaws +uv sync +uv run hacksaws --help +uv run test ``` -The `+` makes a saved target unmistakable. `--target prod-agent` is accepted as -shorthand and normalized to `+prod-agent`; `+prod-agent` is preferred in scripts -and reviews. Login to a target may not override its source, destination, role, -or policy. +`uv run test` is the repository quality gate. It runs formatting, linting, type +checking, the warning-free test suite, and enforces at least 95% coverage. + +## Quick start -Check the planned resolution before logging in: +Browser login needs no pre-existing profile. Hacksaws creates the destination +profile when needed: ```shell -hacksaws config explain +prod-agent -hacksaws config check --target +prod-agent --remote -hacksaws status +hacksaws web in debug +aws sts get-caller-identity --profile debug ``` -## Authentication commands - -### MFA - -The legacy direct flow replaces a source profile’s static credentials with an -MFA STS session and preserves the original entry in `PROFILE.store.credentials` -until logout: +`pk` is an exact alias for `web`: ```shell -hacksaws mfa login engineering 123456 --lifespan 43200 -hacksaws mfa logout engineering +hacksaws pk in admin --name horizon ``` -`mfa in` and `mfa out` are aliases. `--lifespan` is a legacy MFA-session -duration in seconds; its default is 43,200 (12 hours). AWS and account policy -can impose a lower maximum. - -Use MFA as a source for a staged role session by selecting a boundary, direct -role, or target: +MFA login starts from persistent source credentials: ```shell -hacksaws mfa login human 123456 --as prod-readonly -hacksaws mfa login human 123456 --role AgentReadOnly --account prod \ - --policy policies/deploy-readonly.yaml --duration 45m -hacksaws mfa login +prod-agent 123456 -# Universal named alternative: -hacksaws mfa login 123456 --target prod-agent +hacksaws mfa in admin --name horizon 123456 ``` -The target supplies the saved source and destination plan. Supplying a role-only -operand for an unbounded target is rejected before authentication; those flags -can never silently produce a broad native login. - -### Browser (`pk` and `web`) - -`pk` and `web` are equivalent browser-login command families, each wrapping AWS -CLI `aws login`. Use the one your team has standardized on: +The source above is profile `admin` in `~/.aws-horizon`. To write temporary +credentials somewhere else, use `--to LOCATION:PROFILE`: ```shell -hacksaws pk login human -hacksaws web in human --as prod-readonly --duration 45m -hacksaws pk login --target +prod-agent -hacksaws web logout human +hacksaws mfa in admin --name horizon --to default:debug 123456 ``` -`login` has the alias `in`; `logout` has the alias `out`. `--remote` asks the -browser flow to perform remote validation/probing when supported. Browser -commands default the source profile to `default`. - -### Destinations and locations +`.` and `default` both mean the default AWS directory or profile in their +respective position. -The source directory is `--directory`/`--dir` (default `~/.aws`). -`--name`/`--account-name NAME` is a source-directory shortcut for `~/.aws-NAME`. +## Boundary sessions -For a staged role login, choose exactly one destination form: +A boundary assumes a role after authentication. With no `--policy`, the role's +full permissions are used. Supplying a policy creates an intersected session: +AWS allows only actions permitted by both the role and the session policy. ```shell -# Logical location and profile: ~/.aws-agent, profile agent -hacksaws pk login human --as prod-readonly --to agent:agent - -# Explicit directory requires its destination profile -hacksaws pk login human --as prod-readonly \ - --to-directory /secure/aws-agent --to-profile agent +hacksaws web in debug \ + --role AgentSession \ + --policy CloudWatchReadOnlyAccess ``` -`--to LOCATION:PROFILE` is mutually exclusive with `--to-directory` and -`--to-profile`; `--to-directory` always requires `--to-profile`. Logical -`default` and `.` both mean `~/.aws`; every other logical location `NAME` means -`~/.aws-NAME`. Location names use portable resource-name characters only (1–64 -letters/digits/`.`, `_`, `-`, beginning with a letter or digit), so they cannot -contain path separators, drive prefixes, or traversal. Use `--directory` or -`--to-directory` for arbitrary filesystem paths. - -Use `hacksaws logout PROFILE` as a top-level logout convenience, or the matching -authentication-family logout. Add `--ecr` and optionally `--podman` when ECR -container-engine logout is wanted. +Policies may be AWS/customer-managed policy names, ARNs, stored-policy names, or +local JSON/YAML/TOML files. Hacksaws resolves and minifies the document before +calling `AssumeRole`. AWS also applies a separate packed-policy limit; Hacksaws +reports that limit explicitly but does not rewrite policy semantics. -## Roles, trust, and policies +Save role/policy/duration combinations as boundaries and full login presets as +targets: -A **boundary** names a target account, role ARN, optional external ID, optional -duration, and optional session policy. A boundary may be same-account or -cross-account. It does not create AWS IAM resources; configure both source -permission and target trust first. +```shell +hacksaws boundary add cloudwatch AgentSession \ + --account prod --policy CloudWatchReadOnlyAccess --duration 1h -Source identity policy: permit the human/source role to assume the target role. -Replace the ARN with your source principal and target role ARN. +hacksaws target add hacw \ + --source-account prod --source-profile admin \ + --source-location horizon --boundary cloudwatch -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": "sts:AssumeRole", - "Resource": "arn:aws:iam::123456789012:role/AgentReadOnly" - } - ] -} +hacksaws web in +hacw +# Equivalent explicit spelling: +hacksaws web in --target hacw ``` -Target role trust policy for a same-account source role: +Durations accept forms such as `15m`, `15minutes`, `1h`, `hour`, `600s`, and +`600seconds`. Rigid aliases `--htl`, `--mtl`, and `--stl` accept floating-point +hours, minutes, and seconds; sub-second results round to whole seconds. -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { "AWS": "arn:aws:iam::123456789012:role/HumanOperator" }, - "Action": "sts:AssumeRole" - } - ] -} -``` +## Inspect before acting -For a cross-account role, the target account’s trust policy must name the source -account principal (or a narrowly selected source role). Add an external-ID -condition when your trust model requires it: +The human views are compact tables. Add global `--json` for automation and +`--no-color` when ANSI styling is undesirable: -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { "AWS": "arn:aws:iam::111122223333:role/HumanOperator" }, - "Action": "sts:AssumeRole", - "Condition": { "StringEquals": { "sts:ExternalId": "vendor-opaque-id" } } - } - ] -} +```shell +hacksaws status +hacksaws profile list +hacksaws profile list --verify +hacksaws iam list --profile admin --wide +hacksaws cache status +hacksaws config show ``` -Then save the same value with `--external-id` on `boundary add`, or supply it -for an ad hoc role login. It is passed only to `AssumeRole`. - -The target role’s identity policies still determine the maximum permission. A -session policy can only further reduce it. Therefore `--policy` requires a -role/boundary/target; it is never interpreted as a general local permission -system. - -### Policy resolution and the 2048-character limit - -For `--policy VALUE`, resolution order is: an explicit policy ARN, a file path -(`.json`, `.yaml`/`.yml`, or `.toml`, including paths with a slash), a stored -policy name, then a remote IAM policy name. JSON, YAML, and TOML documents must -contain IAM `Version` and `Statement`; inline documents are canonicalized to -compact JSON. - -Stored policies are held under `~/.hacksaws/stored_session_policies`. YAML is -preserved verbatim on store; JSON and TOML are converted to YAML. Local and -stored documents, AWS-managed policies, and remotely resolved policies are -recorded in the local inspection cache. `cache max-age 0s` disables cache reads; -`cache clear` removes cached records. - -Customer-managed policy ARNs must belong to the **target role account** and are -passed as managed session-policy ARNs. AWS-managed policy ARNs are fetched and -used as inline policy documents, so they are subject to STS’s 2,048-character -inline-session-policy limit. For example, -`arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess` may fail if its expanded -compact policy is over 2,048 characters; the error tells you to use a -same-account customer-managed policy ARN instead. A remote bare policy name is -rejected if it is ambiguous between AWS-managed and customer-managed policies. - -## Named configuration - -All named resources are case-insensitively unique and accept safe names. -`--description` is supported on account, boundary, target, and stored policy -records. +Global output flags may appear anywhere before `--`: ```shell -# Account IDs are paired with immutable AWS partitions. -hacksaws account add prod 123456789012 --partition aws -hacksaws account add gov 210987654321 --partition aws-us-gov -hacksaws account add china 109876543210 --partition aws-cn - -# A short role is expanded using the account's partition and ID. -hacksaws boundary add prod-readonly AgentReadOnly --account prod \ - --policy deploy-readonly --external-id vendor-opaque-id --duration 45m - -hacksaws target add prod-agent --source-account prod --source-profile human \ - --source-location . --to agent:default --boundary prod-readonly +hacksaws --json iam policy list +hacksaws iam policy list --color never ``` -An account’s partition is part of its identity because an account number alone -cannot construct correct ARN strings in commercial AWS, GovCloud, and China. -`account add` infers the partition from verified caller identity. An explicit -unverified save requires both `--no-verify` and `--partition`. Do not mix an ARN -from one partition with an account declared in another. - -Use `add`, `update`, `get`, `list`, `rename`, and `remove` for accounts, -boundaries, and targets. `remove` refuses resources with live configuration or -session references. `--cascade` previews whole-resource dependent deletion and -requires interactive confirmation; noninteractive use requires `--yes`. It still -refuses active session references. `--json` is available for `get` and `list`. +## Remote IAM lifecycle -Boundaries can change `--policy`, `--external-id`, and `--duration`, or clear -them with `--clear-policy`, `--clear-external-id`, and `--clear-duration`. -Targets can change or `--clear-boundary`. Accounts, boundaries, and targets can -change a description with `update ... --description TEXT` or clear it with -`--clear-description`; stored-policy descriptions are supplied on `policy add` -or `policy update`. - -## Durations - -AssumeRole duration accepts one of these mutually exclusive forms: +`iam` and `remote` are exact aliases. Credential selectors belong on terminal +commands, so the following is intentionally supported: ```shell ---duration 45m # --ttl is an alias ---htl 1.5 # hours-to-live ---mtl 90 # minutes-to-live ---stl 5400 # seconds-to-live +hacksaws iam policy create agent.yaml --profile admin --dry-run +hacksaws iam policy create agent.yaml --profile admin --yes +hacksaws iam role create AgentSession --profile admin --trust-caller --dry-run ``` -Decimal values are rounded conventionally to whole seconds and must be positive. -Boundary sessions must be at least 900 seconds; chained role sessions are capped -at 3,600 seconds. A boundary duration is its normal default; an ad hoc duration -is used for that login. AWS role configuration can still enforce a lower -maximum. `--ttl`, `--duration`, `--htl`, `--mtl`, and `--stl` are role-only -options and fail without a role/boundary/target. +Create commands never silently overwrite a differing resource. An identical +resource reports `NO CHANGE`; a difference reports `CONFLICT`. Use the normal +`update` command, or deliberate `create --replace` plus confirmation. -## Inspect, repair, and move configuration +Normal remote IAM mutations accept `--dry-run`. A dry run performs discovery, +validation, collision checks, and planning, but creates no recovery journal and +changes neither AWS nor local state. Recovery `continue` and `rollback` commands +resume an already-journaled operation and therefore do not accept `--dry-run`. -```shell -hacksaws status -hacksaws status --json -hacksaws config show -hacksaws config show --account prod --json -hacksaws config explain +prod-agent -hacksaws config check --target +prod-agent --remote -hacksaws config fix --account prod - -hacksaws config export hacksaws-config.zip -hacksaws config import hacksaws-config.zip -hacksaws config import hacksaws-config.zip --replace --yes -``` +## Leave No Trace cleanup -`status` reports active destinations, auth method, source and target identities, -boundary, role, policy provenance, expiration/remaining time, and recorded ECR -state—never credentials. `config show` displays declared configuration; -`config explain` shows a target’s resolved plan; `check` validates locally and -can verify configured remote accounts and roles with `--remote`; `fix` writes a -timestamped backup then normalizes the configuration without changing security -references. `--probe` performs an explicit 900-second AssumeRole test with a -deny-all session policy, discards the returned credentials, and writes no -session files. `fix` reports unresolved issues with a nonzero status; in an -interactive terminal it offers repair/leave/remove per issue and never detaches -a boundary or weakens a security reference automatically. - -Export creates a portable archive containing configuration and stored-policy -files, with checksums; it excludes credentials, active-session metadata, and the -cache. Import requires an exact manifest/member set, verifies every checksum, -validates all content in memory, previews conflicts, and then atomically merges. -Interactive replacement asks for confirmation; noninteractive replacement uses -`--replace --yes`. Referenced external policy files are bundled and promoted to -deterministically named stored YAML policies during import. - -## ECR - -Add `--ecr` to a login to authenticate Docker, or `--podman` to select Podman. -Repeat `--ecr-region REGION` for more registries; the profile’s primary region -is first. +Cleanup deletes only resources whose Hacksaws ownership can be established in +the selected account. A pattern, `--all`, `--smoke`, or `--smoke-run` is +mandatory. With no type flags, all supported types are considered. ```shell -hacksaws pk login human --as prod-readonly --ecr --ecr-region us-west-2 -hacksaws mfa login human 123456 --ecr --podman +hacksaws cleanup "*ServiceBuzz*" --policies --profile admin --dry-run +hacksaws cleanup --all --profile admin --dry-run +hacksaws cleanup --smoke --profile admin --yes ``` -Important: ECR deliberately gets its authorization token with the **broad -intermediate/source session**, before the boundary is installed. This makes -container authentication useful even when the boundary excludes ECR, but it also -means the resulting container-engine registry credential is outside that -boundary’s blast-radius guarantee. ECR and AWS destination updates are treated -transactionally where possible; a failed container login or credential write can -trigger rollback/recovery. Verify state with `hacksaws status` and run explicit -ECR logout when needed. +`--roles`, `--policies`, and `--group-grants` narrow resource types. `--created` +and `--adopted` narrow ownership origin. Cross-retained dependencies require +explicit `--cascade`, `--remove-boundaries`, or +`--remove-from-instance-profiles` consent. `hacksaws iam cleanup` and +`hacksaws remote cleanup` use the same planner and executor. -Plain `hacksaws logout` restores AWS state but deliberately leaves recorded ECR -authorization installed and retains its cleanup record. Run -`hacksaws logout --ecr` (with the original `--podman` choice when applicable) to -remove only registries recorded by Hacksaws. Hacksaws does not pre-logout before -login because Docker exposes no safe portable way to distinguish and restore a -preexisting authorization. +## Log out safely -## Policy cache +Logout removes Hacksaws-managed live credentials without contacting an AWS +logout endpoint. It never stores the intermediate MFA-authenticated credentials +used to assume a boundary role. ```shell -hacksaws cache get -hacksaws cache get --json -hacksaws cache set max-age 30m -hacksaws cache set max-age 0s -hacksaws cache clear --yes +hacksaws logout debug +hacksaws logout --all +hacksaws logout --all --except "default:prod*" --except "+hacw" ``` -`max-age` is the local policy-inspection-cache age, not a credential duration. +Tracked ECR logins are removed by default; use `--keep-ecr` deliberately. +Unknown external profiles are never altered. -## Setup checklist +## Credential threat model -1. Install AWS CLI v2.32+ if using browser login and configure the source - profile normally. -2. For MFA, set `mfa_serial` in the matching AWS config profile and retain an - eligible source credential in its credentials file. -3. Create the source `sts:AssumeRole` permission, target trust relationship, and - target role policies in IAM. -4. Add accounts with the correct partitions; add stored policies, boundaries, - and targets. -5. Run `hacksaws config check --target +NAME --remote` before first use. -6. Start with a short boundary duration and a read-only session policy; inspect - with `hacksaws status`. +The MFA workflow involves three distinct credentials: -## Manual live-AWS smoke matrix +1. Persistent unauthenticated source credentials remain on the device. Give them + only the permissions needed to perform MFA/session bootstrap, because a local + agent may be able to read them. +2. MFA-authenticated intermediate credentials exist only while login and any ECR + login are being completed. They are not backed up when a boundary is used. +3. Boundary credentials are the role/session-policy credentials written to the + destination for the user or agent. -The normal automated suite uses mocked boto3/AWS CLI/container commands and -makes no live AWS calls. Keep live checks opt-in and run them only in disposable -or carefully scoped test accounts: +Browser login similarly uses its authenticated credentials only to complete the +requested workflow, then leaves the final requested credentials at the target. -| Scenario | Manual assertion | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| MFA direct profile | Login writes an MFA session, logout restores the original source entry. | -| Browser native | `pk login`/`web login` invokes AWS CLI v2.32+ and preserves the provider’s normal lifecycle. | -| Same-account boundary | Source can assume the trusted role; session policy reduces access; expiry is reported. | -| Cross-account boundary | Source permission, target trust, and external ID are all required. | -| Policy forms | File, stored policy, customer ARN, AWS-managed ARN, cache hit/miss, and 2048-character failure behave as documented. | -| Destination and rollback | `default`/`.` and named locations resolve correctly; a forced write/ECR failure recovers cleanly. | -| ECR | Docker and Podman receive a registry login from the intermediate credentials and explicit logout removes it. | +## Configure an assumable role -## Development +The role trust policy must allow the login identity to call `sts:AssumeRole`. +Hacksaws can generate the common caller-specific policy: ```shell -uv sync --locked --all-groups -npm ci -uv run task format -uv run test -uv run task test -uv run task check -uv run task build +hacksaws iam role create AgentSession --trust-caller --profile admin ``` -`uv run test` and `uv run task test` share the same full pytest command and -enforce at least 95% aggregate line coverage. +The equivalent trust statement is: -## License +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam::123456789012:user/alice" }, + "Action": "sts:AssumeRole" + } + ] +} +``` -MIT. See [LICENSE](LICENSE). +The caller also needs an identity policy permitting `sts:AssumeRole` on the +role. A group cannot be an IAM trust principal. Hacksaws can instead grant a +group through a managed group policy, or expand the group's current users into +individual trust principals. See the trust guide before choosing between those +models. + +## Learn more + +- [Command cheat sheet](CHEATSHEET.md) +- [Login pathways](docs/login.md) +- [Profiles, status, and logout](docs/profiles-and-sessions.md) +- [IAM policies](docs/iam-policies.md) +- [IAM roles and trust](docs/iam-roles-and-trust.md) +- [Cleanup and Leave No Trace](docs/cleanup.md) +- [Configuration](docs/configuration.md) +- [Policy cache](docs/cache.md) +- [Security model](docs/security-model.md) +- [Automation and JSON](docs/automation-and-json.md) +- [Troubleshooting](docs/troubleshooting.md) +- [Development and smoke tests](docs/development-and-smoke-tests.md) + +Run `hacksaws COMMAND --help` at any level. The CLI is the canonical command +reference and includes selector, safety, confirmation, and repair guidance. diff --git a/docs/automation-and-json.md b/docs/automation-and-json.md new file mode 100644 index 0000000..3ceded0 --- /dev/null +++ b/docs/automation-and-json.md @@ -0,0 +1,25 @@ +# Automation and JSON + +Place `--json` anywhere before the literal `--` to receive one versioned result +envelope on stdout or stderr. Prompts are disabled in JSON mode. Mutations that +would prompt require explicit `--yes`; create collisions additionally require +`--replace` where supported. + +```shell +hacksaws --json iam policy create agent.yaml --profile admin --dry-run +hacksaws iam list --profile admin --json +hacksaws cleanup --all --profile admin --dry-run --json +``` + +Global color controls are `--color auto|always|never` and `--no-color`. +Automation should inspect both numeric exit codes and symbolic result codes or +classifications. Cleanup plan JSON uses `planned`, `no-matches`, or `blocked`. +Executed cleanup results use `cleaned`, `partial`, `blocked`, or +`recovery-required`; the outer result code additionally distinguishes a safety +refusal. See [cleanup.md](cleanup.md). + +Dry runs on normal remote mutations perform remote reads and validation but +create no journal and make no AWS or local mutation. They can therefore fail +when credentials, account assertions, references, validation, or dependencies +are invalid. Recovery `continue` and `rollback` resume an existing journal and +do not offer dry-run mode. diff --git a/docs/cache.md b/docs/cache.md new file mode 100644 index 0000000..adeaa1b --- /dev/null +++ b/docs/cache.md @@ -0,0 +1,22 @@ +# Policy cache + +Resolved remote session policies are minified and cached under +`~/.hacksaws/policy-cache`. Local policy inputs are refreshed every time, +converted to canonical minified JSON, and passed through the same cache loading +path. Remote cache hits are always disclosed. + +```shell +hacksaws cache status +hacksaws cache list +hacksaws cache list "*CloudWatch*" --fresh +hacksaws cache show ENTRY +hacksaws cache get +hacksaws cache get max-age +hacksaws cache set max-age 4h +hacksaws cache clear --stale +hacksaws cache clear --all --yes +``` + +The cache does not bypass AWS's inline session-policy size or packed-policy +limits. Browser-login provider state is session state, not policy-cache state, +and is handled by status/logout commands. diff --git a/docs/cleanup.md b/docs/cleanup.md new file mode 100644 index 0000000..eb47724 --- /dev/null +++ b/docs/cleanup.md @@ -0,0 +1,41 @@ +# Cleanup and Leave No Trace + +Cleanup operates in exactly one verified AWS account and only selects resources +whose Hacksaws ownership is established. It never deletes IAM users, groups, +instance-profile containers, service-linked roles, AWS-managed policies, or +local configuration. + +```shell +hacksaws cleanup "*ServiceBuzz*" --policies --profile admin --dry-run +hacksaws iam cleanup --all --profile admin --dry-run +hacksaws remote cleanup --smoke-run RUN_ID --profile admin --yes +``` + +Patterns are case-insensitive fnmatch expressions and are ORed. Type and origin +filters narrow that selection. `--smoke` and `--smoke-run` are additional AND +filters. `--all` conflicts with patterns and never grants dependency consent. + +Selected internal dependencies are ordered automatically: group grants, roles, +then policies. Dependencies on retained resources require `--cascade`, +`--remove-boundaries`, or `--remove-from-instance-profiles`. Transient AWS +failures are retried at the bottom of the ready queue up to three times; other +independent resources continue. + +Exit codes are stable: + +- `0`: success or executable dry run, including no matches. +- `1`: input, authentication, or planning failure before mutation. +- `2`: partial execution or dependency-blocked residue. +- `3`: ownership/account safety could not be proven. + +In JSON, a plan's `classification` is `planned`, `no-matches`, or `blocked`. +After execution, the nested result classification is `cleaned`, `partial`, +`blocked`, or `recovery-required`. + +AWS audit trails remain. A minimal credential-free local receipt remains for +diagnosis, while temporary policy/trust recovery material is removed after a +successful cleanup. That scrubbed completed receipt can be inspected but cannot +be rolled back because its compensation payloads no longer exist. If cleanup +crosses an irreversible IAM identity deletion and then fails, recovery reports +the remaining manual rebuild instead of recreating a same-named, different +principal and claiming success. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..77ef383 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,31 @@ +# Configuration + +Hacksaws stores first-class configuration in `~/.hacksaws/configs.json`. +Accounts scope boundaries, naming rules, validation, check/fix, and remote IAM +operations. Targets save login source/destination choices; boundaries save role, +policy, external ID, and duration choices. + +```shell +hacksaws account add prod 123456789012 --profile admin +hacksaws account list +hacksaws account rename prod production +hacksaws boundary add logs AgentSession --account prod --policy LogsRead +hacksaws target add debug --source-account prod --source-profile admin +hacksaws config show --account prod +hacksaws config explain debug +hacksaws config check --account prod --profile admin --remote +hacksaws config fix --account prod --profile admin --remote +hacksaws config fix --account prod --target prod-admin --remote --probe +``` + +Both `check` and `fix` accept `--profile`, `--location`, `--directory`, or a +saved `--target` to select account credentials. `--no-verify` skips individual +resource verification where supported. `--probe` implies remote checking and +performs a deny-all AssumeRole probe before offering interactive repairs. + +`config option list` teaches every supported setting with a short description. +Naming rules have global account defaults and policy/role overrides for prefix, +suffix, case, path, and enforcement. + +`config export` creates a portable zip excluding temporary caches. +`config import` validates the complete archive before replacing state. diff --git a/docs/development-and-smoke-tests.md b/docs/development-and-smoke-tests.md new file mode 100644 index 0000000..1a095f3 --- /dev/null +++ b/docs/development-and-smoke-tests.md @@ -0,0 +1,42 @@ +# Development and smoke tests + +Run the complete local quality gate: + +```shell +uv sync +uv run test +``` + +The gate checks Ruff formatting/linting, mypy, Prettier, warning-free tests, +95%+ coverage, package build, and an installed-wheel CLI smoke check. The task +layout follows Camber Ops conventions and works with `mpx --me check`. + +Live IAM smoke tests are explicit and never run in ordinary CI. They require an +account/target guard, create a tagged role and customer-managed policy under +`/hacksaws-test/`, exercise trust/inline/managed attachment/version behavior, +then run cleanup dry-run, cleanup, and absence verification. + +Set all four guards explicitly before invoking the live marker: + +```shell +HACKSAWS_LIVE_AWS=1 \ +HACKSAWS_LIVE_AWS_CLEANUP=1 \ +HACKSAWS_LIVE_AWS_ACCOUNT_ID=123456789012 \ +HACKSAWS_LIVE_AWS_TARGET=smoke-admin \ +uv run pytest -m live_aws +``` + +The account ID must exactly match the target's caller identity. The target must +be a named Hacksaws target; ambient/default credentials are never accepted by +the harness. Keep the target dedicated to a disposable test account. + +Smoke resources carry `hacksaws:smoke=true` and a unique smoke-run ID. If a run +fails, use the printed recovery command: + +```shell +hacksaws cleanup --smoke-run RUN_ID --target TARGET --dry-run +hacksaws cleanup --smoke-run RUN_ID --target TARGET --yes +``` + +Group, permissions-boundary, and instance-profile smoke fixtures require +separate opt-in disposable containers. diff --git a/docs/iam-policies.md b/docs/iam-policies.md new file mode 100644 index 0000000..01ecaf6 --- /dev/null +++ b/docs/iam-policies.md @@ -0,0 +1,29 @@ +# IAM policies + +Remote commands live under `hacksaws iam policy` (`remote` is an alias). + +```shell +hacksaws iam policy create agent.yaml AgentRead --profile admin --dry-run +hacksaws iam policy list "*Agent*" --custom --profile admin +hacksaws iam policy get AgentRead --profile admin +hacksaws iam policy export AgentRead agent.yaml --metadata nested --profile admin +hacksaws iam policy update AgentRead agent.yaml --profile admin +hacksaws iam policy versions AgentRead --profile admin +hacksaws iam policy delete AgentRead --profile admin +``` + +References accept names or ARNs. Create checks AWS first: identical state is +`NO CHANGE`; different state is `CONFLICT`. Prefer `update`; use `--replace` +only for a deliberate create-style replacement preview. + +Policy files accept JSON, YAML, and TOML. Export metadata modes are: + +- `nested`: top-level `metadata` and `policy` keys in one file. +- `sidecar`: bare policy plus a separate metadata file. +- `none`: bare IAM policy document. + +All mutations accept `--dry-run`. AWS Access Analyzer validation is used unless +`--local-validation-only` is explicit. + +Local reusable documents use `hacksaws policy add|get|list|update|remove|rename` +and are stored as YAML under `~/.hacksaws/stored_session_policies`. diff --git a/docs/iam-roles-and-trust.md b/docs/iam-roles-and-trust.md new file mode 100644 index 0000000..1f3dc9a --- /dev/null +++ b/docs/iam-roles-and-trust.md @@ -0,0 +1,52 @@ +# IAM roles and trust + +Create an agent role whose default trust principal is the selected caller: + +```shell +hacksaws iam role create AgentSession --trust-caller --profile admin --dry-run +hacksaws iam role create AgentSession --trust-caller --profile admin --yes +``` + +The caller needs both sides of authorization: + +- The role trust policy allows that caller principal to use `sts:AssumeRole`. +- An identity policy on the caller allows `sts:AssumeRole` on that role ARN. + +Inspect and modify trust explicitly: + +```shell +hacksaws iam role trust get AgentSession --profile admin +hacksaws iam role trust edit AgentSession --profile admin +hacksaws iam role trust add user AgentSession alice --profile admin +hacksaws iam role trust remove user AgentSession alice --profile admin +hacksaws iam role trust grant group AgentSession Developers --profile admin +hacksaws iam role trust add group-members Developers alice bob --profile admin +``` + +IAM groups are not valid trust principals. `trust grant group` manages an +identity policy on the group. `trust add group-members` resolves current group +members and writes individual user principals; later membership changes do not +automatically change that trust policy. Names and ARNs are interchangeable where +AWS permits resolution. + +Role commands also support get/list/update/delete, tag CRUD, managed-policy +attach/detach, and inline-policy list/get/export/put/edit/delete. Use +`--dry-run` on every mutation. + +The complete role command families are: + +```text +hacksaws iam role create|get|list|update|delete ... +hacksaws iam role attach|detach ROLE POLICY ... +hacksaws iam role adopt|release ROLE ... +hacksaws iam role tag list|set|remove ROLE ... +hacksaws iam role inline-policy list|get|export|put|edit|delete ROLE ... +hacksaws iam role trust get|set|edit|export|check ROLE ... +hacksaws iam role trust add|remove user|role|account|principal ROLE PRINCIPAL ... +hacksaws iam role trust add|remove|sync group-members GROUP [MEMBER]... ... +hacksaws iam role trust grant|revoke group ROLE GROUP ... +``` + +Run `hacksaws iam role COMMAND --help` at the terminal leaf for input, selector, +output, and safety details. `adopt` places an existing role under Hacksaws +ownership; `release` removes that ownership without deleting the role. diff --git a/docs/login.md b/docs/login.md new file mode 100644 index 0000000..d334105 --- /dev/null +++ b/docs/login.md @@ -0,0 +1,51 @@ +# Login pathways + +## Browser login (`web` / `pk`) + +Browser login uses the AWS CLI login credential provider and does not require an +existing profile. Hacksaws creates both the config and credentials destinations +when needed. The package includes the Botocore CRT dependency required to verify +these credentials. + +```shell +hacksaws web in debug +hacksaws pk in default --name default +``` + +`web` and `pk` are equivalent. `in` aliases `login`; `out` aliases `logout`. + +## MFA login + +MFA requires persistent source credentials. `PROFILE --name LOCATION` selects +the source profile and `~/.aws-LOCATION` directory. + +```shell +hacksaws mfa in admin --name horizon 123456 +hacksaws mfa in admin --name horizon --to default:debug 123456 +``` + +The source credentials should have only bootstrap permissions. See +[Security model](security-model.md). + +## Roles and session policies + +`--role ARN_OR_NAME` assumes a role after authentication. `--policy` optionally +intersects that role with a session policy: + +```shell +hacksaws web in debug --role AgentSession --policy ./agent.yaml +``` + +`--boundary NAME` / `--as NAME` loads a saved role, policy, external ID, and +duration. `+NAME` or `--target NAME` loads a complete saved login preset. `+` is +the documented shorthand prefix, but any leading non-alphanumeric character is +accepted so users can choose one their shell handles conveniently; the remaining +characters are the target name. + +ECR login deliberately uses the intermediate authenticated credentials before +the final boundary credentials replace them. + +## Destination aliases + +`.` and `default` mean `~/.aws` when used as locations and the `default` profile +when used as profiles. `--to .:.` and `--to default:default` are equivalent. diff --git a/docs/profiles-and-sessions.md b/docs/profiles-and-sessions.md new file mode 100644 index 0000000..e321c17 --- /dev/null +++ b/docs/profiles-and-sessions.md @@ -0,0 +1,30 @@ +# Profiles, sessions, and logout + +`hacksaws profile list` scans the default `~/.aws`, immediate `~/.aws-*` +directories, configured target directories, and active Hacksaws destinations. It +reports profile configuration and conservative credential states. + +Without `--verify`, external profiles are `configured · unverified`. With +`--verify`, Hacksaws calls STS and reports verified, invalid, or unknown without +guessing from token shape. + +Hacksaws-owned sessions may be active, expiring, expired, drifted, or missing +when its metadata and fingerprints prove that state: + +```shell +hacksaws status +hacksaws status --verify +hacksaws profile list --verify +``` + +Logout uses profile-section compare-and-swap. Unrelated file sections survive; +drifted managed sections are skipped unless `--force` is explicit. + +```shell +hacksaws logout debug +hacksaws logout --all +hacksaws logout --all --except "horizon:prod*" --except "+hacw" +``` + +`--except` requires `--all`. Patterns match canonical `location:profile` names +and target aliases. Tracked ECR logins are removed unless `--keep-ecr` is used. diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000..d379cd4 --- /dev/null +++ b/docs/security-model.md @@ -0,0 +1,25 @@ +# Security model + +Hacksaws reduces credential blast radius; it cannot make readable credentials +secret from a process with equivalent filesystem access. + +For MFA login, persistent unauthenticated source credentials remain available. +They should permit only the MFA/bootstrap operations required by the workflow. +MFA-authenticated intermediate credentials are not backed up when a role or +policy boundary is used. Only the final boundary credentials are written to the +agent-facing destination. + +Session policies are intersections, not grants: the assumed role must already +permit an action, and the session policy may only remove access. AWS enforces +both a plaintext session-policy limit and a separate packed binary limit. +Hacksaws detects and explains these failures but does not transform policy +semantics. + +Remote mutations verify caller identity and expected account before planning. +Hacksaws tags created/adopted resources, uses immutable AWS resource IDs in +recovery checks, detects drift before mutation, and refuses destructive action +when ownership cannot be proven. + +Logout never preserves intermediate authenticated credentials. It edits only the +managed profile section using fingerprints and leaves unrelated file data +untouched. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..74ad866 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,55 @@ +# Troubleshooting + +## Browser profile was not created + +Run the current package with `uvx hacksaws` or `uv run hacksaws`. Browser login +verification requires the Botocore CRT extra, which is a project dependency. +Hacksaws rolls back partially written browser-login state when verification +fails. + +## `PackedPolicyTooLarge` + +AWS compresses session policies, policy ARNs, and session tags into a separate +packed representation. A minified document below the plaintext character limit +can still exceed this packed limit. Publish the policy as a customer-managed +policy and attach it to the role, or reduce role/session-tag complexity. +Hacksaws deliberately does not rewrite policy statements. + +## Policy changed but the session did not + +The remote policy cache may still be fresh. `hacksaws cache list` identifies +fresh/stale entries and `hacksaws cache clear PATTERN` invalidates selected +entries. + +## A mutation was interrupted + +```shell +hacksaws iam recovery list +hacksaws iam recovery get JOURNAL_ID +hacksaws iam recovery continue JOURNAL_ID --profile admin +hacksaws iam recovery rollback JOURNAL_ID --profile admin +``` + +Never delete a recovery journal manually while its AWS outcome is uncertain. + +### Cleanup recovery decisions + +Inspect the journal with `recovery get` before choosing a direction, and use +credentials for the journal's exact AWS account and partition. + +- Use `continue` when cleanup is still the intended outcome and the journal has + pending or retryable forward work. Cleanup resumes its dependency-aware queue, + retries eligible failures, and verifies that selected resources are absent. +- A successfully completed cleanup has `payloadsScrubbed: true`. It is a + credential-free diagnostic receipt: `continue` is an idempotent success check, + but `rollback` is intentionally unavailable because the compensation payloads + were removed. +- IAM role and policy deletion crosses an irreversible identity commit point. + AWS cannot recreate the original `RoleId` or `PolicyId`; Hacksaws will not + create a same-named replacement and claim that rollback succeeded. +- Manual remediation is required when the result is `recovery-required`, an + irreversible delete succeeded before a later step failed, AWS absence cannot + be proven, the recorded account/partition is missing or mismatched, or + resource drift prevents safe replay. Preserve the journal, inspect the + remaining AWS resources, and rebuild only after reviewing the reported + completed, failed, and remaining steps. diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index 665510a..d67cdf3 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -3,10 +3,12 @@ from __future__ import annotations import argparse +import contextlib +import fnmatch +import io import json import os import re -import shutil import sys from pathlib import Path from typing import TYPE_CHECKING @@ -21,75 +23,231 @@ from hacksaws import _configs from hacksaws import _duration from hacksaws import _ecr +from hacksaws import _iam_cli +from hacksaws import _output from hacksaws import _policies from hacksaws import _sessions from hacksaws import _state if TYPE_CHECKING: + from collections.abc import Iterator from collections.abc import Sequence +class _HacksawsArgumentParser(argparse.ArgumentParser): + """Propagate strict, non-abbreviating parsing through every command level.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 + kwargs.setdefault("allow_abbrev", False) + super().__init__(*args, **kwargs) + + def _duration_arguments(parser: argparse.ArgumentParser) -> None: group = parser.add_mutually_exclusive_group() - group.add_argument("--duration", "--ttl") - group.add_argument("--htl") - group.add_argument("--mtl") - group.add_argument("--stl") + group.add_argument( + "--duration", "--ttl", help="Boundary duration such as 15m, 1h, or 600s." + ) + group.add_argument("--htl", help="Boundary duration as floating-point hours.") + group.add_argument("--mtl", help="Boundary duration as floating-point minutes.") + group.add_argument("--stl", help="Boundary duration as floating-point seconds.") def _ecr_arguments(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--ecr", action="store_true") - parser.add_argument("--podman", action="store_true") - parser.add_argument("--ecr-region", action="append") + parser.add_argument( + "--ecr", + action="store_true", + help="Also update tracked ECR registry login state.", + ) + parser.add_argument( + "--podman", + action="store_true", + help="Use Podman instead of Docker for ECR login/logout.", + ) + parser.add_argument( + "--ecr-region", + action="append", + help="ECR region; repeat for multiple registries.", + ) def _login_arguments(parser: argparse.ArgumentParser, *, browser: bool = False) -> None: - parser.add_argument("profile", nargs="?") + parser.add_argument( + "profile", + nargs="?", + help=( + "Source/destination AWS profile. A leading non-alphanumeric character " + "selects a saved target; +TARGET is the documented form." + ), + ) if not browser: - parser.add_argument("mfa_code", nargs="?") - parser.add_argument("-l", "--lifespan", type=int, default=43200) - parser.add_argument("--target") + parser.add_argument( + "mfa_code", nargs="?", help="Current six-digit MFA token code." + ) + parser.add_argument( + "-l", + "--lifespan", + type=int, + default=43200, + help="Requested MFA session lifespan in seconds (default: 43200).", + ) parser.add_argument( - "-d", "--dir", "--directory", dest="directory", default="~/.aws" + "--target", + help="Saved target supplying source, destination, and optional boundary.", + ) + parser.add_argument( + "-d", + "--dir", + "--directory", + dest="directory", + default="~/.aws", + help="Source AWS directory (default: ~/.aws).", + ) + parser.add_argument( + "-n", + "--name", + "--account-name", + dest="aws_account_name", + help="Source location name, selecting ~/.aws-NAME.", + ) + parser.add_argument( + "--to", + metavar="LOCATION:PROFILE", + help="Write final credentials to this logical destination.", + ) + parser.add_argument( + "--to-directory", + metavar="PATH", + help="Explicit destination directory; requires --to-profile.", + ) + parser.add_argument( + "--to-profile", + metavar="PROFILE", + help="Destination profile used with --to-directory.", + ) + parser.add_argument( + "--boundary", + "--as", + dest="boundary", + help="Saved role/session-policy boundary.", + ) + parser.add_argument( + "--role", help="Role name or ARN to assume after authentication." + ) + parser.add_argument( + "--policy", + help="Session policy name, ARN, stored name, or local file; requires a role.", + ) + parser.add_argument("--external-id", help="External ID supplied to AssumeRole.") + parser.add_argument("--account", help="Configured account name or ID assertion.") + parser.add_argument( + "--session-name", help="Assumed-role session name shown in AWS audit records." + ) + parser.add_argument( + "--region", help="AWS region used for login and regional operations." ) - parser.add_argument("-n", "--name", "--account-name", dest="aws_account_name") - parser.add_argument("--to") - parser.add_argument("--to-directory") - parser.add_argument("--to-profile") - parser.add_argument("--boundary", "--as", dest="boundary") - parser.add_argument("--role") - parser.add_argument("--policy") - parser.add_argument("--external-id") - parser.add_argument("--account") - parser.add_argument("--session-name") - parser.add_argument("--region") _duration_arguments(parser) _ecr_arguments(parser) if browser: - parser.add_argument("--remote", action="store_true") + parser.add_argument( + "--remote", + action="store_true", + help="Use the AWS CLI remote-device browser flow.", + ) def _logout_arguments(parser: argparse.ArgumentParser) -> None: - parser.add_argument("profile", nargs="?", default="default") - parser.add_argument("--target") + parser.add_argument( + "profile", nargs="?", default="default", help="AWS profile to clear." + ) + parser.add_argument("--target", help="Use a saved target's destination.") parser.add_argument( "-d", "--dir", "--directory", dest="directory", default="~/.aws" ) parser.add_argument("-n", "--name", "--account-name", dest="aws_account_name") + parser.add_argument( + "--all", action="store_true", help="Log out every managed session." + ) + parser.add_argument( + "--except", + dest="except_profiles", + action="append", + default=[], + metavar="PROFILE|LOCATION:PROFILE", + help="Exclude a profile from --all; repeat as needed.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Restore a changed managed section after explicit review.", + ) + parser.add_argument( + "--keep-ecr", + action="store_true", + help="Leave Hacksaws-tracked container registry authorization installed.", + ) _ecr_arguments(parser) def _credential_selector(parser: argparse.ArgumentParser) -> None: selector = parser.add_mutually_exclusive_group() - selector.add_argument("--profile", "--name", dest="profile", default="default") - selector.add_argument("--target") - parser.add_argument("--no-verify", action="store_true") + selector.add_argument( + "--profile", + "--name", + dest="profile", + default="default", + help="AWS profile used for account-scoped verification (default: default).", + ) + selector.add_argument( + "--target", + help="Saved target whose source profile and AWS folder provide credentials.", + ) + folder = parser.add_mutually_exclusive_group() + folder.add_argument( + "--location", + default="default", + help="Logical AWS folder location (default: default, meaning ~/.aws).", + ) + folder.add_argument( + "-d", "--directory", help="Explicit directory containing AWS config files." + ) + parser.add_argument( + "--no-verify", + action="store_true", + help="Save without contacting AWS; only available where documented.", + ) -def _resource_parser( - parent: argparse._SubParsersAction[argparse.ArgumentParser], kind: str -) -> None: - parser = parent.add_parser(kind) +@contextlib.contextmanager +def _selected_credential_session(args: argparse.Namespace) -> Iterator[Any]: + """Create a Boto3 session bound to one explicit profile and AWS folder.""" + selector = _configs.resolve_credential_selector(args) + if selector.target: + data = _state.load_config() + _, target = _state.get_resource(data, "target", selector.target.lstrip("+")) + profile = str(target.get("source_profile", "default")) + directory = ( + Path(str(target["source_directory"])).expanduser().absolute() + if target.get("source_directory") + else _state.aws_directory(target.get("source_location")).absolute() + ) + else: + profile = selector.profile + directory = ( + selector.directory or _state.aws_directory(selector.location).absolute() + ) + with _iam_cli.credential_environment( + directory / "config", directory / "credentials" + ): + yield boto3.Session(profile_name=profile) + + +def _resource_parser(parent: argparse._SubParsersAction[Any], kind: str) -> None: + purposes = { + "account": "Manage named AWS accounts used to scope remote validation.", + "boundary": "Manage saved role and optional session-policy boundaries.", + "target": "Manage saved login source, destination, and boundary presets.", + } + parser = parent.add_parser(kind, help=purposes[kind], description=purposes[kind]) actions = parser.add_subparsers(dest="resource_action") add = actions.add_parser("add") add.add_argument("resource_name") @@ -131,8 +289,7 @@ def _resource_parser( update.add_argument("--clear-policy", action="store_true") update.add_argument("--clear-external-id", action="store_true") update.add_argument("--clear-duration", action="store_true") - update.add_argument("--profile", "--name", dest="profile", default="default") - update.add_argument("--no-verify", action="store_true") + _credential_selector(update) elif kind == "target": add.add_argument("--source-account", required=True) add.add_argument("--source-profile", default="default") @@ -158,14 +315,27 @@ def _resource_parser( def _create_parser() -> argparse.ArgumentParser: """Create the complete, non-abbreviating Hacksaws parser.""" - parser = argparse.ArgumentParser( + parser = _HacksawsArgumentParser( prog="hacksaws", - description="Secure AWS login and boundary manager.", - allow_abbrev=False, + description=( + "Log in to AWS safely, constrain agent credentials, and manage the " + "IAM resources that support those workflows." + ), + epilog=( + "Global output options may appear anywhere before '--':\n" + " --json Emit one stable JSON result envelope.\n" + " --color MODE Color mode: auto, always, or never.\n" + " --no-color Alias for --color never.\n\n" + "Start with 'hacksaws status', or run 'hacksaws COMMAND --help' for " + "examples and safety details." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, ) types = parser.add_subparsers(dest="access_type") - mfa = types.add_parser("mfa") + mfa = types.add_parser( + "mfa", help="Authenticate a source profile with an MFA code." + ) mfa_actions = mfa.add_subparsers(dest="action") mfa_login = mfa_actions.add_parser("login", aliases=["in"]) _login_arguments(mfa_login) @@ -173,22 +343,58 @@ def _create_parser() -> argparse.ArgumentParser: _logout_arguments(mfa_logout) for auth_name in ("pk", "web"): - auth = types.add_parser(auth_name) + auth = types.add_parser( + auth_name, + help="Authenticate through the AWS browser login provider.", + ) actions = auth.add_subparsers(dest="action") login = actions.add_parser("login", aliases=["in"]) _login_arguments(login, browser=True) logout = actions.add_parser("logout", aliases=["out"]) _logout_arguments(logout) - logout = types.add_parser("logout") + logout = types.add_parser("logout", help="Remove local Hacksaws login state.") _logout_arguments(logout) - status = types.add_parser("status") + status = types.add_parser("status", help="Show Hacksaws-managed login sessions.") + status.add_argument("--profile", help="Filter by destination profile.") + status_location = status.add_mutually_exclusive_group() + status_location.add_argument("--location", help="Filter by logical AWS location.") + status_location.add_argument("-d", "--directory", help="Filter by AWS directory.") + status.add_argument( + "--verify", + action="store_true", + help="Opt in to STS verification for each eligible session.", + ) status.add_argument("--json", action="store_true") + profile = types.add_parser("profile", help="Inspect local AWS profiles safely.") + profile_actions = profile.add_subparsers(dest="profile_action") + profile_list = profile_actions.add_parser( + "list", help="List profile names without displaying credential values." + ) + profile_list.add_argument( + "patterns", + nargs="*", + help="Case-insensitive fnmatch patterns for profile or location names.", + ) + profile_list.add_argument( + "--verify", + action="store_true", + help="Verify eligible managed sessions with STS (may contact AWS).", + ) + profile_list.add_argument( + "--wide", + action="store_true", + help="Include full AWS-directory paths in the human table.", + ) + profile_list.add_argument("--json", action="store_true") + for kind in ("account", "boundary", "target"): _resource_parser(types, kind) - policy = types.add_parser("policy") + policy = types.add_parser( + "policy", help="Manage reusable policy documents stored on this computer." + ) policy_actions = policy.add_subparsers(dest="resource_action") for action in ("add", "update"): item = policy_actions.add_parser(action) @@ -205,18 +411,46 @@ def _create_parser() -> argparse.ArgumentParser: rename.add_argument("resource_name") rename.add_argument("new_name") - cache = types.add_parser("cache") + cache = types.add_parser("cache", help="Inspect the Hacksaws session-policy cache.") cache_actions = cache.add_subparsers(dest="cache_action") - cache_get = cache_actions.add_parser("get") + cache_get = cache_actions.add_parser("get", help="Read policy-cache settings.") cache_get.add_argument("setting", nargs="?", choices=("max-age",)) cache_get.add_argument("--json", action="store_true") - cache_set = cache_actions.add_parser("set") + cache_set = cache_actions.add_parser("set", help="Update a policy-cache setting.") cache_set.add_argument("setting", choices=("max-age",)) cache_set.add_argument("value") - cache_clear = cache_actions.add_parser("clear") + cache_actions.add_parser( + "status", help="Summarize fresh, stale, and invalid entries." + ).add_argument("--json", action="store_true") + cache_list = cache_actions.add_parser( + "list", help="List cache metadata, never documents." + ) + cache_list.add_argument( + "patterns", + nargs="*", + help="Case-insensitive fnmatch patterns for cache entry identities.", + ) + cache_filter = cache_list.add_mutually_exclusive_group() + cache_filter.add_argument("--fresh", action="store_true") + cache_filter.add_argument("--stale", action="store_true") + cache_filter.add_argument("--invalid", action="store_true") + cache_list.add_argument("--origin") + cache_list.add_argument("--json", action="store_true") + cache_show = cache_actions.add_parser( + "show", help="Show one explicitly selected cache entry." + ) + cache_show.add_argument("entry") + cache_show.add_argument("--json", action="store_true") + cache_clear = cache_actions.add_parser("clear", help="Remove policy-cache entries.") + cache_clear.add_argument("entries", nargs="*") + clear_scope = cache_clear.add_mutually_exclusive_group() + clear_scope.add_argument("--stale", action="store_true") + clear_scope.add_argument("--all", action="store_true") cache_clear.add_argument("--yes", action="store_true") - config = types.add_parser("config") + config = types.add_parser( + "config", help="Inspect, validate, import, and export Hacksaws configuration." + ) config_actions = config.add_subparsers(dest="config_action") show = config_actions.add_parser("show") show.add_argument("--account") @@ -225,23 +459,159 @@ def _create_parser() -> argparse.ArgumentParser: explain.add_argument("target") explain.add_argument("--json", action="store_true") check = config_actions.add_parser("check") - check.add_argument("--remote", action="store_true") - check.add_argument("--probe", action="store_true") - check.add_argument("--account") + check.add_argument( + "--remote", action="store_true", help="Validate account-scoped AWS resources." + ) + check.add_argument( + "--probe", + action="store_true", + help="Also probe saved boundaries with a deny-all AssumeRole request.", + ) + check.add_argument("--account", help="Check only this configured AWS account.") check.add_argument("--json", action="store_true") _credential_selector(check) fix = config_actions.add_parser("fix") - fix.add_argument("--account") - fix.add_argument("--yes", action="store_true") + fix.add_argument("--account", help="Repair only this configured AWS account.") + fix.add_argument( + "--remote", + action="store_true", + help="Include account-scoped AWS resource issues in the repair review.", + ) + fix.add_argument( + "--probe", + action="store_true", + help="Also probe saved boundaries with a deny-all AssumeRole request.", + ) + fix.add_argument( + "--yes", + action="store_true", + help="Run non-interactively; unresolved issues remain and return nonzero.", + ) + _credential_selector(fix) export = config_actions.add_parser("export") export.add_argument("zip", nargs="?") imported = config_actions.add_parser("import") imported.add_argument("zip") imported.add_argument("--replace", action="store_true") imported.add_argument("--yes", action="store_true") + options = config_actions.add_parser("options") + options.add_argument("--json", action="store_true") + for action in ("get", "reset"): + item = config_actions.add_parser(action) + item.add_argument("key") + item.add_argument("--json", action="store_true") + direct_set = config_actions.add_parser("set") + direct_set.add_argument("key") + direct_set.add_argument("value") + direct_set.add_argument("--json", action="store_true") + option = config_actions.add_parser("option", aliases=["opt"]) + option_actions = option.add_subparsers(dest="option_action") + option_actions.add_parser("list", aliases=["ls"]).add_argument( + "--json", action="store_true" + ) + for action in ("get", "explain", "reset"): + item = option_actions.add_parser(action) + item.add_argument("key") + item.add_argument("--json", action="store_true") + option_set = option_actions.add_parser("set") + option_set.add_argument("key") + option_set.add_argument("value") + option_set.add_argument("--json", action="store_true") + _register_extension_commands(types) return parser +def _register_extension_commands( + parent: argparse._SubParsersAction[Any], +) -> None: + """Reserve integration hooks for IAM and remote command providers. + + Domain modules register their command trees here once their implementation is + available; keeping the hook local prevents output/config plumbing from owning + IAM behavior. + """ + _iam_cli.register_root_cleanup_parser(parent) + _iam_cli.register_parser(parent) + + +def _extract_global_options( + arguments: Sequence[str], +) -> tuple[list[str], str | None, bool]: + """Consume output switches anywhere before ``--`` without changing command syntax.""" + remaining: list[str] = [] + color: str | None = None + use_json = False + index = 0 + while index < len(arguments): + argument = arguments[index] + if argument == "--": + remaining.extend(arguments[index:]) + break + if argument == "--json": + use_json = True + elif argument == "--no-color": + if color and color != "never": + raise _configs.OperationalError("--no-color conflicts with --color.") + color = "never" + elif argument == "--color": + index += 1 + if index == len(arguments) or arguments[index] not in { + "auto", + "always", + "never", + }: + raise _configs.OperationalError( + "--color requires auto, always, or never." + ) + if color and color != arguments[index]: + raise _configs.OperationalError("--color was specified more than once.") + color = arguments[index] + elif argument.startswith("--color="): + value = argument.partition("=")[2] + if value not in {"auto", "always", "never"}: + raise _configs.OperationalError( + "--color requires auto, always, or never." + ) + if color and color != value: + raise _configs.OperationalError("--color was specified more than once.") + color = value + else: + remaining.append(argument) + index += 1 + return remaining, color, use_json + + +def _json_requested(arguments: Sequence[str]) -> bool: + """Detect machine mode before validating any other global option.""" + for argument in arguments: + if argument == "--": + return False + if argument == "--json": + return True + return False + + +@contextlib.contextmanager +def _redirect_stdin(stream: object) -> Iterator[None]: + """Temporarily provide a non-TTY input stream for strict machine mode.""" + previous = sys.stdin + sys.stdin = cast("Any", stream) + try: + yield + finally: + sys.stdin = previous + + +class _NonInteractiveStdin: + """EOF-safe input used to make accidental prompts decline in JSON mode.""" + + def isatty(self) -> bool: + return False + + def readline(self, size: int = -1, /) -> str: + return "" if size == 0 else "\n" + + def _print_help(command: Sequence[str] = ()) -> None: try: _create_parser().parse_args([*command, "--help"]) @@ -250,10 +620,16 @@ def _print_help(command: Sequence[str] = ()) -> None: def _validate_login(namespace: argparse.Namespace) -> None: - if getattr(namespace, "profile", None) and namespace.profile.startswith("+"): + profile = getattr(namespace, "profile", None) + if profile in {".", "default"}: + namespace.profile = "default" + profile = "default" + if profile and not profile[0].isalnum(): if getattr(namespace, "target", None): raise _configs.OperationalError("Specify a target only once.") - namespace.target = namespace.profile + if len(profile) == 1: + raise _configs.OperationalError("A target shorthand requires a name.") + namespace.target = "+" + profile[1:] namespace.profile = None if getattr(namespace, "policy", None) and not ( getattr(namespace, "role", None) @@ -354,16 +730,98 @@ def _run_mfa(context: _configs.Context) -> _configs.Result: def _run_logout(context: _configs.Context) -> _configs.Result: - if context.args.profile.startswith("+") and not context.args.target: - context.args.target = context.args.profile + if getattr(context.args, "except_profiles", None) and not bool( + getattr(context.args, "all", False) + ): + raise _configs.OperationalError("--except requires --all.") + if bool(getattr(context.args, "all", False)): + report = _sessions.logout_all(context.args) + excluded = set(getattr(context.args, "except_profiles", []) or []) + for item in _sessions.profile_inventory()["profiles"]: + if item.get("auth_method") != "legacy-mfa": + continue + if _sessions.matches_logout_exclusion( + destination=item["directory"], + profile=item["profile"], + location=item.get("location"), + excluded=excluded, + ): + report["outcomes"].append( + { + "profile": item["profile"], + "destination": item["directory"], + "state": "excluded", + "changed": False, + } + ) + continue + legacy_args = argparse.Namespace( + **{ + **vars(context.args), + "all": False, + "target": None, + "directory": item["directory"], + "aws_account_name": None, + "profile": item["profile"], + } + ) + legacy_context = _configs.Context(args=legacy_args) + try: + _aws.logout(legacy_context) + report["outcomes"].append( + { + "profile": item["profile"], + "destination": item["directory"], + "state": "logged-out", + "changed": True, + } + ) + except _configs.OperationalError as error: + report["errors"].append( + { + "key": f"{item['directory']}::{item['profile']}", + "message": str(error), + } + ) + message = _logout_report_text(report) + return _configs.Result( + "LOGOUT_ALL", + message, + 1 if report["errors"] else 0, + "stderr" if report["errors"] else "stdout", + report, + kind="info", + ) + profile = context.args.profile + if profile in {".", "default"}: + context.args.profile = "default" + elif profile and not profile[0].isalnum() and not context.args.target: + if len(profile) == 1: + raise _configs.OperationalError("A target shorthand requires a name.") + context.args.target = "+" + profile[1:] context.args.profile = "default" _sessions.recover_journal() if _sessions.logout(context): - return _configs.Result("LOGOUT", f"Logged out of profile {context.profile}") + return _configs.Result( + "LOGOUT", + f"Logged out of profile {context.profile}", + data={"profile": context.profile, "changed": True}, + ) os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(context.credentials_path) os.environ["AWS_CONFIG_FILE"] = str(context.config_path) + legacy_active = context.storage_path.exists() _aws.logout(context) - return _configs.Result("MFA_LOGOUT", f"Logged out of profile {context.profile}") + changed = legacy_active + return _configs.Result( + "MFA_LOGOUT" if changed else "LOGOUT_NO_STATE", + ( + f"Logged out of profile {context.profile}" + if changed + else f"No Hacksaws-managed login state found for profile {context.profile}." + ), + data={"profile": context.profile, "changed": changed}, + kind="info" if not changed else "success", + ) def _run_browser(context: _configs.Context) -> _configs.Result: @@ -391,6 +849,224 @@ def _json_or_text(value: object, use_json: bool) -> str: return str(value) +def _text_table(columns: list[str], rows: list[list[object]]) -> str: + if not rows: + return "(none)" + rendered = [ + [str(value) if value is not None else "-" for value in row] for row in rows + ] + widths = [ + max(len(column), *(len(row[index]) for row in rendered)) + for index, column in enumerate(columns) + ] + header = " ".join( + column.ljust(widths[index]) for index, column in enumerate(columns) + ) + divider = " ".join("-" * width for width in widths) + body = [ + " ".join(value.ljust(widths[index]) for index, value in enumerate(row)) + for row in rendered + ] + return "\n".join([header, divider, *body]) + + +def _status_text(report: dict[str, Any]) -> str: + rows = [ + [ + item.get("location") or item.get("destination"), + item.get("profile", "default"), + item.get("state"), + item.get("auth_method"), + item.get("target_account") or item.get("source_account"), + item.get("boundary") or item.get("role"), + item.get("remaining_seconds"), + (item.get("verification") or {}).get("status"), + ] + for item in report["sessions"] + ] + return _text_table( + [ + "LOCATION", + "PROFILE", + "STATE", + "AUTH", + "ACCOUNT", + "SCOPE", + "REMAINING", + "VERIFY", + ], + rows, + ) + + +def _profile_list_text(report: dict[str, Any], *, wide: bool = False) -> str: + columns = ["LOCATION", "PROFILE", "STATE", "AUTH", "VERIFY"] + if wide: + columns.append("DIRECTORY") + return _text_table( + columns, + [ + [ + item.get("location"), + item["profile"], + item["state"], + item.get("auth_method"), + (item.get("verification") or {}).get("status"), + *([item["directory"]] if wide else []), + ] + for item in report["profiles"] + ], + ) + + +def _config_text(data: dict[str, Any], *, account: str | None = None) -> str: + """Render configuration as compact domain tables instead of Python reprs.""" + accounts = data.get("accounts", {}) + selected_accounts = { + name: value + for name, value in accounts.items() + if account is None or name.casefold() == account.casefold() + } + if account and not selected_accounts: + raise _configs.OperationalError(f"Unknown configured account {account!r}.") + account_names = set(selected_accounts) + boundaries = { + name: value + for name, value in data.get("boundaries", {}).items() + if account is None or value.get("account") in account_names + } + boundary_names = set(boundaries) + targets = { + name: value + for name, value in data.get("targets", {}).items() + if account is None + or value.get("source_account") in account_names + or value.get("boundary") in boundary_names + } + policies = data.get("policies", {}) + if account is not None: + used_policies = { + str(value["policy"]) for value in boundaries.values() if value.get("policy") + } + policies = { + name: value for name, value in policies.items() if name in used_policies + } + sections = [ + "Accounts\n" + + _text_table( + ["NAME", "ID", "PARTITION", "VERIFIED", "DESCRIPTION"], + [ + [ + name, + value.get("id"), + value.get("partition"), + "no" if value.get("unverified") else "yes", + value.get("description"), + ] + for name, value in sorted(selected_accounts.items()) + ], + ), + "Boundaries\n" + + _text_table( + ["NAME", "ACCOUNT", "ROLE", "POLICY", "DURATION"], + [ + [ + name, + value.get("account"), + value.get("role_arn"), + value.get("policy"), + value.get("duration"), + ] + for name, value in sorted(boundaries.items()) + ], + ), + "Targets\n" + + _text_table( + ["NAME", "ACCOUNT", "SOURCE", "DESTINATION", "BOUNDARY"], + [ + [ + name, + value.get("source_account"), + ( + f"{value.get('source_location', value.get('source_directory', 'default'))}:" + f"{value.get('source_profile', 'default')}" + ), + ( + f"{value.get('destination_location', value.get('destination_directory', '-'))}:" + f"{value.get('destination_profile', '-')}" + ), + value.get("boundary"), + ] + for name, value in sorted(targets.items()) + ], + ), + "Stored policies\n" + + _text_table( + ["NAME", "FILE", "DESCRIPTION"], + [ + [name, value.get("file"), value.get("description")] + for name, value in sorted(policies.items()) + ], + ), + ] + if account is None: + sections.append( + "Settings\n" + + _text_table( + ["AREA", "VALUE"], + [ + ["cache.max-age", data.get("cache", {}).get("max_age")], + ["output.color", data.get("output", {}).get("color")], + ], + ) + ) + return "\n\n".join(sections) + + +def _logout_report_text(report: dict[str, Any]) -> str: + rows = [ + [item.get("profile"), item.get("destination"), item["state"]] + for item in report["outcomes"] + ] + rows.extend( + [["-", item["key"], f"error: {item['message']}"] for item in report["errors"]] + ) + return _text_table(["PROFILE", "DESTINATION", "RESULT"], rows) + + +def _cache_list_text(entries: list[dict[str, Any]]) -> str: + return _text_table( + ["ENTRY", "STATE", "ORIGIN", "SOURCE", "AGE", "BYTES"], + [ + [ + item["identity"], + item["state"], + item.get("origin"), + item.get("source_identity"), + item.get("age_seconds"), + item["size"], + ] + for item in entries + ], + ) + + +def _cache_status_text(report: dict[str, Any]) -> str: + counts = report["counts"] + return "\n".join( + ( + f"Policy cache: {report['root']}", + f"Max age: {report['max_age']} seconds", + ( + f"Entries: {sum(counts.values())} " + f"({counts['fresh']} fresh, {counts['stale']} stale, " + f"{counts['invalid']} invalid)" + ), + f"Size: {report['total_bytes']} bytes", + ) + ) + + def _cascade_plan(data: dict[str, Any], kind: str, name: str) -> dict[str, set[str]]: """Compute whole-resource dependent deletion without weakening boundaries.""" key, _ = _state.get_resource(data, kind, name) @@ -536,10 +1212,11 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: ) else: try: - caller_id, caller_partition, _ = _sessions._identity( - boto3.Session(profile_name=args.profile), - label="account configuration", - ) + with _selected_credential_session(args) as selected_session: + caller_id, caller_partition, _ = _sessions._identity( + selected_session, + label="account configuration", + ) except _configs.OperationalError: raise if caller_id != args.account_id: @@ -564,11 +1241,10 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: if not args.no_verify: role_name = role.split("role/", 1)[-1] try: - response = ( - boto3.Session(profile_name=args.profile) - .client("iam") - .get_role(RoleName=role_name) - ) + with _selected_credential_session(args) as selected_session: + response = selected_session.client("iam").get_role( + RoleName=role_name + ) role = response["Role"]["Arn"] except (BotoCoreError, ClientError, KeyError) as error: raise _configs.OperationalError( @@ -659,11 +1335,10 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: if not args.no_verify: role_name = role.split("role/", 1)[-1] try: - role = ( - boto3.Session(profile_name=args.profile) - .client("iam") - .get_role(RoleName=role_name)["Role"]["Arn"] - ) + with _selected_credential_session(args) as selected_session: + role = selected_session.client("iam").get_role( + RoleName=role_name + )["Role"]["Arn"] except (BotoCoreError, ClientError, KeyError) as error: raise _configs.OperationalError( f"Unable to verify role update {role_name!r}: {error}" @@ -796,37 +1471,258 @@ def _run_cache(args: argparse.Namespace) -> _configs.Result: "CACHE_SET", f"Policy cache max-age set to {data['cache']['max_age']} seconds.", ) - if args.cache_action == "clear": - shutil.rmtree(_policies.cache_root(), ignore_errors=True) - return _configs.Result("CACHE_CLEAR", "Policy cache cleared.") if args.cache_action == "get": - entries = ( - list(_policies.cache_root().glob("*.json")) - if _policies.cache_root().exists() - else [] + value: object = ( + {"setting": "max-age", "value": data["cache"]["max_age"]} + if args.setting == "max-age" + else {"max_age": data["cache"]["max_age"]} + ) + return _configs.Result( + "CACHE_GET", + _json_or_text(value, use_json=args.json), + data=value, + kind="info", + ) + if args.cache_action in {"status", "list"}: + report = _policies.cache_inventory() + if args.cache_action == "status": + summary = {key: value for key, value in report.items() if key != "entries"} + return _configs.Result( + "CACHE_STATUS", + _cache_status_text(report), + data=summary, + kind="info", + ) + state = ( + "fresh" + if args.fresh + else "stale" + if args.stale + else "invalid" + if args.invalid + else None + ) + entries = [ + item + for item in report["entries"] + if (state is None or item["state"] == state) + and (args.origin is None or item.get("origin") == args.origin) + and ( + not args.patterns + or any( + fnmatch.fnmatchcase( + str(item["identity"]).casefold(), pattern.casefold() + ) + for pattern in args.patterns + ) + ) + ] + result = {"entries": entries, "count": len(entries), "counts": report["counts"]} + return _configs.Result( + "CACHE_LIST", _cache_list_text(entries), data=result, kind="info" + ) + if args.cache_action == "show": + value = _policies.cache_show(args.entry) + return _configs.Result( + "CACHE_SHOW", + json.dumps(value, indent=2, default=str), + data=value, + kind="info", + ) + if args.cache_action == "clear": + if args.entries and (args.stale or args.all): + raise _configs.OperationalError( + "Cache entry names cannot be combined with --stale or --all." + ) + inventory = _policies.cache_inventory() + if args.entries: + candidates = [ + item + for item in inventory["entries"] + if any( + fnmatch.fnmatchcase( + str(item["identity"]).casefold(), pattern.casefold() + ) + for pattern in args.entries + ) + ] + elif args.stale: + candidates = [ + item + for item in inventory["entries"] + if item["state"] in {"stale", "invalid"} + ] + else: + candidates = list(inventory["entries"]) + if candidates and not args.yes: + interactive = not _configs.json_output_enabled() and bool( + getattr(sys.stdin, "isatty", lambda: False)() + ) + if not _output.confirm( + f"Remove {len(candidates)} policy-cache entr{'y' if len(candidates) == 1 else 'ies'}?", + stdin=sys.stdin, + interactive=interactive, + ): + return _configs.Result( + "CACHE_CLEAR_CANCELLED", + "Policy cache clear cancelled; no entries were removed.", + _configs.EXIT_CANCELLED, + "stderr", + {"removed": []}, + ) + removed = _policies.clear_cache_entries( + args.entries or None, stale_only=bool(args.stale) + ) + result = {"removed": removed, "count": len(removed)} + return _configs.Result( + "CACHE_CLEAR", + f"Removed {len(removed)} policy-cache entr{'y' if len(removed) == 1 else 'ies'}.", + data=result, ) - value = {"max_age": data["cache"]["max_age"], "entries": len(entries)} - return _configs.Result("CACHE_GET", _json_or_text(value, args.json)) _print_help(("cache",)) return _configs.Result("CACHE_HELP", "Choose a cache action.", 2, "stderr") +def _run_profile(args: argparse.Namespace) -> _configs.Result: + if args.profile_action != "list": + _print_help(("profile",)) + return _configs.Result("PROFILE_HELP", "Choose a profile action.", 2, "stderr") + report = _sessions.profile_inventory(args.patterns, verify=args.verify) + return _configs.Result( + "PROFILE_LIST", + _profile_list_text(report, wide=args.wide), + data=report, + kind="info", + ) + + def _run_config(args: argparse.Namespace) -> _configs.Result: action = args.config_action + if action == "options": + options = _state.config_option_patterns() + return _configs.Result( + "CONFIG_OPTIONS", + _json_or_text(options, args.json), + data=options, + ) + if action in {"option", "opt"}: + option_action = args.option_action + if option_action in {"list", "ls"}: + options = _state.config_option_patterns() + return _configs.Result( + "CONFIG_OPTION_LIST", _json_or_text(options, args.json), data=options + ) + data = _state.load_config() + if option_action == "get": + nested_option_value = _state.get_config_option(data, args.key) + return _configs.Result( + "CONFIG_OPTION_GET", + _json_or_text(nested_option_value, args.json), + data={"key": args.key, "value": nested_option_value}, + ) + if option_action == "explain": + descriptions = _state.config_option_patterns() + matching = { + pattern: detail + for pattern, detail in descriptions.items() + if args.key == pattern or args.key in pattern + } + if not matching: + raise _configs.OperationalError( + f"Unknown config option {args.key!r}; run 'config options'." + ) + return _configs.Result( + "CONFIG_OPTION_EXPLAIN", + _json_or_text(matching, args.json), + data=matching, + ) + if option_action == "set": + try: + nested_set_value: object = json.loads(args.value) + except json.JSONDecodeError: + nested_set_value = args.value + _state.set_config_option(data, args.key, nested_set_value) + _state.save_config(data) + return _configs.Result( + "CONFIG_OPTION_SET", + f"Set config option {args.key}.", + data={ + "key": args.key, + "value": _state.get_config_option(data, args.key), + }, + ) + if option_action == "reset": + _state.reset_config_option(data, args.key) + _state.save_config(data) + return _configs.Result( + "CONFIG_OPTION_RESET", + f"Reset config option {args.key}.", + data={"key": args.key, "reset": True}, + ) + _print_help(("config", "option")) + return _configs.Result( + "CONFIG_OPTION_HELP", "Choose a config option action.", 2, "stderr" + ) + if action in {"get", "set", "reset"}: + data = _state.load_config() + if action == "get": + direct_option_value = _state.get_config_option(data, args.key) + return _configs.Result( + "CONFIG_OPTION_GET", + _json_or_text(direct_option_value, args.json), + data={"key": args.key, "value": direct_option_value}, + ) + if action == "set": + try: + direct_option_value = json.loads(args.value) + except json.JSONDecodeError: + direct_option_value = args.value + _state.set_config_option(data, args.key, direct_option_value) + _state.save_config(data) + return _configs.Result( + "CONFIG_OPTION_SET", + f"Set config option {args.key}.", + data={ + "key": args.key, + "value": _state.get_config_option(data, args.key), + }, + ) + _state.reset_config_option(data, args.key) + _state.save_config(data) + return _configs.Result( + "CONFIG_OPTION_RESET", + f"Reset config option {args.key}.", + data={"key": args.key, "reset": True}, + ) if action == "show": data = _state.load_config() + display_data: dict[str, Any] = data + account_name: str | None = None if args.account: - key, account = _state.get_resource(data, "account", args.account) - data = { - "account": {"name": key, **account}, - "boundaries": { - k: v - for k, v in data["boundaries"].items() - if str(v["account"]).casefold() == key.casefold() + account_name, account = _state.get_resource(data, "account", args.account) + boundaries = { + name: value + for name, value in data["boundaries"].items() + if str(value["account"]).casefold() == account_name.casefold() + } + boundary_names = {name.casefold() for name in boundaries} + display_data = { + "account": {"name": account_name, **account}, + "boundaries": boundaries, + "targets": { + name: value + for name, value in data["targets"].items() + if str(value.get("source_account", "")).casefold() + == account_name.casefold() + or str(value.get("boundary", "")).casefold() in boundary_names }, - "targets": data["targets"], } - return _configs.Result("CONFIG_SHOW", _json_or_text(data, args.json)) + text = ( + json.dumps(display_data, indent=2, default=str) + if args.json + else _config_text(data, account=account_name) + ) + return _configs.Result("CONFIG_SHOW", text, data=display_data) if action == "explain": value = _sessions.explain_target(args.target) return _configs.Result("CONFIG_EXPLAIN", _json_or_text(value, args.json)) @@ -853,15 +1749,62 @@ def _run_config(args: argparse.Namespace) -> _configs.Result: return _configs.Result("CONFIG_HELP", "Choose a config action.", 2, "stderr") -def console_main(arguments: Sequence[str] | None = None) -> _configs.Result: +def _console_main_invocation(arguments: Sequence[str] | None = None) -> _configs.Result: + raw_arguments = list(sys.argv[1:] if arguments is None else arguments) + preselected_json = _json_requested(raw_arguments) + _configs.configure_output(color="auto", json_output=preselected_json) + try: + normalized_arguments, requested_color, use_json = _extract_global_options( + raw_arguments + ) + _iam_cli.validate_selector_arguments(normalized_arguments) + except _configs.OperationalError as error: + return _configs.Result( + "ARGUMENT_ERROR", f"Error: {error}", _configs.EXIT_USAGE, "stderr" + ).echo() + _configs.configure_output( + color=cast("Any", requested_color or "auto"), json_output=use_json + ) parser = _create_parser() + parse_stderr = io.StringIO() + parse_stdout = io.StringIO() try: - namespace = parser.parse_args(arguments) + with ( + contextlib.redirect_stderr(parse_stderr) + if use_json + else contextlib.nullcontext(), + contextlib.redirect_stdout(parse_stdout) + if use_json + else contextlib.nullcontext(), + ): + namespace = parser.parse_args(normalized_arguments) except SystemExit as error: - return _configs.Result( - "HELP" if error.code == 0 else "ARGUMENT_ERROR", "", cast("int", error.code) + result = _configs.Result( + "HELP" if error.code == 0 else "ARGUMENT_ERROR", + "" if error.code == 0 else parse_stderr.getvalue().strip(), + cast("int", error.code), + "stderr", + {"help": parse_stdout.getvalue().strip()} if error.code == 0 else None, + ) + return result.echo() if use_json else result + namespace.json = use_json or bool(getattr(namespace, "json", False)) + if requested_color is None and namespace.access_type: + try: + configured_color = _state.load_config()["output"]["color"] + except _configs.OperationalError: + configured_color = "auto" + _configs.configure_output( + color=cast("Any", configured_color), json_output=use_json ) if not namespace.access_type: + if use_json: + return _configs.Result( + "ACCESS_TYPE_HELP", + "Not enough arguments.", + 2, + "stderr", + {"help": parser.format_help().strip()}, + ).echo() _print_help() return _configs.Result( "ACCESS_TYPE_HELP", "Not enough arguments.", 2, "stderr" @@ -871,35 +1814,91 @@ def console_main(arguments: Sequence[str] | None = None) -> _configs.Result: namespace.mfa_code = namespace.profile namespace.profile = None if namespace.mfa_code is None: - parser.print_usage(sys.stderr) + usage = parser.format_usage().strip() + if not use_json: + parser.print_usage(sys.stderr) return _configs.Result( "ARGUMENT_ERROR", "the following arguments are required: PROFILE CODE or +TARGET CODE.", 2, "stderr", + {"usage": usage} if use_json else None, ).echo() + captured_stdout = io.StringIO() + captured_stderr = io.StringIO() + machine_stdin = _NonInteractiveStdin() try: - _sessions.recover_journal() - if namespace.access_type == "mfa": - result = _run_mfa(_configs.Context(args=namespace)) - elif namespace.access_type in {"pk", "web"}: - result = _run_browser(_configs.Context(args=namespace)) - elif namespace.access_type == "logout": - result = _run_logout(_configs.Context(args=namespace)) - elif namespace.access_type == "status": - result = _configs.Result( - "STATUS", _json_or_text(_sessions.status(), namespace.json) - ) - elif namespace.access_type in {"account", "boundary", "target"}: - result = _run_resource(namespace) - elif namespace.access_type == "policy": - result = _run_policy(namespace) - elif namespace.access_type == "cache": - result = _run_cache(namespace) - else: - result = _run_config(namespace) + with ( + contextlib.redirect_stdout(captured_stdout) + if use_json + else contextlib.nullcontext(), + contextlib.redirect_stderr(captured_stderr) + if use_json + else contextlib.nullcontext(), + _redirect_stdin(machine_stdin) if use_json else contextlib.nullcontext(), + ): + is_iam_recovery = namespace.access_type in {"iam", "remote"} and getattr( + namespace, "iam_action", None + ) in {"recovery", "recover"} + is_remote_dry_run = namespace.access_type in { + "iam", + "remote", + "cleanup", + } and bool(getattr(namespace, "dry_run", False)) + if not (is_iam_recovery or is_remote_dry_run): + _sessions.recover_journal() + if namespace.access_type == "mfa": + result = _run_mfa(_configs.Context(args=namespace)) + elif namespace.access_type in {"pk", "web"}: + result = _run_browser(_configs.Context(args=namespace)) + elif namespace.access_type in {"iam", "remote"}: + result = _iam_cli.dispatch(namespace) + elif namespace.access_type == "cleanup": + result = _iam_cli.dispatch_root_cleanup(namespace) + elif namespace.access_type == "logout": + result = _run_logout(_configs.Context(args=namespace)) + elif namespace.access_type == "status": + status_report = _sessions.status_report( + profile=namespace.profile, + location=namespace.location, + directory=Path(namespace.directory) + if namespace.directory + else None, + verify=namespace.verify, + ) + result = _configs.Result( + "STATUS", + _status_text(status_report), + data=status_report, + kind="info", + ) + elif namespace.access_type == "profile": + result = _run_profile(namespace) + elif namespace.access_type in {"account", "boundary", "target"}: + result = _run_resource(namespace) + elif namespace.access_type == "policy": + result = _run_policy(namespace) + elif namespace.access_type == "cache": + result = _run_cache(namespace) + else: + result = _run_config(namespace) except _configs.OperationalError as error: - return _configs.Result( - "OPERATIONAL_ERROR", f"Error: {error}", 1, "stderr" - ).echo() + result = _configs.Result( + "OPERATIONAL_ERROR", + f"Error: {error}", + 1, + "stderr", + error.data, + error.details, + error.repairs, + ) return result.echo() + + +def console_main(arguments: Sequence[str] | None = None) -> _configs.Result: + """Run one isolated CLI invocation without leaking output mode to callers.""" + _configs.configure_output() + try: + return _console_main_invocation(arguments) + finally: + _configs.configure_output() diff --git a/hacksaws/_configs.py b/hacksaws/_configs.py index f01e6de..486d9ec 100644 --- a/hacksaws/_configs.py +++ b/hacksaws/_configs.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +import json import sys from pathlib import Path from typing import TYPE_CHECKING @@ -13,16 +14,46 @@ from botocore.exceptions import BotoCoreError from botocore.exceptions import ClientError +from hacksaws import _output + if TYPE_CHECKING: import argparse from collections.abc import Mapping ContainerEngine = Literal["docker", "podman"] +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_USAGE = 2 +EXIT_POLICY = 3 +EXIT_CANCELLED = 4 +EXIT_INTERRUPTED = 130 + +_output_options = [_output.OutputOptions()] + + +def configure_output( + *, color: _output.ColorMode = "auto", json_output: bool = False +) -> None: + """Set invocation output behavior after global CLI options are normalized.""" + _output_options[0] = _output.OutputOptions(color=color, json=json_output) class OperationalError(Exception): """An expected operational failure that is safe to show without a traceback.""" + def __init__( + self, + message: str, + *, + data: object | None = None, + details: object | None = None, + repairs: object | None = None, + ) -> None: + super().__init__(message) + self.data = data + self.details = details + self.repairs = repairs + @dataclasses.dataclass(frozen=True) class Context: @@ -33,7 +64,8 @@ class Context: @property def profile(self) -> str: """Return the AWS profile name for this invocation.""" - return cast("str", getattr(self.args, "profile", None) or "default") + value = cast("str | None", getattr(self.args, "profile", None)) + return "default" if value in {None, ".", "default"} else value @property def container_engine(self) -> ContainerEngine: @@ -47,7 +79,13 @@ def aws_directory(self) -> Path: """Return the directory containing AWS configuration and credentials.""" account_name = cast("str | None", getattr(self.args, "aws_account_name", None)) configured_directory = cast("str", getattr(self.args, "directory", "~/.aws")) - value = f"~/.aws-{account_name}" if account_name else configured_directory + value = ( + "~/.aws" + if account_name in {".", "default"} + else f"~/.aws-{account_name}" + if account_name + else configured_directory + ) return Path(value).expanduser().absolute() @property @@ -66,6 +104,31 @@ def storage_path(self) -> Path: return self.aws_directory / f"{self.profile}.store.credentials" +@dataclasses.dataclass(frozen=True) +class CredentialSelector: + """Resolved local credential source; resolution never performs a login.""" + + profile: str = "default" + location: str = "default" + directory: Path | None = None + target: str | None = None + + +def resolve_credential_selector(args: argparse.Namespace) -> CredentialSelector: + """Normalize credential selection without AWS side effects.""" + profile = cast("str", getattr(args, "profile", None) or "default") + location = cast("str", getattr(args, "location", None) or "default") + directory_value = cast("str | None", getattr(args, "directory", None)) + return CredentialSelector( + profile=profile, + location=location, + directory=( + Path(directory_value).expanduser().absolute() if directory_value else None + ), + target=cast("str | None", getattr(args, "target", None)), + ) + + @dataclasses.dataclass(frozen=True) class AwsAccount: """AWS account identity and ECR region configuration.""" @@ -154,10 +217,41 @@ class Result: message: str exit_code: int = 0 stream: Literal["stdout", "stderr"] = "stdout" + data: object | None = None + details: object | None = None + repairs: object | None = None + kind: Literal["info", "success", "warning", "error"] | None = None def echo(self) -> Result: """Write the result message to its intended output stream.""" - if self.message: - output = sys.stderr if self.stream == "stderr" else sys.stdout - print(self.message, file=output) + output = sys.stderr if self.stream == "stderr" else sys.stdout + if _output_options[0].json: + envelope: dict[str, object] = { + "schemaVersion": _output.SCHEMA_VERSION, + "ok": self.exit_code == EXIT_OK, + "code": self.code, + } + if self.exit_code == EXIT_OK: + envelope["data"] = ( + self.data if self.data is not None else {"message": self.message} + ) + else: + envelope["error"] = { + "message": self.message, + "exitCode": self.exit_code, + "data": self.data if self.data is not None else {}, + "details": self.details if self.details is not None else [], + "repairs": self.repairs if self.repairs is not None else [], + } + print(json.dumps(envelope, indent=2, default=str), file=output) + elif self.message: + kind = self.kind or ("error" if self.stream == "stderr" else "success") + _output.print_message( + self.message, stream=output, options=_output_options[0], kind=kind + ) return self + + +def json_output_enabled() -> bool: + """Return whether the current invocation requires one machine envelope.""" + return _output_options[0].json diff --git a/hacksaws/_iam_cleanup.py b/hacksaws/_iam_cleanup.py new file mode 100644 index 0000000..f4c1be8 --- /dev/null +++ b/hacksaws/_iam_cleanup.py @@ -0,0 +1,1272 @@ +"""Account-scoped inventory and Leave No Trace cleanup planning for IAM.""" + +# Cleanup deliberately exposes complete operator-facing diagnostics. +# ruff: noqa: ANN401, BLE001, C901, PLR0913, TRY003 + +from __future__ import annotations + +import fnmatch +import random +import time +from collections import deque +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Mapping +from dataclasses import dataclass +from dataclasses import field +from enum import StrEnum +from typing import Any +from typing import cast + +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError + +from hacksaws import _iam_managed_policies as policies +from hacksaws import _iam_recovery as recovery +from hacksaws import _iam_roles as roles +from hacksaws._configs import OperationalError + +ORIGIN_TAG = "hacksaws:ownership-origin" +SMOKE_TAG = "hacksaws:smoke" +SMOKE_RUN_TAG = "hacksaws:run-id" +ROLE_KIND_TAG = "hacksaws:resource-kind" +ROLE_ID_TAG = "hacksaws:resource-id" +_RECOVERY_SERVICE = "iam-cleanup" +_HANDLER = "aws-operation" +_LNT_ATTEMPTS = 3 +_TRANSIENT_CODES = frozenset( + { + "ConcurrentModification", + "DeleteConflict", + "LimitExceeded", + "RequestLimitExceeded", + "ServiceFailure", + "ServiceUnavailable", + "Throttling", + "ThrottlingException", + "TooManyRequestsException", + } +) + + +class ResourceType(StrEnum): + """First-class remote resource types understood by cleanup.""" + + ROLE = "role" + POLICY = "policy" + GROUP_GRANT = "group-grant" + + +class OwnershipOrigin(StrEnum): + """How a managed resource entered Hacksaws ownership.""" + + CREATED = "created" + ADOPTED = "adopted" + LEGACY = "legacy" + UNKNOWN = "unknown" + + +class PlanClassification(StrEnum): + """Stable cleanup planning outcomes used by CLI exit classification.""" + + PLANNED = "planned" + NO_MATCHES = "no-matches" + BLOCKED = "blocked" + + +class ResultClassification(StrEnum): + """Stable cleanup execution outcomes used by CLI exit classification.""" + + CLEANED = "cleaned" + PARTIAL = "partial" + BLOCKED = "blocked" + RECOVERY_REQUIRED = "recovery-required" + + +@dataclass(frozen=True, slots=True) +class InventoryItem: + """One normalized IAM inventory item with its deletion-relevant state.""" + + resource_type: ResourceType + name: str + arn: str + resource_id: str + origin: OwnershipOrigin + owned: bool + path: str + smoke: bool = False + smoke_run_id: str | None = None + dependencies: Mapping[str, tuple[str, ...]] = field(default_factory=dict) + snapshot: object | None = field(default=None, repr=False, compare=False) + + @property + def key(self) -> str: + """Return a stable plan key for this account-local resource.""" + return f"{self.resource_type.value}:{self.arn}" + + def as_dict(self) -> dict[str, object]: + """Return credential-free structured output for CLI renderers.""" + return { + "type": self.resource_type.value, + "name": self.name, + "arn": self.arn, + "resourceId": self.resource_id, + "origin": self.origin.value, + "owned": self.owned, + "path": self.path, + "smoke": self.smoke, + "smokeRunId": self.smoke_run_id, + "dependencies": { + key: list(value) for key, value in self.dependencies.items() + }, + } + + +@dataclass(frozen=True, slots=True) +class IamInventory: + """Account-bound remote IAM inventory.""" + + account_id: str + partition: str + caller_arn: str + items: tuple[InventoryItem, ...] + warnings: tuple[str, ...] = () + + def filter( + self, + *, + patterns: Iterable[str] = (), + resource_types: Iterable[ResourceType] = (), + origins: Iterable[OwnershipOrigin] = (), + owned_only: bool = False, + smoke_only: bool = False, + smoke_run_id: str | None = None, + ) -> tuple[InventoryItem, ...]: + """Filter inventory with case-insensitive fnmatch name/ARN semantics.""" + selected_patterns = tuple(patterns) + selected_types = frozenset(resource_types) + selected_origins = frozenset(origins) + return tuple( + item + for item in self.items + if (not owned_only or item.owned) + and (not selected_types or item.resource_type in selected_types) + and (not selected_origins or item.origin in selected_origins) + and (not smoke_only or item.smoke) + and (smoke_run_id is None or item.smoke_run_id == smoke_run_id) + and ( + not selected_patterns + or any( + fnmatch.fnmatchcase(item.name.casefold(), pattern.casefold()) + or fnmatch.fnmatchcase(item.arn.casefold(), pattern.casefold()) + for pattern in selected_patterns + ) + ) + ) + + def as_dict(self) -> dict[str, object]: + """Return structured inventory output.""" + return { + "accountId": self.account_id, + "partition": self.partition, + "callerArn": self.caller_arn, + "count": len(self.items), + "items": [item.as_dict() for item in self.items], + "warnings": list(self.warnings), + } + + +@dataclass(frozen=True, slots=True) +class CleanupOptions: + """Selection and dependency safeguards for one cleanup plan.""" + + patterns: tuple[str, ...] = () + all_resources: bool = False + resource_types: frozenset[ResourceType] = frozenset() + origins: frozenset[OwnershipOrigin] = frozenset() + smoke_only: bool = False + smoke_run_id: str | None = None + cascade: bool = False + remove_boundaries: bool = False + remove_from_instance_profiles: bool = False + dry_run: bool = True + + +@dataclass(frozen=True, slots=True) +class CleanupBlocker: + """One explicit reason an otherwise-selected resource cannot be cleaned.""" + + resource_key: str + code: str + message: str + + def as_dict(self) -> dict[str, str]: + """Return structured blocker output.""" + return { + "resource": self.resource_key, + "code": self.code, + "message": self.message, + } + + +@dataclass(frozen=True, slots=True) +class CleanupStep: + """One durable, dependency-ordered AWS mutation.""" + + id: str + resource_key: str + action: str + params: Mapping[str, object] + compensate_action: str | None = None + compensate_params: Mapping[str, object] = field(default_factory=dict) + prerequisites: tuple[str, ...] = () + irreversible: bool = False + + def as_dict(self) -> dict[str, object]: + """Return a credential-free exact preview.""" + return { + "id": self.id, + "resource": self.resource_key, + "action": self.action, + "params": dict(self.params), + "compensateAction": self.compensate_action, + "prerequisites": list(self.prerequisites), + "irreversible": self.irreversible, + } + + +@dataclass(frozen=True, slots=True) +class CleanupPlan: + """Frozen account-scoped cleanup selection and dependency DAG.""" + + account_id: str + partition: str + caller_arn: str + options: CleanupOptions + resources: tuple[InventoryItem, ...] + steps: tuple[CleanupStep, ...] + blockers: tuple[CleanupBlocker, ...] = () + warnings: tuple[str, ...] = () + + @property + def classification(self) -> PlanClassification: + """Classify this immutable plan.""" + if not self.resources: + return PlanClassification.NO_MATCHES + if self.blockers: + return PlanClassification.BLOCKED + return PlanClassification.PLANNED + + def as_dict(self) -> dict[str, object]: + """Return an exact operator-facing preview.""" + return { + "classification": self.classification.value, + "accountId": self.account_id, + "partition": self.partition, + "callerArn": self.caller_arn, + "resources": [item.as_dict() for item in self.resources], + "steps": [step.as_dict() for step in self.steps], + "blockers": [item.as_dict() for item in self.blockers], + "warnings": list(self.warnings), + "leaveNoTrace": { + "awsResourcesExpectedAbsent": [item.key for item in self.resources], + "localRecoveryJournalRetained": True, + }, + } + + +@dataclass(frozen=True, slots=True) +class CleanupResult: + """Durable cleanup execution result.""" + + classification: ResultClassification + journal_id: str | None + completed: tuple[str, ...] + failed: tuple[str, ...] + remaining: tuple[str, ...] + lnt: bool + + def as_dict(self) -> dict[str, object]: + """Return structured execution output.""" + return { + "classification": self.classification.value, + "journalId": self.journal_id, + "completed": list(self.completed), + "failed": list(self.failed), + "remaining": list(self.remaining), + "leaveNoTrace": self.lnt, + } + + +def _tag_values(value: Mapping[str, str] | Iterable[policies.Tag]) -> dict[str, str]: + if isinstance(value, Mapping): + return {str(key).casefold(): str(item) for key, item in value.items()} + return {tag.key.casefold(): tag.value for tag in value} + + +def ownership_origin( + tags: Mapping[str, str] | Iterable[policies.Tag], +) -> OwnershipOrigin: + """Classify explicit origin tags while preserving legacy managed resources.""" + value = _tag_values(tags).get(ORIGIN_TAG) + if value == OwnershipOrigin.CREATED.value: + return OwnershipOrigin.CREATED + if value == OwnershipOrigin.ADOPTED.value: + return OwnershipOrigin.ADOPTED + if value is None: + return OwnershipOrigin.LEGACY + return OwnershipOrigin.UNKNOWN + + +def _error_code(error: BaseException) -> str: + if isinstance(error, ClientError): + return str(error.response.get("Error", {}).get("Code", type(error).__name__)) + return type(error).__name__ + + +def _step_id(prefix: str, index: int) -> str: + return f"{prefix}-{index:04d}" + + +class CleanupService: + """Inventory, plan, and durably execute remote IAM cleanup.""" + + def __init__( + self, + context: Any, + *, + role_service: roles.IamRoleService | None = None, + policy_service: policies.IamManagedPolicyService | None = None, + sleeper: Callable[[float], None] = time.sleep, + jitter: Callable[[float, float], float] = random.uniform, + ) -> None: + self.context = context + self.role_service = role_service or roles.IamRoleService(context.iam) + self.policy_service = policy_service or policies.IamManagedPolicyService( + context.iam, + context.sts, + getattr(context, "access_analyzer", None), + policies.PolicyServiceOptions( + account_id=context.account_id, + partition=context.partition, + ), + ) + self._sleep = sleeper + self._jitter = jitter + + def inventory(self) -> IamInventory: + """Hydrate all roles and local policies into one account inventory.""" + items: list[InventoryItem] = [] + warnings: list[str] = [] + for summary in self.role_service.list_roles(path_prefix="/"): + try: + role = self.role_service.get_role(summary.name) + except (BotoCoreError, ClientError) as error: + warnings.append(f"Unable to hydrate role {summary.name}: {error}") + continue + tags = _tag_values(role.tags) + owned = tags.get(roles.MANAGED_TAG) == "true" + items.append( + InventoryItem( + ResourceType.ROLE, + role.name, + role.arn, + role.role_id, + ownership_origin(role.tags) if owned else OwnershipOrigin.UNKNOWN, + owned, + role.path, + tags.get(SMOKE_TAG) == "true", + tags.get(SMOKE_RUN_TAG), + { + "attachedPolicies": tuple(role.attached_policies), + "inlinePolicies": tuple(role.inline_policies), + "instanceProfiles": tuple(role.instance_profiles), + "permissionsBoundary": ( + (role.permissions_boundary,) + if role.permissions_boundary + else () + ), + }, + role, + ) + ) + for policy_summary in self.policy_service.list_policies( + scope=policies.PolicyScope.LOCAL, include_tags=True + ): + policy_record = self.policy_service.get_policy( + policy_summary.arn.value, + include_document=True, + include_versions=True, + include_tags=True, + ) + tags = _tag_values(policy_record.tags) + resource_id = tags.get("hacksaws:resource-id", "") + group_name = resource_id.removeprefix("group-") + resource_type = ( + ResourceType.GROUP_GRANT + if policy_record.owned + and resource_id.startswith("group-") + and group_name + and policy_record.name == f"hacksaws-{group_name}-assume-roles" + else ResourceType.POLICY + ) + dependencies = self.policy_service.policy_dependencies( + policy_record.arn.value + ) + items.append( + InventoryItem( + resource_type, + policy_record.name, + policy_record.arn.value, + policy_record.policy_id, + ( + ownership_origin(policy_record.tags) + if policy_record.owned + else OwnershipOrigin.UNKNOWN + ), + policy_record.owned, + policy_record.path, + tags.get(SMOKE_TAG) == "true", + tags.get(SMOKE_RUN_TAG), + { + "permissionUsers": tuple( + item.name for item in dependencies.permission_users + ), + "permissionGroups": tuple( + item.name for item in dependencies.permission_groups + ), + "permissionRoles": tuple( + item.name for item in dependencies.permission_roles + ), + "boundaryUsers": tuple( + item.name for item in dependencies.boundary_users + ), + "boundaryRoles": tuple( + item.name for item in dependencies.boundary_roles + ), + }, + (policy_record, dependencies), + ) + ) + return IamInventory( + self.context.account_id, + self.context.partition, + self.context.arn, + tuple( + sorted( + items, + key=lambda item: (item.resource_type, item.name.casefold()), + ) + ), + tuple(warnings), + ) + + def plan(self, options: CleanupOptions) -> CleanupPlan: + """Build an exact dependency-ordered deletion plan without mutating AWS.""" + if not ( + options.all_resources + or options.patterns + or options.smoke_only + or options.smoke_run_id + ): + raise OperationalError( + "Cleanup requires PATTERN arguments, --all, --smoke, or --smoke-run." + ) + inventory = self.inventory() + selected = inventory.filter( + patterns=() if options.all_resources else options.patterns, + resource_types=options.resource_types, + origins=options.origins, + owned_only=True, + smoke_only=options.smoke_only, + smoke_run_id=options.smoke_run_id, + ) + blockers: list[CleanupBlocker] = [] + warnings = list(inventory.warnings) + steps: list[CleanupStep] = [] + selected_roles = [ + item for item in selected if item.resource_type is ResourceType.ROLE + ] + selected_grants = [ + item for item in selected if item.resource_type is ResourceType.GROUP_GRANT + ] + selected_policies = [ + item for item in selected if item.resource_type is ResourceType.POLICY + ] + trust_steps, trust_blockers = self._group_trust_steps( + selected_grants, inventory, selected_roles + ) + steps.extend(trust_steps) + blockers.extend(trust_blockers) + trust_tail = (trust_steps[-1].id,) if trust_steps else () + grant_tail: list[str] = [] + for item in selected_grants: + grant_steps, grant_blockers = self._policy_steps(item, options, trust_tail) + steps.extend(grant_steps) + blockers.extend(grant_blockers) + if grant_steps: + grant_tail.append(grant_steps[-1].id) + role_tail: list[str] = [] + for item in selected_roles: + role_steps, role_blockers = self._role_steps( + item, options, tuple(grant_tail) + ) + steps.extend(role_steps) + blockers.extend(role_blockers) + if role_steps: + role_tail.append(role_steps[-1].id) + policy_prerequisites = (*grant_tail, *role_tail) + for item in selected_policies: + policy_steps, policy_blockers = self._policy_steps( + item, options, policy_prerequisites + ) + steps.extend(policy_steps) + blockers.extend(policy_blockers) + return CleanupPlan( + inventory.account_id, + inventory.partition, + inventory.caller_arn, + options, + selected, + tuple(steps), + tuple(blockers), + tuple(warnings), + ) + + @staticmethod + def _grant_role_arns(item: InventoryItem) -> tuple[str, ...]: + snapshot = item.snapshot + if not isinstance(snapshot, tuple) or not isinstance( + snapshot[0], policies.ManagedPolicyRecord + ): + return () + document = snapshot[0].document or {} + statements = document.get("Statement", []) + if isinstance(statements, Mapping): + statements = [statements] + result: list[str] = [] + for statement in statements if isinstance(statements, list) else []: + if not isinstance(statement, Mapping) or statement.get("Sid") != ( + "HacksawsGroupAssumeRoles" + ): + continue + resources = statement.get("Resource", []) + values = resources if isinstance(resources, list) else [resources] + result.extend(str(value) for value in values if isinstance(value, str)) + return tuple(sorted(set(result))) + + def _group_trust_steps( + self, + selected_grants: Iterable[InventoryItem], + inventory: IamInventory, + selected_roles: Iterable[InventoryItem], + ) -> tuple[list[CleanupStep], list[CleanupBlocker]]: + grants = tuple(selected_grants) + selected_grant_arns = {item.arn for item in grants} + selected_role_arns = {item.arn for item in selected_roles} + all_grants = tuple( + item + for item in inventory.items + if item.resource_type is ResourceType.GROUP_GRANT and item.owned + ) + candidates = { + arn + for item in grants + for arn in self._grant_role_arns(item) + if arn not in selected_role_arns + } + result: list[CleanupStep] = [] + blockers: list[CleanupBlocker] = [] + previous: tuple[str, ...] = () + principal = roles.DurablePrincipal( + "account", + f"arn:{self.context.partition}:iam::{self.context.account_id}:root", + self.context.account_id, + self.context.partition, + ) + for role_arn in sorted(candidates): + retained = any( + grant.arn not in selected_grant_arns + and role_arn in self._grant_role_arns(grant) + for grant in all_grants + ) + if retained: + continue + role_name = role_arn.rsplit("/", maxsplit=1)[-1] + try: + role = self.role_service.get_role(role_name) + mutation = roles.plan_remove_owned_group_trust( + role.name, role.trust, principal + ) + except (BotoCoreError, ClientError, roles.IamRoleError) as error: + blockers.append( + CleanupBlocker( + f"role:{role_arn}", + "GROUP_TRUST_BLOCKED", + str(error), + ) + ) + continue + for operation in mutation.operations: + identifier = _step_id( + f"group-trust-{role.role_id or role.name}", len(result) + ) + result.append( + CleanupStep( + identifier, + f"role:{role_arn}", + operation.action, + dict(operation.params), + operation.compensate_action, + dict(operation.compensate_params or {}), + previous, + ) + ) + previous = (identifier,) + return result, blockers + + def _role_steps( + self, + item: InventoryItem, + options: CleanupOptions, + prerequisites: tuple[str, ...], + ) -> tuple[list[CleanupStep], list[CleanupBlocker]]: + role = item.snapshot + if not isinstance(role, roles.RoleSnapshot): + raise OperationalError( + f"Role inventory snapshot is invalid for {item.arn}." + ) + blockers: list[CleanupBlocker] = [] + has_dependencies = bool( + role.attached_policies + or role.inline_policies + or role.permissions_boundary + or role.instance_profiles + ) + if has_dependencies and not options.cascade: + blockers.append( + CleanupBlocker(item.key, "CASCADE_REQUIRED", "Role has dependencies.") + ) + if role.permissions_boundary and not options.remove_boundaries: + blockers.append( + CleanupBlocker( + item.key, + "BOUNDARY_OPT_IN_REQUIRED", + "Role permissions-boundary removal requires explicit opt-in.", + ) + ) + if role.instance_profiles and not options.remove_from_instance_profiles: + blockers.append( + CleanupBlocker( + item.key, + "INSTANCE_PROFILE_OPT_IN_REQUIRED", + "Instance-profile membership removal requires explicit opt-in.", + ) + ) + if blockers: + return [], blockers + plan = roles.plan_delete_role( + role, + cascade=True, + remove_from_instance_profiles=options.remove_from_instance_profiles, + ) + result: list[CleanupStep] = [] + previous = prerequisites + for index, operation in enumerate(plan.operations): + identifier = _step_id(f"role-{role.role_id or role.name}", index) + params = dict(operation.params) + if operation.action == "delete_role": + params["ExpectedRoleId"] = role.role_id + result.append( + CleanupStep( + identifier, + item.key, + operation.action, + params, + operation.compensate_action, + dict(operation.compensate_params or {}), + previous, + operation.action == "delete_role", + ) + ) + previous = (identifier,) + return result, [] + + def _policy_steps( + self, + item: InventoryItem, + options: CleanupOptions, + prerequisites: tuple[str, ...] = (), + ) -> tuple[list[CleanupStep], list[CleanupBlocker]]: + snapshot = item.snapshot + if ( + not isinstance(snapshot, tuple) + or len(snapshot) != len(("policy", "dependencies")) + or not isinstance(snapshot[0], policies.ManagedPolicyRecord) + or not isinstance(snapshot[1], policies.PolicyDependencies) + ): + raise OperationalError( + f"Policy inventory snapshot is invalid for {item.arn}." + ) + policy, dependencies = snapshot + blockers: list[CleanupBlocker] = [] + own_group = ( + item.resource_type is ResourceType.GROUP_GRANT + and len(dependencies.permission_groups) == 1 + and dependencies.permission_groups[0].name + == _tag_values(policy.tags) + .get("hacksaws:resource-id", "") + .removeprefix("group-") + and not dependencies.permission_users + and not dependencies.permission_roles + ) + permission_dependencies = bool( + dependencies.permission_users + or dependencies.permission_groups + or dependencies.permission_roles + ) + boundary_dependencies = bool( + dependencies.boundary_users or dependencies.boundary_roles + ) + if permission_dependencies and not options.cascade and not own_group: + blockers.append( + CleanupBlocker(item.key, "CASCADE_REQUIRED", "Policy has attachments.") + ) + if boundary_dependencies and not ( + options.cascade and options.remove_boundaries + ): + blockers.append( + CleanupBlocker( + item.key, + "BOUNDARY_OPT_IN_REQUIRED", + "Policy is assigned as a permissions boundary.", + ) + ) + if blockers: + return [], blockers + result: list[CleanupStep] = [] + previous = prerequisites + + def append( + action: str, + params: Mapping[str, object], + compensation: str | None = None, + compensation_params: Mapping[str, object] | None = None, + *, + irreversible: bool = False, + ) -> None: + nonlocal previous + identifier = _step_id( + f"policy-{policy.policy_id or policy.name}", len(result) + ) + result.append( + CleanupStep( + identifier, + item.key, + action, + dict(params), + compensation, + dict(compensation_params or {}), + previous, + irreversible, + ) + ) + previous = (identifier,) + + arn = policy.arn.value + for entity in dependencies.permission_users: + append( + "detach_user_policy", + {"UserName": entity.name, "PolicyArn": arn}, + "attach_user_policy", + {"UserName": entity.name, "PolicyArn": arn}, + ) + for entity in dependencies.permission_groups: + append( + "detach_group_policy", + {"GroupName": entity.name, "PolicyArn": arn}, + "attach_group_policy", + {"GroupName": entity.name, "PolicyArn": arn}, + ) + for entity in dependencies.permission_roles: + append( + "detach_role_policy", + {"RoleName": entity.name, "PolicyArn": arn}, + "attach_role_policy", + {"RoleName": entity.name, "PolicyArn": arn}, + ) + for entity in dependencies.boundary_users: + append( + "delete_user_permissions_boundary", + {"UserName": entity.name}, + "put_user_permissions_boundary", + {"UserName": entity.name, "PermissionsBoundary": arn}, + ) + for entity in dependencies.boundary_roles: + append( + "delete_role_permissions_boundary", + {"RoleName": entity.name}, + "put_role_permissions_boundary", + {"RoleName": entity.name, "PermissionsBoundary": arn}, + ) + for version in policy.versions: + if version.version_id == policy.default_version_id: + continue + compensation_params: dict[str, object] = {} + if version.document is not None: + compensation_params = { + "PolicyArn": arn, + "PolicyDocument": policies.canonical_policy_json(version.document), + "SetAsDefault": False, + } + append( + "delete_policy_version", + {"PolicyArn": arn, "VersionId": version.version_id}, + "create_policy_version" if compensation_params else None, + compensation_params, + ) + append( + "delete_policy", + {"PolicyArn": arn, "ExpectedPolicyId": policy.policy_id}, + irreversible=True, + ) + return result, [] + + def execute(self, plan: CleanupPlan) -> CleanupResult: + """Execute a confirmed plan through an account-bound durable retry queue.""" + if ( + plan.account_id != self.context.account_id + or plan.partition != self.context.partition + ): + raise OperationalError( + "Cleanup plan does not match selected AWS credentials." + ) + if plan.classification is PlanClassification.NO_MATCHES: + return CleanupResult( + ResultClassification.CLEANED, None, (), (), (), lnt=True + ) + if plan.blockers: + return CleanupResult( + ResultClassification.BLOCKED, + None, + (), + tuple(item.resource_key for item in plan.blockers), + tuple(item.key for item in plan.resources), + lnt=False, + ) + self._assert_plan_current(plan) + ensure_recovery_handler() + journal = recovery.begin_journal( + _RECOVERY_SERVICE, + plan.account_id, + "cleanup", + partition=plan.partition, + ) + for step in plan.steps: + journal.record_before_mutation( + _HANDLER, + forward={ + "planStepId": step.id, + "resourceKey": step.resource_key, + "prerequisites": list(step.prerequisites), + "action": step.action, + "params": dict(step.params), + "irreversible": step.irreversible, + }, + compensation={ + "action": step.compensate_action, + "params": dict(step.compensate_params), + "irreversible": step.irreversible, + "forwardAction": step.action, + "forwardParams": dict(step.params), + }, + ) + state = _continue_queue( + journal.id, + self.context, + sleeper=self._sleep, + jitter=self._jitter, + ) + completed = tuple(cast("list[str]", state["completed"])) + failed = tuple(cast("list[str]", state["failed"])) + remaining = tuple(cast("list[str]", state["remaining"])) + residue = ( + self._verify_lnt(plan.resources) if not failed and not remaining else () + ) + if residue: + recovery.mark_failure( + journal.id, + OperationalError( + "AWS cleanup completed but Leave No Trace absence could not " + "be proven." + ), + ) + remaining = residue + elif not failed and not remaining: + recovery.finish_journal(journal.id, scrub_payloads=True) + lnt = not failed and not remaining + classification = ( + ResultClassification.CLEANED + if lnt + else ResultClassification.RECOVERY_REQUIRED + if state["irreversibleFailure"] + else ResultClassification.PARTIAL + ) + return CleanupResult( + classification, journal.id, completed, failed, remaining, lnt + ) + + def _assert_plan_current(self, plan: CleanupPlan) -> None: + for item in plan.resources: + if isinstance(item.snapshot, roles.RoleSnapshot): + current = self.role_service.get_role(item.name) + if roles.role_snapshot_hash(current) != roles.role_snapshot_hash( + item.snapshot + ): + raise OperationalError( + f"Role {item.name!r} changed after cleanup planning." + ) + continue + snapshot = item.snapshot + if not isinstance(snapshot, tuple) or not isinstance( + snapshot[0], policies.ManagedPolicyRecord + ): + continue + expected_policy, expected_dependencies = snapshot + current_policy = self.policy_service.get_policy( + item.arn, + include_document=True, + include_versions=True, + include_tags=True, + ) + current_dependencies = self.policy_service.policy_dependencies(item.arn) + if ( + current_policy != expected_policy + or current_dependencies != expected_dependencies + ): + raise OperationalError( + f"Policy {item.name!r} changed after cleanup planning." + ) + + def continue_journal(self, journal_id: str) -> CleanupResult: + """Resume a cleanup journal with its dependency-aware retry scheduler.""" + ensure_recovery_handler() + journal = recovery.get_journal(journal_id) + if journal.get("serviceType") != _RECOVERY_SERVICE: + raise OperationalError(f"Journal {journal_id!r} is not an IAM cleanup.") + self._assert_journal_scope(journal) + if journal.get("status") == "completed" and journal.get("payloadsScrubbed"): + return CleanupResult( + ResultClassification.CLEANED, + journal_id, + tuple( + str(step["forward"].get("resourceKey", "")) + for step in journal["steps"] + ), + (), + (), + lnt=True, + ) + state = _continue_queue( + journal_id, + self.context, + sleeper=self._sleep, + jitter=self._jitter, + ) + completed = tuple(cast("list[str]", state["completed"])) + failed = tuple(cast("list[str]", state["failed"])) + remaining = tuple(cast("list[str]", state["remaining"])) + if not failed and not remaining: + remaining = self._verify_journal_lnt(journal_id) + if not failed and not remaining: + recovery.finish_journal(journal_id, scrub_payloads=True) + elif remaining: + recovery.mark_failure( + journal_id, + OperationalError("Cleanup recovery could not prove AWS absence."), + ) + classification = ( + ResultClassification.CLEANED + if not failed and not remaining + else ResultClassification.RECOVERY_REQUIRED + if state["irreversibleFailure"] + else ResultClassification.PARTIAL + ) + return CleanupResult( + classification, + journal_id, + completed, + failed, + remaining, + lnt=classification is ResultClassification.CLEANED, + ) + + def rollback_journal(self, journal_id: str) -> dict[str, Any]: + """Rollback a cleanup journal only within its recorded AWS scope.""" + ensure_recovery_handler() + journal = recovery.get_journal(journal_id) + if journal.get("serviceType") != _RECOVERY_SERVICE: + raise OperationalError(f"Journal {journal_id!r} is not an IAM cleanup.") + self._assert_journal_scope(journal) + if journal.get("payloadsScrubbed"): + raise OperationalError( + "Completed cleanup receipts cannot be rolled back after recovery " + "payloads have been scrubbed." + ) + return recovery.rollback_journal(journal_id, self.context) + + def _assert_journal_scope(self, journal: Mapping[str, object]) -> None: + if journal.get("accountId") != self.context.account_id: + raise OperationalError("Cleanup journal does not match selected account.") + recorded_partition = journal.get("partition") + if recorded_partition is None: + raise OperationalError( + "Cleanup journal has no recorded AWS partition and cannot be " + "recovered safely; preserve it for manual inspection." + ) + if recorded_partition != self.context.partition: + raise OperationalError("Cleanup journal does not match selected partition.") + + def _verify_journal_lnt(self, journal_id: str) -> tuple[str, ...]: + journal = recovery.get_journal(journal_id) + residue: list[str] = [] + for step in journal["steps"]: + forward = step["forward"] + if forward.get("irreversible") is not True: + continue + action = forward.get("action") + params = forward.get("params", {}) + if not isinstance(params, Mapping): + raise OperationalError("Cleanup journal identity payload is invalid.") + try: + if action == "delete_role": + self.context.iam.get_role(RoleName=params.get("RoleName")) + elif action == "delete_policy": + self.context.iam.get_policy(PolicyArn=params.get("PolicyArn")) + else: + continue + except ClientError as error: + if _error_code(error) == "NoSuchEntity": + continue + raise + resource = str(forward.get("resourceKey", "unknown")) + if resource not in residue: + residue.append(resource) + return tuple(residue) + + def _verify_lnt(self, resources: Iterable[InventoryItem]) -> tuple[str, ...]: + residue: list[str] = [] + for item in resources: + absent = False + for attempt in range(_LNT_ATTEMPTS): + try: + if item.resource_type is ResourceType.ROLE: + self.context.iam.get_role(RoleName=item.name) + else: + self.context.iam.get_policy(PolicyArn=item.arn) + except ClientError as error: + if _error_code(error) == "NoSuchEntity": + absent = True + break + raise + if attempt + 1 < _LNT_ATTEMPTS: + self._sleep(self._jitter(0.0, 0.1 * (2**attempt))) + if not absent: + residue.append(item.key) + return tuple(residue) + + +def _call(context: Any, action: str, params: Mapping[str, object]) -> None: + allowed = { + "add_role_to_instance_profile", + "attach_group_policy", + "attach_role_policy", + "attach_user_policy", + "create_policy_version", + "delete_policy", + "delete_policy_version", + "delete_role", + "delete_role_permissions_boundary", + "delete_role_policy", + "delete_user_permissions_boundary", + "detach_group_policy", + "detach_role_policy", + "detach_user_policy", + "put_role_permissions_boundary", + "put_role_policy", + "put_user_permissions_boundary", + "remove_role_from_instance_profile", + "update_assume_role_policy", + } + if action not in allowed: + raise OperationalError(f"Cleanup action {action!r} is not allowlisted.") + request = dict(params) + expected_role_id = request.pop("ExpectedRoleId", None) + expected_policy_id = request.pop("ExpectedPolicyId", None) + if expected_role_id is not None: + current = context.iam.get_role(RoleName=request["RoleName"])["Role"] + if current.get("RoleId") != expected_role_id: + raise OperationalError("Role identity changed after cleanup planning.") + if expected_policy_id is not None: + current = context.iam.get_policy(PolicyArn=request["PolicyArn"])["Policy"] + if current.get("PolicyId") != expected_policy_id: + raise OperationalError("Policy identity changed after cleanup planning.") + getattr(context.iam, action)(**request) + + +def _forward( + payload: Mapping[str, object], context: object +) -> Mapping[str, object] | None: + action = payload.get("action") + params = payload.get("params") + if not isinstance(action, str) or not isinstance(params, Mapping): + raise OperationalError("Cleanup recovery forward payload is invalid.") + try: + _call(context, action, params) + except ClientError as error: + if str(error.response.get("Error", {}).get("Code")) == "NoSuchEntity": + return {"absenceObserved": True} + raise + if action in {"delete_policy", "delete_role"}: + identity = params.get("ExpectedPolicyId") or params.get("ExpectedRoleId") + return { + "deletionAccepted": True, + "expectedResourceId": str(identity or ""), + } + return None + + +def _compensate(payload: Mapping[str, object], raw_context: object) -> None: + context = cast("Any", raw_context) + if payload.get("irreversible") is True: + action = payload.get("forwardAction") + params = payload.get("forwardParams") + if not isinstance(action, str) or not isinstance(params, Mapping): + raise OperationalError("Cleanup commit-point receipt is invalid.") + try: + if action == "delete_role": + current = context.iam.get_role(RoleName=params.get("RoleName"))["Role"] + if current.get("RoleId") == params.get("ExpectedRoleId"): + return + elif action == "delete_policy": + current = context.iam.get_policy(PolicyArn=params.get("PolicyArn"))[ + "Policy" + ] + if current.get("PolicyId") == params.get("ExpectedPolicyId"): + return + except ClientError as error: + if _error_code(error) != "NoSuchEntity": + raise + raise OperationalError( + "Cleanup crossed an irreversible IAM identity commit point; " + "Hacksaws will not recreate the deleted resource." + ) + action = payload.get("action") + params = payload.get("params") + if action is None: + return + if not isinstance(action, str) or not isinstance(params, Mapping): + raise OperationalError("Cleanup recovery compensation payload is invalid.") + _call(context, action, params) + + +def ensure_recovery_handler() -> None: + """Register the fixed allowlisted cleanup recovery handler once.""" + try: + recovery.register_handler( + _RECOVERY_SERVICE, + _HANDLER, + forward=_forward, + compensate=_compensate, + ) + except ValueError as error: + if "already registered" not in str(error): + raise + + +def _continue_queue( + journal_id: str, + context: object, + *, + sleeper: Callable[[float], None], + jitter: Callable[[float, float], float], + max_attempts: int = 3, +) -> dict[str, object]: + """Run pending journal steps as a dependency-ready, durable retry queue.""" + initial = recovery.get_journal(journal_id) + completed_steps = [ + step for step in initial["steps"] if step["status"] == "completed" + ] + completed_plan_ids: set[str] = { + str(step["forward"].get("planStepId")) for step in completed_steps + } + completed_resources: list[str] = list( + dict.fromkeys( + str(step["forward"].get("resourceKey", "unknown")) + for step in completed_steps + ) + ) + failed_resources: list[str] = [] + irreversible_failure = False + pending = deque(step for step in initial["steps"] if step["status"] == "pending") + stalled = 0 + while pending: + step = pending.popleft() + forward = step["forward"] + prerequisites = set(forward.get("prerequisites", [])) + if not prerequisites.issubset(completed_plan_ids): + pending.append(step) + stalled += 1 + if stalled >= len(pending): + break + continue + stalled = 0 + attempts = int(step.get("attempts", 0)) + 1 + try: + effect = _forward(forward, context) + except Exception as error: + code = _error_code(error) + recovery.mark_queue_attempt( + journal_id, + str(step["id"]), + attempts=attempts, + error=error, + ) + resource = str(forward.get("resourceKey", "unknown")) + if code in _TRANSIENT_CODES and attempts < max_attempts: + sleeper(jitter(0.0, 0.1 * (2 ** (attempts - 1)))) + step["attempts"] = attempts + pending.append(step) + continue + if resource not in failed_resources: + failed_resources.append(resource) + if forward.get("irreversible") is True: + irreversible_failure = True + continue + recovery.mark_step_completed(journal_id, str(step["id"]), effect=effect) + plan_id = str(forward["planStepId"]) + completed_plan_ids.add(plan_id) + resource = str(forward["resourceKey"]) + if resource not in completed_resources: + completed_resources.append(resource) + current = recovery.get_journal(journal_id) + remaining = [ + str(step["forward"].get("resourceKey", "unknown")) + for step in current["steps"] + if step["status"] == "pending" + ] + remaining = list(dict.fromkeys(remaining)) + if remaining: + recovery.mark_failure( + journal_id, + OperationalError("Cleanup queue retained pending or failed resources."), + ) + return { + "completed": completed_resources, + "failed": failed_resources, + "remaining": remaining, + "irreversibleFailure": irreversible_failure, + } + + +ensure_recovery_handler() diff --git a/hacksaws/_iam_cli.py b/hacksaws/_iam_cli.py new file mode 100644 index 0000000..e4a58b7 --- /dev/null +++ b/hacksaws/_iam_cli.py @@ -0,0 +1,783 @@ +"""Shared IAM CLI parser, credential context, and leaf-adapter contract.""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Protocol +from typing import cast + +import boto3 +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError + +from hacksaws import _configs +from hacksaws import _iam_cleanup +from hacksaws import _iam_policy_cli +from hacksaws import _iam_recovery +from hacksaws import _iam_role_cli +from hacksaws import _state + +if TYPE_CHECKING: + from collections.abc import Callable + from collections.abc import Iterator + + +class IamAdapter(Protocol): + """A leaf module that contributes a parser and dispatch implementation.""" + + name: str + + def register(self, parser: argparse.ArgumentParser) -> None: + """Register leaf subcommands below the supplied IAM command parser.""" + + def dispatch( + self, args: argparse.Namespace, context: IamCommandContext + ) -> _configs.Result | None: + """Handle a parsed leaf, returning ``None`` when it does not own it.""" + + +_adapters: list[IamAdapter] = [] +_CREDENTIAL_ENVIRONMENT_KEYS = ( + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_ROLE_ARN", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_EC2_METADATA_DISABLED", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ENDPOINT_URL", + "AWS_ENDPOINT_URL_STS", + "AWS_ENDPOINT_URL_IAM", + "AWS_ENDPOINT_URL_ACCESSANALYZER", + "AWS_ENDPOINT_URL_ACCESS_ANALYZER", +) + + +def _ensure_builtin_adapters() -> None: + """Register bundled parser adapters exactly once at parser construction time.""" + for adapter in (_iam_policy_cli, _iam_role_cli): + if not any(existing.name == adapter.name for existing in _adapters): + register_adapter(adapter) + + +def register_adapter(adapter: IamAdapter) -> None: + """Register one policy/role adapter exactly once for parser and dispatch wiring.""" + if any(existing.name == adapter.name for existing in _adapters): + raise ValueError(adapter.name) + _adapters.append(adapter) + + +def clear_adapters() -> None: + """Clear adapters for isolated tests; production integrations register once.""" + _adapters.clear() + + +def add_selector_arguments( + parser: argparse.ArgumentParser, + *, + root: bool = False, + mutation: bool = False, +) -> None: + """Add common IAM selectors without child defaults clobbering parent options.""" + fallback: object = None if root else argparse.SUPPRESS + credentials = parser.add_argument_group("credential selection") + credentials.add_argument( + "--profile", + default="default" if root else argparse.SUPPRESS, + metavar="PROFILE", + help="AWS profile to use (default: default).", + ) + credentials.add_argument( + "--location", + default="default" if root else argparse.SUPPRESS, + metavar="NAME", + help="Named AWS directory such as horizon (default: default).", + ) + credentials.add_argument( + "-d", + "--directory", + default=fallback, + metavar="PATH", + help="Explicit AWS config directory; cannot be combined with --location.", + ) + credentials.add_argument( + "--target", + default=fallback, + metavar="NAME", + help=( + "Saved target supplying credentials; cannot be overridden by source " + "selectors." + ), + ) + credentials.add_argument( + "--account", + default=fallback, + metavar="NAME_OR_ID", + help="Require the selected credentials to identify this configured account.", + ) + credentials.add_argument( + "--region", + default=fallback, + metavar="REGION", + help="AWS region used for regional clients and console links.", + ) + if mutation: + safety = parser.add_argument_group("safety") + safety.add_argument( + "--dry-run", + action="store_true", + default=False if root else argparse.SUPPRESS, + help=( + "Resolve, validate, and show the plan without changing AWS or " + "local state." + ), + ) + safety.add_argument( + "--yes", + action="store_true", + default=False if root else argparse.SUPPRESS, + help="Approve the displayed plan without an interactive prompt.", + ) + + +def _selector_arguments(parser: argparse.ArgumentParser, *, root: bool = False) -> None: + """Backward-compatible internal spelling for common selectors.""" + add_selector_arguments(parser, root=root) + + +_SELECTOR_SPELLINGS = { + "profile": ("--profile",), + "location": ("--location",), + "directory": ("-d", "--directory"), + "target": ("--target",), + "account": ("--account",), + "region": ("--region",), + "yes": ("--yes",), + "dry_run": ("--dry-run",), +} + + +def validate_selector_arguments(arguments: list[str]) -> None: + """Reject duplicated and contradictory IAM selectors before argparse merges them.""" + if not arguments or arguments[0] not in { + "iam", + "remote", + "cleanup", + "account", + "boundary", + "config", + }: + return + found: dict[str, str] = {} + for token in arguments[1:]: + option = token.partition("=")[0] + for logical, spellings in _SELECTOR_SPELLINGS.items(): + if option not in spellings: + continue + if logical in found: + raise _configs.OperationalError( + f"{option} duplicates {found[logical]}; specify " + f"{logical.replace('_', '-')} only once." + ) + found[logical] = option + break + if "location" in found and "directory" in found: + raise _configs.OperationalError( + "--location and --directory select the same AWS folder; specify only one." + ) + if "target" in found: + conflicts = [ + found[key] for key in ("profile", "location", "directory") if key in found + ] + if conflicts: + raise _configs.OperationalError( + "--target supplies its own credential source and cannot be combined " + "with " + + ", ".join(conflicts) + + ". --account may be combined with --target as an identity assertion." + ) + + +def register_parser( + parent: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: + """Register canonical ``iam`` and exact ``remote`` alias command trees.""" + _ensure_builtin_adapters() + iam = parent.add_parser( + "iam", aliases=["remote"], help="Manage remote IAM resources." + ) + _selector_arguments(iam, root=True) + actions = iam.add_subparsers(dest="iam_action") + for name, aliases, help_text in ( + ("policy", ["policies"], "Managed-policy operations."), + ("role", ["roles"], "IAM role operations."), + ): + command = actions.add_parser(name, aliases=aliases, help=help_text) + _selector_arguments(command) + for adapter in _adapters: + if adapter.name == name: + adapter.register(command) + inventory = actions.add_parser( + "list", + help="List Hacksaws-owned IAM roles, policies, and group grants.", + description=( + "Inventory Hacksaws-owned remote IAM resources in one verified AWS account." + ), + ) + _selector_arguments(inventory) + _inventory_arguments(inventory) + cleanup = actions.add_parser( + "cleanup", + help="Plan or remove selected Hacksaws-owned IAM resources.", + description=( + "Perform account-scoped, dependency-ordered Leave No Trace cleanup." + ), + ) + add_selector_arguments(cleanup, mutation=True) + _cleanup_arguments(cleanup) + recovery = actions.add_parser( + "recovery", + aliases=["recover"], + help="Inspect or recover an interrupted transaction.", + ) + _selector_arguments(recovery) + recovery.add_argument( + "recovery_action", choices=("list", "get", "continue", "rollback") + ) + recovery.add_argument("journal_id", nargs="?") + + +def register_root_cleanup_parser( + parent: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: + """Register the canonical top-level cleanup command.""" + parser = parent.add_parser( + "cleanup", + help="Remove selected Hacksaws-owned IAM resources from one AWS account.", + description=( + "Plan and execute dependency-ordered Leave No Trace cleanup. A pattern, " + "--all, --smoke, or --smoke-run is required." + ), + ) + parser.set_defaults(iam_action="cleanup") + add_selector_arguments(parser, root=True, mutation=True) + _cleanup_arguments(parser) + + +def _resource_filters(parser: argparse.ArgumentParser) -> None: + resources = parser.add_argument_group("resource selection") + resources.add_argument( + "patterns", + nargs="*", + metavar="PATTERN", + help=( + "Case-insensitive fnmatch pattern matched against names and ARNs; " + "multiple patterns are ORed." + ), + ) + resources.add_argument("--roles", action="store_true", help="Include IAM roles.") + resources.add_argument( + "--policies", action="store_true", help="Include customer-managed policies." + ) + resources.add_argument( + "--group-grants", + action="store_true", + help="Include Hacksaws-managed group assume-role grants.", + ) + resources.add_argument( + "--created", action="store_true", help="Include resources created by Hacksaws." + ) + resources.add_argument( + "--adopted", + action="store_true", + help="Include resources explicitly adopted by Hacksaws.", + ) + resources.add_argument( + "--smoke", + action="store_true", + help="Restrict selection to tagged smoke-test resources.", + ) + resources.add_argument( + "--smoke-run", + metavar="RUN_ID", + help="Restrict selection to one tagged smoke-test run.", + ) + + +def _inventory_arguments(parser: argparse.ArgumentParser) -> None: + _resource_filters(parser) + output = parser.add_argument_group("output") + width = output.add_mutually_exclusive_group() + width.add_argument( + "--compact", + action="store_true", + help="Show the smallest useful table (default).", + ) + width.add_argument( + "--wide", + action="store_true", + help="Include ARNs, paths, and dependency counts.", + ) + + +def _cleanup_arguments(parser: argparse.ArgumentParser) -> None: + _resource_filters(parser) + selection = parser.add_argument_group("cleanup scope") + selection.add_argument( + "--all", + action="store_true", + help=( + "Select every matching supported resource type; conflicts with positional " + "patterns." + ), + ) + dependencies = parser.add_argument_group("dependency handling") + dependencies.add_argument( + "--cascade", + action="store_true", + help=( + "Detach ordinary retained attachments required to delete selected " + "resources." + ), + ) + dependencies.add_argument( + "--remove-boundaries", + action="store_true", + help=( + "Remove selected policies from retained user or role permissions " + "boundaries." + ), + ) + dependencies.add_argument( + "--remove-from-instance-profiles", + action="store_true", + help="Remove selected roles from retained instance profiles.", + ) + + +def _cleanup_types(args: argparse.Namespace) -> frozenset[_iam_cleanup.ResourceType]: + values: set[_iam_cleanup.ResourceType] = set() + if args.roles: + values.add(_iam_cleanup.ResourceType.ROLE) + if args.policies: + values.add(_iam_cleanup.ResourceType.POLICY) + if args.group_grants: + values.add(_iam_cleanup.ResourceType.GROUP_GRANT) + return frozenset(values) + + +def _cleanup_origins( + args: argparse.Namespace, +) -> frozenset[_iam_cleanup.OwnershipOrigin]: + values: set[_iam_cleanup.OwnershipOrigin] = set() + if args.created: + values.add(_iam_cleanup.OwnershipOrigin.CREATED) + if args.adopted: + values.add(_iam_cleanup.OwnershipOrigin.ADOPTED) + return frozenset( + values + or {_iam_cleanup.OwnershipOrigin.CREATED, _iam_cleanup.OwnershipOrigin.ADOPTED} + ) + + +def _inventory_text(items: list[dict[str, object]], *, wide: bool) -> str: + if not items: + return "No matching Hacksaws-owned IAM resources." + headers: tuple[str, ...] + rows: list[tuple[str, ...]] + if wide: + headers = ("Type", "Name", "Origin", "Path", "Deps", "ARN") + rows = [ + ( + str(item["type"]), + str(item["name"]), + str(item["origin"]), + str(item["path"]), + str( + sum( + len(value) + for value in cast( + "dict[str, list[object]]", item["dependencies"] + ).values() + ) + ), + str(item["arn"]), + ) + for item in items + ] + else: + headers = ("Type", "Name", "Origin", "Smoke") + rows = [ + ( + str(item["type"]), + str(item["name"]), + str(item["origin"]), + "🧪" if item["smoke"] else "", + ) + for item in items + ] + widths = [ + max(len(headers[index]), *(len(row[index]) for row in rows)) + for index in range(len(headers)) + ] + return "\n".join( + [ + " ".join( + value.ljust(widths[index]) for index, value in enumerate(headers) + ), + " ".join("-" * width for width in widths), + *( + " ".join(value.ljust(widths[index]) for index, value in enumerate(row)) + for row in rows + ), + ] + ) + + +def inventory_result( + args: argparse.Namespace, context: IamCommandContext +) -> _configs.Result: + inventory = _iam_cleanup.CleanupService(context).inventory() + selected = inventory.filter( + patterns=args.patterns, + resource_types=_cleanup_types(args), + origins=_cleanup_origins(args), + owned_only=True, + smoke_only=args.smoke, + smoke_run_id=args.smoke_run, + ) + items = [item.as_dict() for item in selected] + data = {**inventory.as_dict(), "count": len(items), "items": items} + return _configs.Result( + "IAM_INVENTORY", _inventory_text(items, wide=args.wide), data=data + ) + + +def cleanup_result( + args: argparse.Namespace, context: IamCommandContext +) -> _configs.Result: + if args.all and args.patterns: + raise _configs.OperationalError( + "--all conflicts with positional cleanup patterns." + ) + if not (args.all or args.patterns or args.smoke or args.smoke_run): + raise _configs.OperationalError( + "Cleanup requires PATTERN, --all, --smoke, or --smoke-run so " + "account-wide deletion is never accidental." + ) + options = _iam_cleanup.CleanupOptions( + patterns=tuple(args.patterns), + all_resources=args.all, + resource_types=_cleanup_types(args), + origins=_cleanup_origins(args), + smoke_only=args.smoke, + smoke_run_id=args.smoke_run, + cascade=args.cascade, + remove_boundaries=args.remove_boundaries, + remove_from_instance_profiles=args.remove_from_instance_profiles, + dry_run=bool(args.dry_run), + ) + service = _iam_cleanup.CleanupService(context) + plan = service.plan(options) + plan_data = plan.as_dict() + if ( + args.dry_run + or plan.classification is not _iam_cleanup.PlanClassification.PLANNED + ): + blocked = plan.classification is _iam_cleanup.PlanClassification.BLOCKED + return _configs.Result( + "IAM_CLEANUP_PLAN", + "DRY RUN — cleanup plan\n" + + json.dumps(plan_data, indent=2) + + "\nNo AWS or local state was changed.", + 2 if blocked else 0, + "stderr" if blocked else "stdout", + plan_data, + ) + if not args.yes: + if _configs.json_output_enabled() or not os.isatty(0): + return _configs.Result( + "IAM_CLEANUP_CONFIRMATION_REQUIRED", + "Cleanup requires --yes in non-interactive or JSON mode.", + _configs.EXIT_CANCELLED, + "stderr", + plan_data, + ) + sys.stdout.write("Cleanup plan:\n" + json.dumps(plan_data, indent=2) + "\n") + if input("\nType exactly 'yes' to execute this plan:\n> ").strip() != "yes": + return _configs.Result( + "IAM_CLEANUP_CANCELLED", + "Cleanup cancelled; no AWS changes were made.", + _configs.EXIT_CANCELLED, + "stderr", + plan_data, + ) + outcome = service.execute(plan) + data = {"plan": plan_data, "result": outcome.as_dict()} + partial = outcome.classification is not _iam_cleanup.ResultClassification.CLEANED + return _configs.Result( + "IAM_CLEANUP_PARTIAL" if partial else "IAM_CLEANUP_COMPLETE", + ( + "Cleanup completed with remaining resources." + if partial + else "Leave No Trace cleanup completed successfully." + ), + 2 if partial else 0, + "stderr" if partial else "stdout", + data, + ) + + +def _dispatch_cleanup(args: argparse.Namespace) -> _configs.Result: + """Create a cleanup context while classifying identity refusal distinctly.""" + try: + context = IamCommandContext.create(args) + except _configs.OperationalError as error: + message = str(error) + if "selected account requires" in message: + return _configs.Result( + "IAM_CLEANUP_SAFETY_REFUSAL", + "Cleanup refused because the selected credentials could not prove " + f"the requested AWS account: {message}", + _configs.EXIT_POLICY, + "stderr", + data={"reason": "account-mismatch"}, + repairs=[ + "Select credentials for the requested account or correct --account." + ], + ) + raise + return cleanup_result(args, context) + + +@contextlib.contextmanager +def credential_environment(config: Path, credentials: Path) -> Iterator[None]: + """Temporarily bind Boto3 to exactly one selected shared-config source.""" + keys = _CREDENTIAL_ENVIRONMENT_KEYS + previous = {key: os.environ.get(key) for key in keys} + os.environ["AWS_CONFIG_FILE"] = str(config) + os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(credentials) + for key in keys[2:]: + os.environ.pop(key, None) + os.environ["AWS_EC2_METADATA_DISABLED"] = "true" + try: + yield + finally: + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +@dataclass(frozen=True) +class IamCommandContext: + """Verified AWS clients and selected local credential source for one IAM command.""" + + selector: _configs.CredentialSelector + config_path: Path + credentials_path: Path + session: Any + iam: Any + sts: Any + access_analyzer: Any + account_id: str + partition: str + arn: str + + @classmethod + def create( + cls, + args: argparse.Namespace, + *, + session_factory: Callable[..., Any] = boto3.Session, + ) -> IamCommandContext: + """Resolve local selection, create clients, and verify caller identity only.""" + selector = _configs.resolve_credential_selector(args) + directory, profile, expected_account = _selected_source(selector, args) + with credential_environment(directory / "config", directory / "credentials"): + try: + selected_session = session_factory( + profile_name=profile, region_name=args.region + ) + credentials = selected_session.get_credentials() + if credentials is None: + raise _configs.OperationalError( + f"Selected AWS profile {profile!r} has no credentials." + ) + frozen = credentials.get_frozen_credentials() + session = session_factory( + aws_access_key_id=frozen.access_key, + aws_secret_access_key=frozen.secret_key, + aws_session_token=frozen.token, + region_name=args.region or selected_session.region_name, + ) + sts = session.client("sts") + iam = session.client("iam") + access_analyzer = session.client("accessanalyzer") + response = sts.get_caller_identity() + account_id = str(response["Account"]) + arn = str(response["Arn"]) + partition_match = re.fullmatch(r"arn:([^:]+):.+", arn) + if not re.fullmatch(r"\d{12}", account_id) or partition_match is None: + raise KeyError("invalid caller identity") + partition = partition_match.group(1) + except (BotoCoreError, ClientError, KeyError) as error: + raise _configs.OperationalError( + "Unable to verify selected IAM credentials with " + f"GetCallerIdentity: {error}" + ) from error + if expected_account and ( + account_id != expected_account["id"] + or partition != expected_account["partition"] + ): + raise _configs.OperationalError( + "Selected IAM credentials identify " + f"{partition}:{account_id}, but the selected account requires " + f"{expected_account['partition']}:{expected_account['id']}." + ) + return cls( + selector=_configs.CredentialSelector( + profile=profile, + location=selector.location, + directory=directory, + target=selector.target, + ), + config_path=directory / "config", + credentials_path=directory / "credentials", + session=session, + iam=iam, + sts=sts, + access_analyzer=access_analyzer, + account_id=account_id, + partition=partition, + arn=arn, + ) + + +def _selected_source( + selector: _configs.CredentialSelector, args: argparse.Namespace +) -> tuple[Path, str, dict[str, Any] | None]: + """Resolve target/location/directory precedence without triggering any login.""" + data = _state.load_config() + expected_name = args.account + if selector.target: + _, target = _state.get_resource(data, "target", selector.target.lstrip("+")) + directory = ( + Path(target["source_directory"]) + if target.get("source_directory") + else _state.aws_directory(target.get("source_location")) + ) + profile = str(target.get("source_profile", "default")) + expected_name = expected_name or str(target["source_account"]) + else: + directory = selector.directory or _state.aws_directory(selector.location) + profile = selector.profile + expected = None + if expected_name: + _, expected = _state.get_resource(data, "account", expected_name) + return directory.expanduser().absolute(), profile, expected + + +def recovery_result(args: argparse.Namespace) -> _configs.Result: + """Inspect or execute an IAM-only durable recovery journal.""" + action = args.recovery_action + if action == "list": + journals = _iam_recovery.list_journals() + data = {"journals": journals, "count": len(journals)} + return _configs.Result("IAM_RECOVERY_LIST", json.dumps(data), data=data) + journal_id = getattr(args, "journal_id", None) + if not journal_id: + raise _configs.OperationalError( + f"IAM recovery {action} requires a journal ID.", + repairs=["Run 'hacksaws iam recovery list' to find journal IDs."], + ) + if action == "get": + data = _iam_recovery.get_journal(journal_id) + return _configs.Result("IAM_RECOVERY_GET", json.dumps(data), data=data) + journal = _iam_recovery.get_journal(journal_id) + if journal.get("serviceType") == "policy": + _iam_policy_cli.ensure_recovery_handlers() + elif journal.get("serviceType") == "iam-cleanup": + _iam_cleanup.ensure_recovery_handler() + context = IamCommandContext.create(args) + if action == "continue" and journal.get("serviceType") == "iam-cleanup": + outcome = _iam_cleanup.CleanupService(context).continue_journal(journal_id) + data = outcome.as_dict() + elif action == "continue": + data = _iam_recovery.continue_journal(journal_id, context) + elif journal.get("serviceType") == "iam-cleanup": + data = _iam_cleanup.CleanupService(context).rollback_journal(journal_id) + else: + data = _iam_recovery.rollback_journal(journal_id, context) + return _configs.Result( + "IAM_RECOVERY_CONTINUE" if action == "continue" else "IAM_RECOVERY_ROLLBACK", + f"IAM recovery {action} completed for {journal_id}.", + data=data, + ) + + +def dispatch(args: argparse.Namespace) -> _configs.Result: # noqa: PLR0911 + """Dispatch recovery locally or hand verified context to the owning leaf adapter.""" + if args.iam_action in {"recovery", "recover"}: + return recovery_result(args) + if args.iam_action in {"list", "cleanup"}: + if args.iam_action == "cleanup": + return _dispatch_cleanup(args) + return inventory_result(args, IamCommandContext.create(args)) + if args.iam_action not in {"policy", "policies", "role", "roles"}: + return _configs.Result( + "IAM_HELP", "Choose an IAM command.", _configs.EXIT_USAGE, "stderr" + ) + expected = "policy" if args.iam_action in {"policy", "policies"} else "role" + leaf = "policy_action" if expected == "policy" else "role_command" + if not getattr(args, leaf, None): + return _configs.Result( + "IAM_LEAF_HELP", + f"Choose an IAM {expected} command.", + _configs.EXIT_USAGE, + "stderr", + ) + candidates = [adapter for adapter in _adapters if adapter.name == expected] + if not candidates: + return _configs.Result( + "IAM_LEAF_HELP", + f"No {expected} command adapter is installed.", + _configs.EXIT_USAGE, + "stderr", + ) + context = IamCommandContext.create(args) + for adapter in candidates: + result = adapter.dispatch(args, context) + if result is not None: + return result + return _configs.Result( + "IAM_LEAF_HELP", + f"No {args.iam_action} command adapter is installed.", + _configs.EXIT_USAGE, + "stderr", + ) + + +def dispatch_root_cleanup(args: argparse.Namespace) -> _configs.Result: + """Dispatch the canonical root cleanup command through the shared service.""" + return _dispatch_cleanup(args) diff --git a/hacksaws/_iam_managed_policies.py b/hacksaws/_iam_managed_policies.py new file mode 100644 index 0000000..0a010ec --- /dev/null +++ b/hacksaws/_iam_managed_policies.py @@ -0,0 +1,2204 @@ +"""Pure, dependency-injected AWS IAM managed-policy workflows.""" + +from __future__ import annotations + +import hashlib +import json +import re +import time +import uuid +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Mapping +from collections.abc import Sequence +from dataclasses import dataclass +from dataclasses import replace +from datetime import UTC +from datetime import datetime +from enum import StrEnum +from typing import Protocol + +from botocore.exceptions import ClientError + +from hacksaws._iam_policy_documents import JsonValue +from hacksaws._iam_policy_documents import canonical_policy_json +from hacksaws._iam_policy_documents import decode_iam_document +from hacksaws._iam_policy_documents import policy_digest + +DEFAULT_PATH = "/hacksaws/" +AWS_ACCOUNT = "aws" +MAX_TAGS = 50 +MAX_TAG_KEY = 128 +MAX_TAG_VALUE = 256 +MAX_POLICY_NAME = 128 +MAX_MANAGED_POLICY_SIZE = 6_144 +MAX_POLICY_VERSIONS = 5 +PACKED_WARNING_PERCENT = 80 +MIN_SESSION_DURATION = 900 +ARN_PART_COUNT = 6 +ACCOUNT_PATTERN = re.compile(r"^\d{12}$") +NAME_PATTERN = re.compile(r"^[\w+=,.@-]{1,128}$", re.ASCII) +ARN_PATTERN = re.compile( + r"^arn:(?Paws(?:-us-gov|-cn)?):iam::" + r"(?Paws|\d{12}):policy/(?P[^\s]+)$" +) +ROLE_ARN_PATTERN = re.compile( + r"^arn:(?Paws(?:-us-gov|-cn)?):iam::" + r"(?P\d{12}):role/(?P[^\s]+)$" +) +RESERVED_TAGS = frozenset( + { + "hacksaws:managed-by", + "hacksaws:resource-id", + "hacksaws:resource-kind", + "hacksaws:created-by", + "hacksaws:created-at", + "hacksaws:ownership-origin", + } +) +OWNERSHIP_TAGS = frozenset( + { + "hacksaws:managed-by", + "hacksaws:resource-id", + "hacksaws:resource-kind", + } +) +DENY_ALL_SESSION_POLICY: dict[str, JsonValue] = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "HacksawsAssumabilityProbe", + "Effect": "Deny", + "Action": "*", + "Resource": "*", + } + ], +} + + +class IamClient(Protocol): + """Minimal IAM client surface required by the policy service.""" + + def list_policies(self, **kwargs: object) -> Mapping[str, object]: ... + + def get_policy(self, **kwargs: object) -> Mapping[str, object]: ... + + def get_policy_version(self, **kwargs: object) -> Mapping[str, object]: ... + + def list_policy_versions(self, **kwargs: object) -> Mapping[str, object]: ... + + def create_policy(self, **kwargs: object) -> Mapping[str, object]: ... + + def create_policy_version(self, **kwargs: object) -> Mapping[str, object]: ... + + def set_default_policy_version(self, **kwargs: object) -> object: ... + + def delete_policy_version(self, **kwargs: object) -> object: ... + + def list_policy_tags(self, **kwargs: object) -> Mapping[str, object]: ... + + def tag_policy(self, **kwargs: object) -> object: ... + + def untag_policy(self, **kwargs: object) -> object: ... + + def list_entities_for_policy(self, **kwargs: object) -> Mapping[str, object]: ... + + def detach_user_policy(self, **kwargs: object) -> object: ... + + def detach_group_policy(self, **kwargs: object) -> object: ... + + def detach_role_policy(self, **kwargs: object) -> object: ... + + def delete_user_permissions_boundary(self, **kwargs: object) -> object: ... + + def delete_role_permissions_boundary(self, **kwargs: object) -> object: ... + + def delete_policy(self, **kwargs: object) -> object: ... + + +class StsClient(Protocol): + """Minimal STS client surface required by the policy service.""" + + def get_caller_identity(self, **kwargs: object) -> Mapping[str, object]: ... + + def assume_role(self, **kwargs: object) -> Mapping[str, object]: ... + + +class AccessAnalyzerClient(Protocol): + """Minimal IAM Access Analyzer client surface used for validation.""" + + def validate_policy(self, **kwargs: object) -> Mapping[str, object]: ... + + +class PolicyServiceError(RuntimeError): + """Base class for managed-policy service failures.""" + + +class ImmutablePolicyError(PolicyServiceError): + """Reject a mutation targeting an AWS-managed policy.""" + + +class PolicyDriftError(PolicyServiceError): + """Report optimistic-concurrency drift before a mutation.""" + + +class PolicyValidationError(PolicyServiceError): + """Report a plan that cannot be safely executed.""" + + def __init__(self, report: ValidationReport) -> None: + self.report = report + super().__init__("Policy validation failed.") + + +class PackedPolicyProbeError(PolicyServiceError): + """Expose structured STS packed-policy failure information.""" + + def __init__(self, diagnostic: PackedPolicyDiagnostic) -> None: + self.diagnostic = diagnostic + super().__init__(diagnostic.message) + + +class PolicyScope(StrEnum): + """AWS list-policies scope values.""" + + ALL = "All" + AWS = "AWS" + LOCAL = "Local" + + +class PolicyKind(StrEnum): + """Managed policy ownership kinds.""" + + AWS_MANAGED = "aws-managed" + CUSTOMER_MANAGED = "customer-managed" + + +class DiagnosticSeverity(StrEnum): + """Severity of a local or AWS policy validation diagnostic.""" + + ERROR = "error" + WARNING = "warning" + SUGGESTION = "suggestion" + + +class ChangeAction(StrEnum): + """High-level managed-policy publication actions.""" + + CREATE = "create" + NOOP = "noop" + UPDATE = "update" + ROLLBACK = "rollback" + ADOPT = "adopt" + RELEASE = "release" + DELETE = "delete" + + +class StepState(StrEnum): + """Execution state for an operation journal step.""" + + PLANNED = "planned" + SUCCEEDED = "succeeded" + FAILED = "failed" + COMPENSATED = "compensated" + + +@dataclass(frozen=True, slots=True) +class ManagedPolicyArn: + """Parsed IAM managed-policy ARN with exact account semantics.""" + + value: str + partition: str + account_id: str + resource: str + + @property + def kind(self) -> PolicyKind: + """Classify AWS-owned and customer-owned managed policies.""" + if self.account_id == AWS_ACCOUNT: + return PolicyKind.AWS_MANAGED + return PolicyKind.CUSTOMER_MANAGED + + @property + def name(self) -> str: + """Return the final path segment of the policy ARN.""" + return self.resource.rsplit("/", maxsplit=1)[-1] + + @property + def path(self) -> str: + """Return the IAM policy path including leading and trailing slashes.""" + if "/" not in self.resource: + return "/" + prefix = self.resource.rsplit("/", maxsplit=1)[0] + return f"/{prefix}/" + + @classmethod + def parse(cls, value: str) -> ManagedPolicyArn: + """Parse a full managed-policy ARN without accepting partial forms.""" + match = ARN_PATTERN.fullmatch(value) + if match is None: + message = f"Invalid IAM managed-policy ARN: {value!r}." + raise PolicyServiceError(message) + return cls( + value=value, + partition=match.group("partition"), + account_id=match.group("account"), + resource=match.group("resource"), + ) + + +@dataclass(frozen=True, slots=True) +class CallerIdentity: + """Verified caller attribution used by policy audit tags.""" + + account_id: str + partition: str + arn: str + principal_id: str + + +@dataclass(frozen=True, slots=True) +class Tag: + """IAM tag key and value.""" + + key: str + value: str + + def as_request(self) -> dict[str, str]: + """Convert to boto3 request shape.""" + return {"Key": self.key, "Value": self.value} + + +@dataclass(frozen=True, slots=True) +class RepairAction: + """Machine-readable proposed correction for a diagnostic.""" + + code: str + field: str + message: str + suggested_value: str | None = None + + +@dataclass(frozen=True, slots=True) +class ValidationDiagnostic: + """Aggregated validation result suitable for later CLI rendering.""" + + severity: DiagnosticSeverity + code: str + message: str + field: str | None = None + repair: RepairAction | None = None + + +@dataclass(frozen=True, slots=True) +class ValidationReport: + """Complete local and optional AWS validation results.""" + + diagnostics: tuple[ValidationDiagnostic, ...] = () + + @property + def valid(self) -> bool: + """Return whether the report contains no blocking errors.""" + return not any( + item.severity is DiagnosticSeverity.ERROR for item in self.diagnostics + ) + + @property + def repairs(self) -> tuple[RepairAction, ...]: + """Return every actionable repair in diagnostic order.""" + return tuple( + item.repair for item in self.diagnostics if item.repair is not None + ) + + def merge(self, other: ValidationReport) -> ValidationReport: + """Combine reports without discarding either source.""" + return ValidationReport(self.diagnostics + other.diagnostics) + + +@dataclass(frozen=True, slots=True) +class PolicyVersionRecord: + """IAM managed-policy version metadata and optional document.""" + + version_id: str + is_default: bool + created_at: datetime | None + document: dict[str, JsonValue] | None = None + + +@dataclass(frozen=True, slots=True) +class ManagedPolicyRecord: + """Complete managed-policy metadata used by service workflows.""" + + arn: ManagedPolicyArn + policy_id: str + name: str + path: str + default_version_id: str + attachment_count: int + permissions_boundary_usage_count: int + tags: tuple[Tag, ...] = () + document: dict[str, JsonValue] | None = None + versions: tuple[PolicyVersionRecord, ...] = () + description: str | None = None + + @property + def owned(self) -> bool: + """Return whether standard Hacksaws ownership tags are present.""" + values = {tag.key.casefold(): tag.value for tag in self.tags} + return ( + values.get("hacksaws:managed-by") == "hacksaws" + and values.get("hacksaws:resource-kind") == "managed-policy" + and bool(values.get("hacksaws:resource-id")) + ) + + +@dataclass(frozen=True, slots=True) +class ResolutionResult: + """Policy reference resolution that preserves all ambiguous candidates.""" + + reference: str + candidates: tuple[ManagedPolicyRecord, ...] + + @property + def ambiguous(self) -> bool: + """Return whether more than one candidate matched.""" + return len(self.candidates) > 1 + + @property + def selected(self) -> ManagedPolicyRecord | None: + """Return the single candidate, otherwise no implicit selection.""" + if len(self.candidates) == 1: + return self.candidates[0] + return None + + +@dataclass(frozen=True, slots=True) +class Compensation: + """Best-effort inverse operation available after a successful step.""" + + operation: str + parameters: Mapping[str, object] + + +@dataclass(frozen=True, slots=True) +class OperationStep: + """One inspectable mutation step with optional compensation metadata.""" + + step_id: str + operation: str + parameters: Mapping[str, object] + destructive: bool = False + compensation: Compensation | None = None + + +@dataclass(frozen=True, slots=True) +class OperationPlan: + """Pure operation plan produced before any AWS mutation occurs.""" + + plan_id: str + action: ChangeAction + summary: str + steps: tuple[OperationStep, ...] + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class JournalEntry: + """One immutable operation execution journal entry.""" + + step_id: str + state: StepState + occurred_at: datetime + detail: str | None = None + + +@dataclass(slots=True) +class OperationJournal: + """Append-only in-memory journal returned to later persistence layers.""" + + plan_id: str + entries: list[JournalEntry] + + def record( + self, + step_id: str, + state: StepState, + detail: str | None = None, + ) -> None: + """Append an execution event without performing persistence.""" + self.entries.append(JournalEntry(step_id, state, datetime.now(UTC), detail)) + + +@dataclass(frozen=True, slots=True) +class PolicyChangePlan: + """Managed-policy publication plan with concurrency preconditions.""" + + operation: OperationPlan + policy_arn: ManagedPolicyArn | None + name: str + path: str + document: dict[str, JsonValue] + description: str | None + tags: tuple[Tag, ...] + expected_default_version_id: str | None = None + expected_digest: str | None = None + prune_version_id: str | None = None + rollback_version_id: str | None = None + validation: ValidationReport = ValidationReport() + + +@dataclass(frozen=True, slots=True) +class PublishResult: + """Result of executing a create, no-op, update, or rollback plan.""" + + action: ChangeAction + policy: ManagedPolicyRecord + journal: OperationJournal + + +@dataclass(frozen=True, slots=True) +class PolicyExport: + """Lossless service-level export data for external serialization.""" + + policy: ManagedPolicyRecord + exported_at: datetime + active_document: dict[str, JsonValue] + versions: tuple[PolicyVersionRecord, ...] = () + + +@dataclass(frozen=True, slots=True) +class EntityReference: + """IAM identity depending on a managed policy.""" + + kind: str + name: str + entity_id: str + + +@dataclass(frozen=True, slots=True) +class PolicyDependencies: + """All permission-policy and permissions-boundary dependencies.""" + + permission_users: tuple[EntityReference, ...] = () + permission_groups: tuple[EntityReference, ...] = () + permission_roles: tuple[EntityReference, ...] = () + boundary_users: tuple[EntityReference, ...] = () + boundary_roles: tuple[EntityReference, ...] = () + + @property + def empty(self) -> bool: + """Return whether the policy has no live entity dependencies.""" + return not any( + ( + self.permission_users, + self.permission_groups, + self.permission_roles, + self.boundary_users, + self.boundary_roles, + ) + ) + + +@dataclass(frozen=True, slots=True) +class PolicyDeletionPlan: + """Dependency-complete policy deletion plan.""" + + policy: ManagedPolicyRecord + dependencies: PolicyDependencies + operation: OperationPlan + cascade: bool + + @property + def executable(self) -> bool: + """Return whether dependencies permit the selected deletion mode.""" + return self.cascade or self.dependencies.empty + + +@dataclass(frozen=True, slots=True) +class TagChangePlan: + """Optimistically guarded ownership or tag mutation plan.""" + + policy: ManagedPolicyRecord + operation: OperationPlan + add: tuple[Tag, ...] + remove: tuple[str, ...] + expected_digest: str + + +@dataclass(frozen=True, slots=True) +class MutationResult: + """Policy mutation result containing an auditable journal.""" + + policy: ManagedPolicyRecord | None + journal: OperationJournal + + +@dataclass(frozen=True, slots=True) +class PackedPolicyDiagnostic: + """Structured STS PackedPolicyTooLarge error information.""" + + code: str + message: str + packed_policy_size: int | None + repairs: tuple[RepairAction, ...] + + +@dataclass(frozen=True, slots=True) +class PackedPolicyWarning: + """Non-fatal warning returned for a successful near-limit STS request.""" + + packed_policy_size: int + threshold: int + message: str + + +@dataclass(frozen=True, slots=True) +class AssumeRoleProbeResult: + """Credential-free result of an explicit AssumeRole policy probe.""" + + role_arn: str + assumed_role_arn: str + expires_at: datetime | None + packed_policy_size: int | None + warning: PackedPolicyWarning | None + + +@dataclass(frozen=True, slots=True) +class RetryPolicy: + """Bounded delays for eventual-consistency verification.""" + + delays: tuple[float, ...] = (0.0, 0.25, 0.5, 1.0, 2.0, 4.0) + + +@dataclass(frozen=True, slots=True) +class PolicyServiceOptions: + """Account and retry configuration for the managed-policy service.""" + + account_id: str + partition: str + owned_path: str = DEFAULT_PATH + retry: RetryPolicy = RetryPolicy() + + +@dataclass(frozen=True, slots=True) +class CreatePolicyOptions: + """Optional metadata and validation settings for policy creation.""" + + description: str | None = None + path: str | None = None + resource_id: str | None = None + user_tags: tuple[Tag, ...] = () + caller: CallerIdentity | None = None + include_aws_validation: bool = True + + +@dataclass(frozen=True, slots=True) +class AssumeRoleProbeOptions: + """Context parameters for an explicit deny-all AssumeRole probe.""" + + session_name: str = "hacksaws-policy-probe" + duration_seconds: int = MIN_SESSION_DURATION + external_id: str | None = None + source_identity: str | None = None + session_tags: tuple[Tag, ...] = () + packed_warning_threshold: int = PACKED_WARNING_PERCENT + + +def _mapping(value: object, *, label: str) -> Mapping[str, object]: + if not isinstance(value, Mapping): + message = f"AWS response field {label!r} is not an object." + raise PolicyServiceError(message) + for key in value: + if not isinstance(key, str): + message = f"AWS response field {label!r} has a non-string key." + raise PolicyServiceError(message) + return value + + +def _items(value: object, *, label: str) -> Sequence[object]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + message = f"AWS response field {label!r} is not a list." + raise PolicyServiceError(message) + return value + + +def _string(value: object, *, label: str) -> str: + if not isinstance(value, str): + message = f"AWS response field {label!r} is not a string." + raise PolicyServiceError(message) + return value + + +def _integer(value: object, *, default: int = 0) -> int: + if isinstance(value, bool) or not isinstance(value, int): + return default + return value + + +def _datetime(value: object) -> datetime | None: + if isinstance(value, datetime): + return value + return None + + +def _normalize_path(path: str) -> str: + if not path.startswith("/") or not path.endswith("/"): + message = "IAM policy path must begin and end with '/'." + raise PolicyServiceError(message) + return path + + +def _new_step( + operation: str, + parameters: Mapping[str, object], + *, + destructive: bool = False, + compensation: Compensation | None = None, +) -> OperationStep: + return OperationStep( + step_id=uuid.uuid4().hex, + operation=operation, + parameters=parameters, + destructive=destructive, + compensation=compensation, + ) + + +def _operation_plan( + action: ChangeAction, + summary: str, + steps: Iterable[OperationStep], + *, + warnings: Iterable[str] = (), +) -> OperationPlan: + return OperationPlan( + plan_id=uuid.uuid4().hex, + action=action, + summary=summary, + steps=tuple(steps), + warnings=tuple(warnings), + ) + + +def _tags_from_response(value: object) -> tuple[Tag, ...]: + result: list[Tag] = [] + for item in _items(value, label="Tags"): + tag = _mapping(item, label="Tag") + result.append( + Tag(_string(tag.get("Key"), label="Tag.Key"), str(tag.get("Value", ""))) + ) + return tuple(result) + + +def _metadata_record(value: object) -> ManagedPolicyRecord: + policy = _mapping(value, label="Policy") + arn = ManagedPolicyArn.parse(_string(policy.get("Arn"), label="Policy.Arn")) + return ManagedPolicyRecord( + arn=arn, + policy_id=_string(policy.get("PolicyId"), label="Policy.PolicyId"), + name=_string(policy.get("PolicyName"), label="Policy.PolicyName"), + path=str(policy.get("Path", arn.path)), + default_version_id=_string( + policy.get("DefaultVersionId"), label="Policy.DefaultVersionId" + ), + attachment_count=_integer(policy.get("AttachmentCount")), + permissions_boundary_usage_count=_integer( + policy.get("PermissionsBoundaryUsageCount") + ), + description=( + str(policy["Description"]) + if policy.get("Description") is not None + else None + ), + ) + + +def _client_error_details(error: ClientError) -> tuple[str, str]: + detail = error.response.get("Error", {}) + if not isinstance(detail, Mapping): + return "Unknown", str(error) + return str(detail.get("Code", "Unknown")), str(detail.get("Message", error)) + + +def parse_packed_policy_diagnostic( + error: ClientError, +) -> PackedPolicyDiagnostic | None: + """Parse STS PackedPolicyTooLarge into actionable structured data.""" + code, message = _client_error_details(error) + if code != "PackedPolicyTooLarge": + return None + match = re.search(r"(?P\d{1,3})\s*%", message) + packed_size = int(match.group("size")) if match else None + repairs = ( + RepairAction( + "reduce-session-policy", + "Policy", + "Remove redundant session statements or split the workflow.", + ), + RepairAction( + "reduce-session-tags", + "Tags", + "Pass fewer or shorter session tags.", + ), + RepairAction( + "use-role-permissions", + "PolicyArns", + "Move stable permissions into the role and keep the session " + "boundary small.", + ), + ) + return PackedPolicyDiagnostic(code, message, packed_size, repairs) + + +def packed_policy_warning( + packed_policy_size: int | None, + *, + threshold: int = PACKED_WARNING_PERCENT, +) -> PackedPolicyWarning | None: + """Return warning data for successful STS calls near the packed limit.""" + if packed_policy_size is None or packed_policy_size < threshold: + return None + message = ( + f"STS packed policy size is {packed_policy_size}% of the service limit; " + "future policy or tag growth may fail." + ) + return PackedPolicyWarning(packed_policy_size, threshold, message) + + +class AccessAnalyzerPolicyValidator: + """Translate paginated IAM Access Analyzer findings into diagnostics.""" + + def __init__(self, client: AccessAnalyzerClient) -> None: + self._client = client + + def validate( + self, + document: Mapping[str, JsonValue], + *, + policy_type: str = "IDENTITY_POLICY", + resource_type: str | None = None, + ) -> ValidationReport: + """Validate a document and aggregate every result page.""" + diagnostics: list[ValidationDiagnostic] = [] + token: str | None = None + while True: + request: dict[str, object] = { + "policyDocument": canonical_policy_json(document), + "policyType": policy_type, + } + if resource_type is not None: + request["validatePolicyResourceType"] = resource_type + if token is not None: + request["nextToken"] = token + response = self._client.validate_policy(**request) + for raw in _items(response.get("findings", []), label="findings"): + finding = _mapping(raw, label="finding") + finding_type = str(finding.get("findingType", "WARNING")).casefold() + severity = { + "error": DiagnosticSeverity.ERROR, + "security_warning": DiagnosticSeverity.WARNING, + "warning": DiagnosticSeverity.WARNING, + "suggestion": DiagnosticSeverity.SUGGESTION, + }.get(finding_type, DiagnosticSeverity.WARNING) + diagnostics.append( + ValidationDiagnostic( + severity=severity, + code=str(finding.get("issueCode", "AWS_VALIDATION")), + message=str( + finding.get("findingDetails", "AWS policy finding") + ), + ) + ) + next_token = response.get("nextToken") + if not isinstance(next_token, str) or not next_token: + break + token = next_token + return ValidationReport(tuple(diagnostics)) + + +class IamManagedPolicyService: + """Plan and execute IAM managed-policy operations without UI side effects.""" + + def __init__( + self, + iam: IamClient, + sts: StsClient, + access_analyzer: AccessAnalyzerClient | None, + options: PolicyServiceOptions, + sleeper: Callable[[float], None] = time.sleep, + ) -> None: + account_id = options.account_id + partition = options.partition + if ACCOUNT_PATTERN.fullmatch(account_id) is None: + message = f"Invalid target AWS account ID {account_id!r}." + raise PolicyServiceError(message) + if partition not in {"aws", "aws-us-gov", "aws-cn"}: + message = f"Unsupported AWS partition {partition!r}." + raise PolicyServiceError(message) + self._iam = iam + self._sts = sts + self._validator = ( + AccessAnalyzerPolicyValidator(access_analyzer) + if access_analyzer is not None + else None + ) + self.account_id = account_id + self.partition = partition + self.owned_path = _normalize_path(options.owned_path) + self.retry = options.retry + self._sleep = sleeper + + def caller_identity(self) -> CallerIdentity: + """Get and exactly verify STS caller identity for this service target.""" + response = self._sts.get_caller_identity() + account = _string(response.get("Account"), label="Account") + arn = _string(response.get("Arn"), label="Arn") + principal_id = _string(response.get("UserId"), label="UserId") + arn_parts = arn.split(":", maxsplit=5) + if len(arn_parts) != ARN_PART_COUNT: + message = f"STS returned malformed caller ARN {arn!r}." + raise PolicyServiceError(message) + partition = arn_parts[1] + if account != self.account_id or partition != self.partition: + message = ( + f"Authenticated caller targets {partition}:{account}, expected " + f"{self.partition}:{self.account_id}." + ) + raise PolicyServiceError(message) + return CallerIdentity(account, partition, arn, principal_id) + + def validate_policy( + self, + document: Mapping[str, JsonValue], + *, + name: str | None = None, + path: str | None = None, + tags: Sequence[Tag] = (), + include_aws: bool = True, + ) -> ValidationReport: + """Aggregate local shape, quota, naming, tag, and AWS findings.""" + diagnostics: list[ValidationDiagnostic] = [] + if name is not None and ( + len(name) > MAX_POLICY_NAME or NAME_PATTERN.fullmatch(name) is None + ): + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "INVALID_POLICY_NAME", + "Policy name must be 1-128 IAM name characters.", + "name", + RepairAction( + "replace-name", + "name", + "Use letters, numbers, or _+=,.@-.", + ), + ) + ) + checked_path = path or self.owned_path + if ( + not checked_path.startswith("/") + or not checked_path.endswith("/") + or "*" in checked_path + ): + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "INVALID_POLICY_PATH", + "Policy path must begin/end with '/' and cannot contain '*'.", + "path", + RepairAction( + "replace-path", + "path", + "Use a valid IAM path.", + self.owned_path, + ), + ) + ) + if document.get("Version") != "2012-10-17": + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.WARNING, + "POLICY_LANGUAGE_VERSION", + "Use IAM policy language version 2012-10-17.", + "Version", + RepairAction( + "set-policy-language-version", + "Version", + "Set the current IAM policy language version.", + "2012-10-17", + ), + ) + ) + statements = document.get("Statement") + if not isinstance(statements, (dict, list)): + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "INVALID_STATEMENT", + "Policy Statement must be an object or list.", + "Statement", + ) + ) + elif isinstance(statements, list) and not statements: + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.WARNING, + "EMPTY_STATEMENT", + "Policy contains no permission statements.", + "Statement", + ) + ) + size = len(canonical_policy_json(document)) + if size > MAX_MANAGED_POLICY_SIZE: + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "POLICY_SIZE_EXCEEDED", + f"Minified policy is {size} characters; maximum is 6144.", + "policy", + RepairAction( + "split-policy", + "policy", + "Split permissions into multiple managed policies.", + ), + ) + ) + diagnostics.extend(self._validate_tags(tags)) + report = ValidationReport(tuple(diagnostics)) + if include_aws and self._validator is not None: + report = report.merge(self._validator.validate(document)) + return report + + def _validate_tags(self, tags: Sequence[Tag]) -> list[ValidationDiagnostic]: + diagnostics: list[ValidationDiagnostic] = [] + if len(tags) > MAX_TAGS: + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "TAG_LIMIT_EXCEEDED", + f"IAM permits at most {MAX_TAGS} tags.", + "tags", + ) + ) + seen: set[str] = set() + for tag in tags: + folded = tag.key.casefold() + if not tag.key or len(tag.key) > MAX_TAG_KEY: + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "INVALID_TAG_KEY", + f"Invalid tag key {tag.key!r}.", + "tags", + ) + ) + if len(tag.value) > MAX_TAG_VALUE: + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "INVALID_TAG_VALUE", + f"Tag {tag.key!r} value exceeds {MAX_TAG_VALUE} characters.", + "tags", + ) + ) + if folded.startswith("aws:") or tag.value.casefold().startswith("aws:"): + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "RESERVED_AWS_TAG_PREFIX", + f"Tag {tag.key!r} uses the reserved aws: prefix.", + "tags", + ) + ) + if tag.key in seen: + diagnostics.append( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "DUPLICATE_TAG_KEY", + f"Tag key {tag.key!r} was supplied more than once.", + "tags", + ) + ) + seen.add(tag.key) + return diagnostics + + def ownership_tags( + self, + resource_id: str, + user_tags: Sequence[Tag] = (), + *, + caller: CallerIdentity | None = None, + created_at: datetime | None = None, + ownership_origin: str = "created", + ) -> tuple[Tag, ...]: + """Merge repeatable user tags with protected ownership/audit tags.""" + collisions = [ + tag.key for tag in user_tags if tag.key.casefold() in RESERVED_TAGS + ] + if collisions: + message = ( + f"User tags cannot override reserved tags: {', '.join(collisions)}." + ) + raise PolicyServiceError(message) + identity = caller or self.caller_identity() + timestamp = created_at or datetime.now(UTC) + creator = identity.arn + if len(creator) > MAX_TAG_VALUE: + digest = hashlib.sha256(creator.encode()).hexdigest() + creator = f"sha256:{digest}" + result = [*user_tags] + result.extend( + ( + Tag("hacksaws:managed-by", "hacksaws"), + Tag("hacksaws:resource-id", resource_id), + Tag("hacksaws:resource-kind", "managed-policy"), + Tag("hacksaws:created-by", creator), + Tag("hacksaws:created-at", timestamp.isoformat()), + Tag("hacksaws:ownership-origin", ownership_origin), + ) + ) + report = ValidationReport(tuple(self._validate_tags(result))) + if not report.valid: + raise PolicyValidationError(report) + return tuple(result) + + def list_policies( + self, + *, + scope: PolicyScope = PolicyScope.ALL, + path_prefix: str | None = None, + include_tags: bool = False, + ) -> tuple[ManagedPolicyRecord, ...]: + """List every matching policy, following all IAM Marker pages.""" + marker: str | None = None + records: list[ManagedPolicyRecord] = [] + while True: + request: dict[str, object] = {"Scope": scope.value} + if path_prefix is not None: + request["PathPrefix"] = path_prefix + if marker is not None: + request["Marker"] = marker + response = self._iam.list_policies(**request) + for item in _items(response.get("Policies", []), label="Policies"): + record = _metadata_record(item) + self._assert_arn_target(record.arn) + if include_tags and record.arn.kind is PolicyKind.CUSTOMER_MANAGED: + record = self._with_tags(record) + records.append(record) + if response.get("IsTruncated") is not True: + break + marker = _string(response.get("Marker"), label="Marker") + return tuple(records) + + def _resolve_arn(self, reference: str) -> ResolutionResult: + arn = ManagedPolicyArn.parse(reference) + self._assert_arn_target(arn) + try: + return ResolutionResult(reference, (self._read_policy(arn),)) + except ClientError as error: + code, _ = _client_error_details(error) + if code == "NoSuchEntity": + return ResolutionResult(reference, ()) + raise + + @staticmethod + def _split_reference(reference: str) -> tuple[str | None, str]: + if ":" not in reference: + return None, reference + namespace, name = reference.split(":", maxsplit=1) + if namespace not in {"owned", "custom", "aws"}: + return None, reference + return namespace, name + + def resolve(self, reference: str) -> ResolutionResult: + """Resolve ARN/name and explicit owned:/custom:/aws: namespaces.""" + if reference.startswith("arn:"): + return self._resolve_arn(reference) + + namespace, name = self._split_reference(reference) + if not name: + message = "Policy reference name cannot be empty." + raise PolicyServiceError(message) + scopes = { + "owned": (PolicyScope.LOCAL,), + "custom": (PolicyScope.LOCAL,), + "aws": (PolicyScope.AWS,), + None: (PolicyScope.LOCAL, PolicyScope.AWS), + }[namespace] + matches: list[ManagedPolicyRecord] = [] + for scope in scopes: + for record in self.list_policies( + scope=scope, + path_prefix=self.owned_path if namespace == "owned" else None, + include_tags=namespace == "owned", + ): + if record.name.casefold() != name.casefold(): + continue + if namespace == "owned" and not record.owned: + continue + matches.append(record) + return ResolutionResult(reference, tuple(matches)) + + def get_policy( + self, + reference: str, + *, + include_document: bool = True, + include_versions: bool = False, + include_tags: bool = True, + ) -> ManagedPolicyRecord: + """Get one unambiguous policy by ARN or supported name reference.""" + resolution = self.resolve(reference) + selected = resolution.selected + if selected is None: + if resolution.ambiguous: + arns = ", ".join(item.arn.value for item in resolution.candidates) + message = f"Policy reference {reference!r} is ambiguous: {arns}." + else: + message = f"Policy reference {reference!r} was not found." + raise PolicyServiceError(message) + return self._hydrate_policy( + selected, + include_document=include_document, + include_versions=include_versions, + include_tags=include_tags, + ) + + def _assert_arn_target(self, arn: ManagedPolicyArn) -> None: + if arn.partition != self.partition: + message = ( + f"Policy partition {arn.partition!r} does not match {self.partition!r}." + ) + raise PolicyServiceError(message) + if ( + arn.kind is PolicyKind.CUSTOMER_MANAGED + and arn.account_id != self.account_id + ): + message = ( + f"Customer policy account {arn.account_id} does not match " + f"{self.account_id}." + ) + raise PolicyServiceError(message) + + def _read_policy(self, arn: ManagedPolicyArn) -> ManagedPolicyRecord: + response = self._iam.get_policy(PolicyArn=arn.value) + record = _metadata_record(response.get("Policy")) + self._assert_arn_target(record.arn) + if record.arn.value != arn.value: + message = "IAM returned a different policy ARN than requested." + raise PolicyServiceError(message) + return record + + def _with_tags(self, record: ManagedPolicyRecord) -> ManagedPolicyRecord: + marker: str | None = None + tags: list[Tag] = [] + while True: + request: dict[str, object] = {"PolicyArn": record.arn.value} + if marker is not None: + request["Marker"] = marker + response = self._iam.list_policy_tags(**request) + tags.extend(_tags_from_response(response.get("Tags", []))) + if response.get("IsTruncated") is not True: + break + marker = _string(response.get("Marker"), label="Marker") + return replace(record, tags=tuple(tags)) + + def _list_versions( + self, + arn: ManagedPolicyArn, + *, + include_documents: bool = False, + ) -> tuple[PolicyVersionRecord, ...]: + marker: str | None = None + versions: list[PolicyVersionRecord] = [] + while True: + request: dict[str, object] = {"PolicyArn": arn.value} + if marker is not None: + request["Marker"] = marker + response = self._iam.list_policy_versions(**request) + for raw in _items(response.get("Versions", []), label="Versions"): + item = _mapping(raw, label="PolicyVersion") + version_id = _string(item.get("VersionId"), label="VersionId") + document = ( + self._get_version_document(arn, version_id) + if include_documents + else None + ) + versions.append( + PolicyVersionRecord( + version_id=version_id, + is_default=item.get("IsDefaultVersion") is True, + created_at=_datetime(item.get("CreateDate")), + document=document, + ) + ) + if response.get("IsTruncated") is not True: + break + marker = _string(response.get("Marker"), label="Marker") + return tuple(versions) + + def _get_version_document( + self, arn: ManagedPolicyArn, version_id: str + ) -> dict[str, JsonValue]: + response = self._iam.get_policy_version( + PolicyArn=arn.value, + VersionId=version_id, + ) + version = _mapping(response.get("PolicyVersion"), label="PolicyVersion") + return decode_iam_document(version.get("Document")) + + def _hydrate_policy( + self, + record: ManagedPolicyRecord, + *, + include_document: bool, + include_versions: bool, + include_tags: bool, + ) -> ManagedPolicyRecord: + current = self._read_policy(record.arn) + tags = ( + self._with_tags(current).tags + if include_tags and current.arn.kind is PolicyKind.CUSTOMER_MANAGED + else () + ) + document = ( + self._get_version_document(current.arn, current.default_version_id) + if include_document + else None + ) + versions = ( + self._list_versions(current.arn, include_documents=include_versions) + if include_versions + else () + ) + return replace(current, tags=tags, document=document, versions=versions) + + def plan_create( + self, + name: str, + document: dict[str, JsonValue], + *, + options: CreatePolicyOptions | None = None, + ) -> PolicyChangePlan: + """Plan a tagged customer-managed policy creation.""" + selected_options = options or CreatePolicyOptions() + selected_path = selected_options.path or self.owned_path + tags = self.ownership_tags( + selected_options.resource_id or uuid.uuid4().hex, + selected_options.user_tags, + caller=selected_options.caller, + ) + report = self.validate_policy( + document, + name=name, + path=selected_path, + tags=tags, + include_aws=selected_options.include_aws_validation, + ) + request: dict[str, object] = { + "PolicyName": name, + "Path": selected_path, + "PolicyDocument": canonical_policy_json(document), + "Tags": [tag.as_request() for tag in tags], + } + if selected_options.description is not None: + request["Description"] = selected_options.description + step = _new_step( + "CreatePolicy", + request, + compensation=Compensation( + "DeletePolicy", + { + "PolicyArn": ( + f"arn:{self.partition}:iam::{self.account_id}:policy" + f"{selected_path}{name}" + ) + }, + ), + ) + operation = _operation_plan( + ChangeAction.CREATE, + f"Create customer-managed policy {selected_path}{name}.", + (step,), + ) + return PolicyChangePlan( + operation=operation, + policy_arn=None, + name=name, + path=selected_path, + document=document, + description=selected_options.description, + tags=tags, + validation=report, + ) + + def plan_publish( + self, + reference: str, + document: dict[str, JsonValue], + *, + include_aws_validation: bool = True, + ) -> PolicyChangePlan: + """Plan a no-op or safely versioned customer-policy update.""" + current = self.get_policy( + reference, + include_document=True, + include_versions=True, + include_tags=True, + ) + self._require_mutable(current) + report = self.validate_policy( + document, + name=current.name, + path=current.path, + tags=current.tags, + include_aws=include_aws_validation, + ) + if current.document is None: + message = "Current managed policy document was not loaded." + raise PolicyServiceError(message) + current_digest = policy_digest(current.document) + if current_digest == policy_digest(document): + operation = _operation_plan( + ChangeAction.NOOP, + f"Policy {current.arn.value} is semantically unchanged.", + (), + ) + return PolicyChangePlan( + operation=operation, + policy_arn=current.arn, + name=current.name, + path=current.path, + document=document, + description=None, + tags=current.tags, + expected_default_version_id=current.default_version_id, + expected_digest=current_digest, + validation=report, + ) + + prune_id: str | None = None + warnings: list[str] = [] + steps: list[OperationStep] = [] + if len(current.versions) >= MAX_POLICY_VERSIONS: + nondefault = [item for item in current.versions if not item.is_default] + if not current.owned: + repair = RepairAction( + "select-version-to-prune", + "versions", + "Select and explicitly delete a nondefault version before " + "publishing.", + ) + report = report.merge( + ValidationReport( + ( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "POLICY_VERSION_LIMIT", + "Unowned policy has five versions; automatic pruning " + "is refused.", + "versions", + repair, + ), + ) + ) + ) + elif nondefault: + prune = min( + nondefault, + key=lambda item: ( + item.created_at or datetime.min.replace(tzinfo=UTC) + ), + ) + prune_id = prune.version_id + steps.append( + _new_step( + "DeletePolicyVersion", + { + "PolicyArn": current.arn.value, + "VersionId": prune_id, + }, + destructive=True, + ) + ) + warnings.append( + f"Oldest nondefault version {prune_id} will be pruned at the " + "five-version limit." + ) + else: + report = report.merge( + ValidationReport( + ( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "NO_PRUNABLE_VERSION", + "Policy version limit reached with no nondefault " + "version.", + "versions", + ), + ) + ) + ) + steps.append( + _new_step( + "CreatePolicyVersion", + { + "PolicyArn": current.arn.value, + "PolicyDocument": canonical_policy_json(document), + "SetAsDefault": True, + }, + compensation=Compensation( + "SetDefaultPolicyVersion", + { + "PolicyArn": current.arn.value, + "VersionId": current.default_version_id, + }, + ), + ) + ) + operation = _operation_plan( + ChangeAction.UPDATE, + f"Publish a new default version for {current.arn.value}.", + steps, + warnings=warnings, + ) + return PolicyChangePlan( + operation=operation, + policy_arn=current.arn, + name=current.name, + path=current.path, + document=document, + description=None, + tags=current.tags, + expected_default_version_id=current.default_version_id, + expected_digest=current_digest, + prune_version_id=prune_id, + validation=report, + ) + + def plan_rollback( + self, + reference: str, + version_id: str, + ) -> PolicyChangePlan: + """Plan switching a customer-managed policy to a retained version.""" + current = self.get_policy( + reference, + include_document=True, + include_versions=True, + include_tags=True, + ) + self._require_mutable(current) + target = next( + (item for item in current.versions if item.version_id == version_id), + None, + ) + if target is None: + message = f"Policy version {version_id!r} does not exist." + raise PolicyServiceError(message) + if current.document is None: + message = "Current managed policy document was not loaded." + raise PolicyServiceError(message) + target_document = self._get_version_document(current.arn, version_id) + action = ( + ChangeAction.NOOP + if version_id == current.default_version_id + else ChangeAction.ROLLBACK + ) + steps: tuple[OperationStep, ...] = () + if action is ChangeAction.ROLLBACK: + steps = ( + _new_step( + "SetDefaultPolicyVersion", + {"PolicyArn": current.arn.value, "VersionId": version_id}, + compensation=Compensation( + "SetDefaultPolicyVersion", + { + "PolicyArn": current.arn.value, + "VersionId": current.default_version_id, + }, + ), + ), + ) + operation = _operation_plan( + action, + f"Set {version_id} as default for {current.arn.value}.", + steps, + ) + return PolicyChangePlan( + operation=operation, + policy_arn=current.arn, + name=current.name, + path=current.path, + document=target_document, + description=None, + tags=current.tags, + expected_default_version_id=current.default_version_id, + expected_digest=policy_digest(current.document), + rollback_version_id=version_id, + ) + + def execute_change(self, plan: PolicyChangePlan) -> PublishResult: + """Execute a validated policy change with drift and journal safeguards.""" + if not plan.validation.valid: + raise PolicyValidationError(plan.validation) + journal = OperationJournal(plan.operation.plan_id, []) + if plan.operation.action is ChangeAction.CREATE: + return self._execute_create(plan, journal) + if plan.policy_arn is None: + message = "Non-create policy plan requires an ARN." + raise PolicyServiceError(message) + current = self._assert_change_precondition(plan) + if plan.operation.action is ChangeAction.NOOP: + return PublishResult(ChangeAction.NOOP, current, journal) + if plan.operation.action is ChangeAction.ROLLBACK: + return self._execute_rollback(plan, current, journal) + return self._execute_update(plan, current, journal) + + def _execute_create( + self, + plan: PolicyChangePlan, + journal: OperationJournal, + ) -> PublishResult: + step = plan.operation.steps[0] + request: dict[str, object] = { + "PolicyName": plan.name, + "Path": plan.path, + "PolicyDocument": canonical_policy_json(plan.document), + "Tags": [tag.as_request() for tag in plan.tags], + } + if plan.description is not None: + request["Description"] = plan.description + try: + response = self._iam.create_policy(**request) + except ClientError as error: + journal.record(step.step_id, StepState.FAILED, str(error)) + raise + journal.record(step.step_id, StepState.SUCCEEDED) + created = _metadata_record(response.get("Policy")) + self._assert_arn_target(created.arn) + verified = self._verify_policy( + created.arn, + expected_version=created.default_version_id, + expected_digest=policy_digest(plan.document), + ) + return PublishResult(ChangeAction.CREATE, verified, journal) + + def _assert_change_precondition( + self, + plan: PolicyChangePlan, + ) -> ManagedPolicyRecord: + if plan.policy_arn is None: + message = "Policy change precondition requires an ARN." + raise PolicyServiceError(message) + current = self._hydrate_policy( + self._read_policy(plan.policy_arn), + include_document=True, + include_versions=True, + include_tags=True, + ) + if current.document is None: + message = "Current managed policy document was not loaded." + raise PolicyServiceError(message) + if ( + current.default_version_id != plan.expected_default_version_id + or policy_digest(current.document) != plan.expected_digest + ): + message = ( + f"Policy {current.arn.value} changed after planning; rebuild and " + "review " + "the operation plan." + ) + raise PolicyDriftError(message) + return current + + def _execute_update( + self, + plan: PolicyChangePlan, + current: ManagedPolicyRecord, + journal: OperationJournal, + ) -> PublishResult: + step_index = 0 + if plan.prune_version_id is not None: + prune_step = plan.operation.steps[step_index] + try: + self._iam.delete_policy_version( + PolicyArn=current.arn.value, + VersionId=plan.prune_version_id, + ) + except ClientError as error: + journal.record(prune_step.step_id, StepState.FAILED, str(error)) + raise + journal.record(prune_step.step_id, StepState.SUCCEEDED) + step_index += 1 + publish_step = plan.operation.steps[step_index] + try: + response = self._iam.create_policy_version( + PolicyArn=current.arn.value, + PolicyDocument=canonical_policy_json(plan.document), + SetAsDefault=True, + ) + except ClientError as error: + journal.record(publish_step.step_id, StepState.FAILED, str(error)) + raise + version = _mapping(response.get("PolicyVersion"), label="PolicyVersion") + version_id = _string(version.get("VersionId"), label="VersionId") + journal.record(publish_step.step_id, StepState.SUCCEEDED, version_id) + verified = self._verify_policy( + current.arn, + expected_version=version_id, + expected_digest=policy_digest(plan.document), + ) + return PublishResult(ChangeAction.UPDATE, verified, journal) + + def _execute_rollback( + self, + plan: PolicyChangePlan, + current: ManagedPolicyRecord, + journal: OperationJournal, + ) -> PublishResult: + version_id = plan.rollback_version_id + if version_id is None: + message = "Rollback plan does not specify a target version." + raise PolicyServiceError(message) + step = plan.operation.steps[0] + try: + self._iam.set_default_policy_version( + PolicyArn=current.arn.value, + VersionId=version_id, + ) + except ClientError as error: + journal.record(step.step_id, StepState.FAILED, str(error)) + raise + journal.record(step.step_id, StepState.SUCCEEDED) + verified = self._verify_policy( + current.arn, + expected_version=version_id, + expected_digest=policy_digest(plan.document), + ) + return PublishResult(ChangeAction.ROLLBACK, verified, journal) + + def _verify_policy( + self, + arn: ManagedPolicyArn, + *, + expected_version: str, + expected_digest: str, + ) -> ManagedPolicyRecord: + last_detail = "policy was not visible" + for delay in self.retry.delays: + if delay: + self._sleep(delay) + try: + current = self._hydrate_policy( + self._read_policy(arn), + include_document=True, + include_versions=False, + include_tags=True, + ) + except ClientError as error: + code, detail = _client_error_details(error) + if code != "NoSuchEntity": + raise + last_detail = detail + continue + if current.document is None: + last_detail = "policy document was absent" + continue + if ( + current.default_version_id == expected_version + and policy_digest(current.document) == expected_digest + ): + return current + last_detail = ( + f"observed default {current.default_version_id} with digest " + f"{policy_digest(current.document)}" + ) + message = ( + f"IAM accepted the mutation for {arn.value}, but bounded propagation " + f"verification failed: {last_detail}." + ) + raise PolicyServiceError(message) + + def export_policy( + self, + reference: str, + *, + include_all_versions: bool = False, + ) -> PolicyExport: + """Return active policy data and optionally every retained version.""" + policy = self.get_policy( + reference, + include_document=True, + include_versions=include_all_versions, + include_tags=True, + ) + if policy.document is None: + message = "Managed policy export has no active document." + raise PolicyServiceError(message) + return PolicyExport( + policy=policy, + exported_at=datetime.now(UTC), + active_document=policy.document, + versions=policy.versions if include_all_versions else (), + ) + + @staticmethod + def _require_mutable(policy: ManagedPolicyRecord) -> None: + if policy.arn.kind is PolicyKind.AWS_MANAGED: + message = f"AWS-managed policy {policy.arn.value} is immutable." + raise ImmutablePolicyError(message) + + @staticmethod + def _tag_digest(tags: Sequence[Tag]) -> str: + value = json.dumps( + sorted((tag.key, tag.value) for tag in tags), + ensure_ascii=False, + separators=(",", ":"), + ) + return hashlib.sha256(value.encode()).hexdigest() + + def plan_adopt( + self, + reference: str, + resource_id: str, + *, + user_tags: Sequence[Tag] = (), + caller: CallerIdentity | None = None, + ) -> TagChangePlan: + """Plan adopting an existing customer policy into Hacksaws ownership.""" + policy = self.get_policy( + reference, + include_document=False, + include_versions=False, + include_tags=True, + ) + self._require_mutable(policy) + values = {tag.key.casefold(): tag.value for tag in policy.tags} + manager = values.get("hacksaws:managed-by") + if manager is not None and manager != "hacksaws": + message = f"Policy is already managed by {manager!r}." + raise PolicyServiceError(message) + add = self.ownership_tags( + resource_id, + user_tags, + caller=caller, + ownership_origin="adopted", + ) + report = ValidationReport(tuple(self._validate_tags(add))) + if not report.valid: + raise PolicyValidationError(report) + step = _new_step( + "TagPolicy", + { + "PolicyArn": policy.arn.value, + "Tags": [tag.as_request() for tag in add], + }, + compensation=Compensation( + "RestorePolicyTags", + {"Tags": [tag.as_request() for tag in policy.tags]}, + ), + ) + operation = _operation_plan( + ChangeAction.ADOPT, + f"Adopt {policy.arn.value} into Hacksaws ownership.", + (step,), + ) + return TagChangePlan( + policy, + operation, + add, + (), + self._tag_digest(policy.tags), + ) + + def plan_release(self, reference: str) -> TagChangePlan: + """Plan removing Hacksaws ownership/audit tags without deleting policy.""" + policy = self.get_policy( + reference, + include_document=False, + include_versions=False, + include_tags=True, + ) + self._require_mutable(policy) + remove = tuple( + tag.key for tag in policy.tags if tag.key.casefold() in RESERVED_TAGS + ) + steps: tuple[OperationStep, ...] = () + if remove: + previous = [ + tag.as_request() for tag in policy.tags if tag.key in set(remove) + ] + steps = ( + _new_step( + "UntagPolicy", + {"PolicyArn": policy.arn.value, "TagKeys": list(remove)}, + compensation=Compensation( + "TagPolicy", + {"PolicyArn": policy.arn.value, "Tags": previous}, + ), + ), + ) + operation = _operation_plan( + ChangeAction.RELEASE, + f"Release {policy.arn.value} from Hacksaws ownership.", + steps, + ) + return TagChangePlan( + policy, + operation, + (), + remove, + self._tag_digest(policy.tags), + ) + + def execute_tag_change(self, plan: TagChangePlan) -> MutationResult: + """Execute an adopt/release tag plan after checking tag drift.""" + self._require_mutable(plan.policy) + current = self.get_policy( + plan.policy.arn.value, + include_document=False, + include_versions=False, + include_tags=True, + ) + if self._tag_digest(current.tags) != plan.expected_digest: + message = ( + f"Policy tags for {current.arn.value} changed after planning; " + "rebuild the tag plan." + ) + raise PolicyDriftError(message) + journal = OperationJournal(plan.operation.plan_id, []) + if not plan.operation.steps: + return MutationResult(current, journal) + step = plan.operation.steps[0] + try: + if plan.add: + self._iam.tag_policy( + PolicyArn=current.arn.value, + Tags=[tag.as_request() for tag in plan.add], + ) + if plan.remove: + self._iam.untag_policy( + PolicyArn=current.arn.value, + TagKeys=list(plan.remove), + ) + except ClientError as error: + journal.record(step.step_id, StepState.FAILED, str(error)) + raise + journal.record(step.step_id, StepState.SUCCEEDED) + updated = self.get_policy( + current.arn.value, + include_document=False, + include_versions=False, + include_tags=True, + ) + return MutationResult(updated, journal) + + def _list_entities( + self, + arn: ManagedPolicyArn, + usage: str, + ) -> tuple[ + tuple[EntityReference, ...], + tuple[EntityReference, ...], + tuple[EntityReference, ...], + ]: + users: dict[str, EntityReference] = {} + groups: dict[str, EntityReference] = {} + roles: dict[str, EntityReference] = {} + marker: str | None = None + while True: + request: dict[str, object] = { + "PolicyArn": arn.value, + "PolicyUsageFilter": usage, + } + if marker is not None: + request["Marker"] = marker + response = self._iam.list_entities_for_policy(**request) + self._collect_entities( + response.get("PolicyUsers", []), + "User", + "UserName", + "UserId", + users, + ) + self._collect_entities( + response.get("PolicyGroups", []), + "Group", + "GroupName", + "GroupId", + groups, + ) + self._collect_entities( + response.get("PolicyRoles", []), + "Role", + "RoleName", + "RoleId", + roles, + ) + if response.get("IsTruncated") is not True: + break + marker = _string(response.get("Marker"), label="Marker") + return tuple(users.values()), tuple(groups.values()), tuple(roles.values()) + + @staticmethod + def _collect_entities( + raw_items: object, + kind: str, + name_key: str, + id_key: str, + destination: dict[str, EntityReference], + ) -> None: + for raw in _items(raw_items, label=f"Policy{kind}s"): + item = _mapping(raw, label=kind) + name = _string(item.get(name_key), label=name_key) + entity_id = _string(item.get(id_key), label=id_key) + destination[entity_id] = EntityReference(kind, name, entity_id) + + def policy_dependencies(self, reference: str) -> PolicyDependencies: + """List every attachment and permissions-boundary dependency.""" + policy = self.get_policy( + reference, + include_document=False, + include_versions=False, + include_tags=False, + ) + permission_users, permission_groups, permission_roles = self._list_entities( + policy.arn, + "PermissionsPolicy", + ) + boundary_users, _, boundary_roles = self._list_entities( + policy.arn, + "PermissionsBoundary", + ) + return PolicyDependencies( + permission_users, + permission_groups, + permission_roles, + boundary_users, + boundary_roles, + ) + + @staticmethod + def _cascade_delete_steps( + arn: str, + dependencies: PolicyDependencies, + ) -> list[OperationStep]: + steps = [ + _new_step( + "DetachUserPolicy", + {"UserName": entity.name, "PolicyArn": arn}, + destructive=True, + ) + for entity in dependencies.permission_users + ] + steps.extend( + _new_step( + "DetachGroupPolicy", + {"GroupName": entity.name, "PolicyArn": arn}, + destructive=True, + ) + for entity in dependencies.permission_groups + ) + steps.extend( + _new_step( + "DetachRolePolicy", + {"RoleName": entity.name, "PolicyArn": arn}, + destructive=True, + ) + for entity in dependencies.permission_roles + ) + steps.extend( + _new_step( + "DeleteUserPermissionsBoundary", + {"UserName": entity.name}, + destructive=True, + ) + for entity in dependencies.boundary_users + ) + steps.extend( + _new_step( + "DeleteRolePermissionsBoundary", + {"RoleName": entity.name}, + destructive=True, + ) + for entity in dependencies.boundary_roles + ) + return steps + + @staticmethod + def _version_delete_steps(policy: ManagedPolicyRecord) -> list[OperationStep]: + return [ + _new_step( + "DeletePolicyVersion", + { + "PolicyArn": policy.arn.value, + "VersionId": version.version_id, + }, + destructive=True, + ) + for version in policy.versions + if version.version_id != policy.default_version_id + ] + + def plan_delete( + self, + reference: str, + *, + cascade: bool = False, + ) -> PolicyDeletionPlan: + """Plan dependency-complete deletion without performing confirmation.""" + policy = self.get_policy( + reference, + include_document=False, + include_versions=True, + include_tags=True, + ) + self._require_mutable(policy) + dependencies = self.policy_dependencies(policy.arn.value) + steps: list[OperationStep] = [] + warnings: list[str] = [] + if not policy.owned: + warnings.append("Policy does not carry complete Hacksaws ownership tags.") + if cascade: + steps.extend(self._cascade_delete_steps(policy.arn.value, dependencies)) + elif not dependencies.empty: + warnings.append("Deletion is blocked until all dependencies are removed.") + steps.extend(self._version_delete_steps(policy)) + steps.append( + _new_step( + "DeletePolicy", + {"PolicyArn": policy.arn.value}, + destructive=True, + ) + ) + operation = _operation_plan( + ChangeAction.DELETE, + f"Delete customer-managed policy {policy.arn.value}.", + steps, + warnings=warnings, + ) + return PolicyDeletionPlan(policy, dependencies, operation, cascade) + + def execute_delete(self, plan: PolicyDeletionPlan) -> MutationResult: + """Execute an already-confirmed dependency-complete deletion plan.""" + if not plan.executable: + message = "Deletion plan has dependencies but cascade was not authorized." + raise PolicyValidationError( + ValidationReport( + ( + ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "POLICY_HAS_DEPENDENCIES", + message, + "dependencies", + ), + ) + ) + ) + current = self.get_policy( + plan.policy.arn.value, + include_document=False, + include_versions=True, + include_tags=True, + ) + current_dependencies = self.policy_dependencies(current.arn.value) + if ( + current.policy_id != plan.policy.policy_id + or current.default_version_id != plan.policy.default_version_id + or current.versions != plan.policy.versions + or current_dependencies != plan.dependencies + ): + message = "Policy or its dependencies changed after deletion planning." + raise PolicyDriftError(message) + journal = OperationJournal(plan.operation.plan_id, []) + for step in plan.operation.steps: + try: + self._execute_delete_step(step) + except ClientError as error: + journal.record(step.step_id, StepState.FAILED, str(error)) + raise + journal.record(step.step_id, StepState.SUCCEEDED) + return MutationResult(None, journal) + + def _execute_delete_step(self, step: OperationStep) -> None: + parameters = dict(step.parameters) + operations: dict[str, Callable[..., object]] = { + "DetachUserPolicy": self._iam.detach_user_policy, + "DetachGroupPolicy": self._iam.detach_group_policy, + "DetachRolePolicy": self._iam.detach_role_policy, + "DeleteUserPermissionsBoundary": ( + self._iam.delete_user_permissions_boundary + ), + "DeleteRolePermissionsBoundary": ( + self._iam.delete_role_permissions_boundary + ), + "DeletePolicyVersion": self._iam.delete_policy_version, + "DeletePolicy": self._iam.delete_policy, + } + operation = operations.get(step.operation) + if operation is None: + message = f"Unsupported deletion step {step.operation!r}." + raise PolicyServiceError(message) + operation(**parameters) + + def probe_assume_role( + self, + role_arn: str, + document: Mapping[str, JsonValue], + *, + options: AssumeRoleProbeOptions | None = None, + ) -> AssumeRoleProbeResult: + """Probe the selected policy exactly and discard returned credentials.""" + selected_options = options or AssumeRoleProbeOptions() + role_match = ROLE_ARN_PATTERN.fullmatch(role_arn) + if role_match is None: + message = f"Invalid IAM role ARN {role_arn!r}." + raise PolicyServiceError(message) + if ( + role_match.group("partition") != self.partition + or role_match.group("account") != self.account_id + ): + message = ( + "AssumeRole probe ARN does not match the target account/partition." + ) + raise PolicyServiceError(message) + request: dict[str, object] = { + "RoleArn": role_arn, + "RoleSessionName": selected_options.session_name, + "DurationSeconds": selected_options.duration_seconds, + "Policy": canonical_policy_json(document), + } + if selected_options.external_id is not None: + request["ExternalId"] = selected_options.external_id + if selected_options.source_identity is not None: + request["SourceIdentity"] = selected_options.source_identity + if selected_options.session_tags: + request["Tags"] = [ + tag.as_request() for tag in selected_options.session_tags + ] + try: + response = self._sts.assume_role(**request) + except ClientError as error: + diagnostic = parse_packed_policy_diagnostic(error) + if diagnostic is not None: + raise PackedPolicyProbeError(diagnostic) from error + raise + assumed = _mapping(response.get("AssumedRoleUser"), label="AssumedRoleUser") + credentials = _mapping(response.get("Credentials"), label="Credentials") + packed_size = ( + _integer(response.get("PackedPolicySize"), default=-1) + if "PackedPolicySize" in response + else None + ) + if packed_size == -1: + packed_size = None + return AssumeRoleProbeResult( + role_arn=role_arn, + assumed_role_arn=_string(assumed.get("Arn"), label="AssumedRoleUser.Arn"), + expires_at=_datetime(credentials.get("Expiration")), + packed_policy_size=packed_size, + warning=packed_policy_warning( + packed_size, + threshold=selected_options.packed_warning_threshold, + ), + ) diff --git a/hacksaws/_iam_policy_cli.py b/hacksaws/_iam_policy_cli.py new file mode 100644 index 0000000..2dbf442 --- /dev/null +++ b/hacksaws/_iam_policy_cli.py @@ -0,0 +1,2867 @@ +"""CLI adapter for safe AWS IAM managed-policy workflows.""" + +# The parser and dispatch functions intentionally enumerate a broad command grammar. +# ruff: noqa: C901, PLR0911, PLR0912, PLR0915, TRY003 + +from __future__ import annotations + +import argparse +import contextlib +import difflib +import fnmatch +import json +import os +import re +import shlex +import subprocess +import sys +import tempfile +import uuid +from dataclasses import asdict +from dataclasses import replace +from io import StringIO +from pathlib import Path +from typing import TYPE_CHECKING +from typing import cast +from urllib.parse import quote + +import yaml +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError +from rich.console import Console + +from hacksaws import _configs +from hacksaws import _iam_recovery +from hacksaws import _output +from hacksaws import _policies +from hacksaws import _state +from hacksaws._configs import OperationalError +from hacksaws._iam_managed_policies import AssumeRoleProbeOptions +from hacksaws._iam_managed_policies import ChangeAction +from hacksaws._iam_managed_policies import CreatePolicyOptions +from hacksaws._iam_managed_policies import DiagnosticSeverity +from hacksaws._iam_managed_policies import EntityReference +from hacksaws._iam_managed_policies import IamManagedPolicyService +from hacksaws._iam_managed_policies import ImmutablePolicyError +from hacksaws._iam_managed_policies import ManagedPolicyArn +from hacksaws._iam_managed_policies import ManagedPolicyRecord +from hacksaws._iam_managed_policies import PackedPolicyProbeError +from hacksaws._iam_managed_policies import PolicyChangePlan +from hacksaws._iam_managed_policies import PolicyDeletionPlan +from hacksaws._iam_managed_policies import PolicyDependencies +from hacksaws._iam_managed_policies import PolicyDriftError +from hacksaws._iam_managed_policies import PolicyKind +from hacksaws._iam_managed_policies import PolicyScope +from hacksaws._iam_managed_policies import PolicyServiceError +from hacksaws._iam_managed_policies import PolicyServiceOptions +from hacksaws._iam_managed_policies import PolicyValidationError +from hacksaws._iam_managed_policies import PolicyVersionRecord +from hacksaws._iam_managed_policies import Tag +from hacksaws._iam_managed_policies import ValidationReport +from hacksaws._iam_policy_documents import InputMetadata +from hacksaws._iam_policy_documents import JsonValue +from hacksaws._iam_policy_documents import LoadedPolicyInput +from hacksaws._iam_policy_documents import MetadataMode +from hacksaws._iam_policy_documents import PolicyFormat +from hacksaws._iam_policy_documents import PolicyInputError +from hacksaws._iam_policy_documents import canonical_policy_json +from hacksaws._iam_policy_documents import load_policy_input +from hacksaws._iam_policy_documents import policy_digest + +if TYPE_CHECKING: + from collections.abc import Iterable + from collections.abc import Mapping + from collections.abc import Sequence + + from hacksaws._iam_cli import IamCommandContext + +name = "policy" +_FORMAT_CHOICES = tuple(item.value for item in PolicyFormat) +_METADATA_CHOICES = tuple(item.value for item in MetadataMode) +_RESERVED_PREFIX = "hacksaws:" +_MAX_POLICY_VERSIONS = 5 + + +def _console_url(context: IamCommandContext, arn: str) -> str: + region = ( + getattr(getattr(context, "session", None), "region_name", None) or "us-east-1" + ) + return ( + f"https://{region}.console.aws.amazon.com/iam/home?region={region}" + f"#/policies/details/{quote(arn, safe='')}?section=permissions" + ) + + +def _selectors(parser: argparse.ArgumentParser, *, mutation: bool = False) -> None: + """Expose credential selectors on the terminal command where users need them.""" + group = parser.add_argument_group("credential selection") + group.add_argument( + "--profile", + default=argparse.SUPPRESS, + metavar="PROFILE", + help="AWS profile to use.", + ) + group.add_argument( + "--location", + default=argparse.SUPPRESS, + metavar="NAME", + help="Named AWS config directory to use.", + ) + group.add_argument( + "-d", + "--directory", + default=argparse.SUPPRESS, + metavar="PATH", + help="Explicit AWS config directory; conflicts with --location.", + ) + group.add_argument( + "--target", + default=argparse.SUPPRESS, + metavar="NAME", + help="Saved target supplying the credential source.", + ) + group.add_argument( + "--account", + default=argparse.SUPPRESS, + metavar="NAME_OR_ID", + help="Assert the selected AWS account.", + ) + group.add_argument( + "--region", + default=argparse.SUPPRESS, + metavar="REGION", + help="Region used for AWS clients and console links.", + ) + if mutation: + safety = parser.add_argument_group("safety") + safety.add_argument( + "--dry-run", + action="store_true", + default=argparse.SUPPRESS, + help="Validate and show the plan without changing AWS or local state.", + ) + safety.add_argument( + "--yes", + action="store_true", + default=argparse.SUPPRESS, + help="Approve the displayed plan without prompting.", + ) + + +def _input_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--format", choices=_FORMAT_CHOICES) + parser.add_argument("--metadata", choices=_METADATA_CHOICES) + parser.add_argument("--metadata-file", type=Path) + parser.add_argument( + "--local-validation-only", + action="store_true", + help="Skip IAM Access Analyzer validation.", + ) + + +def _tag_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--tag", + action="append", + default=[], + metavar="KEY=VALUE", + help="Add an IAM tag; repeat for multiple tags.", + ) + + +def register(parser: argparse.ArgumentParser) -> None: + """Register the complete managed-policy command grammar.""" + actions = parser.add_subparsers(dest="policy_action") + + create = actions.add_parser( + "create", + aliases=["publish"], + help="Create a customer-managed policy without silently overwriting one.", + description=( + "Validate and publish a local policy document. If NAME is omitted, a " + "name is derived from the filename and naming configuration." + ), + epilog=( + "Examples:\n" + " hacksaws iam policy create agent-read.yaml --profile admin\n" + " hacksaws iam policy create agent-read.yaml AgentRead " + "--tag project=api --dry-run\n" + " hacksaws iam policy create agent-read.yaml AgentRead --replace --yes" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + create.set_defaults(policy_action="create") + create.add_argument( + "file", help="JSON, YAML, or TOML policy document, or '-' for stdin." + ) + create.add_argument( + "name", + nargs="?", + help="IAM policy name; defaults to a configured name derived from FILE.", + ) + create.add_argument("--description", help="Human-readable IAM policy description.") + create.add_argument( + "--path", + dest="iam_path", + help="IAM path prefix (default from Hacksaws configuration).", + ) + create.add_argument( + "--replace", + action="store_true", + help="Deliberately update an existing differing policy after preview.", + ) + _input_options(create) + _tag_options(create) + _selectors(create, mutation=True) + + listing = actions.add_parser("list", help="List managed policies.") + listing.add_argument("patterns", nargs="*") + scope = listing.add_mutually_exclusive_group() + scope.add_argument("--custom", action="store_true") + scope.add_argument("--aws", action="store_true") + scope.add_argument("--all", action="store_true") + width = listing.add_mutually_exclusive_group() + width.add_argument("--compact", action="store_true") + width.add_argument("--wide", action="store_true") + _selectors(listing) + + get = actions.add_parser("get", help="Show a managed policy.") + get.add_argument("policy") + _selectors(get) + + export = actions.add_parser("export", help="Export a policy document.") + export.add_argument("policy") + export.add_argument("output", nargs="?") + export.add_argument("--format", choices=_FORMAT_CHOICES) + export.add_argument("--metadata", choices=_METADATA_CHOICES, default="none") + export.add_argument("--metadata-file", type=Path) + export.add_argument("--all-versions", action="store_true") + _selectors(export) + + update = actions.add_parser("update", help="Publish a new policy version.") + update.add_argument("policy_or_file", nargs="?") + update.add_argument("file", nargs="?") + update.add_argument("--from-stored", metavar="NAME") + _input_options(update) + _selectors(update, mutation=True) + + edit = actions.add_parser("edit", help="Edit and publish a policy.") + edit.add_argument("policy") + edit.add_argument("--format", choices=_FORMAT_CHOICES, default="yaml") + edit.add_argument("--local-validation-only", action="store_true") + _selectors(edit, mutation=True) + + versions = actions.add_parser("versions", help="List retained versions.") + versions.add_argument("policy") + _selectors(versions) + + rollback = actions.add_parser("rollback", help="Select a retained version.") + rollback.add_argument("policy") + rollback.add_argument("version") + _selectors(rollback, mutation=True) + + delete = actions.add_parser( + "delete", aliases=["remove"], help="Delete a customer-managed policy." + ) + delete.set_defaults(policy_action="delete") + delete.add_argument("policy") + delete.add_argument("--cascade", action="store_true") + delete.add_argument( + "--remove-boundaries", + action="store_true", + help="Explicitly remove user/role permissions-boundary assignments.", + ) + delete.add_argument("--allow-unmanaged", action="store_true") + _selectors(delete, mutation=True) + + check = actions.add_parser("check", help="Validate a policy and optional role.") + check.add_argument("policy") + check.add_argument("--role") + check.add_argument("--local-validation-only", action="store_true") + _selectors(check) + + tag = actions.add_parser("tag", help="Manage customer-policy tags.") + tag_actions = tag.add_subparsers(dest="policy_tag_action") + tag_list = tag_actions.add_parser("list") + tag_list.add_argument("policy") + _selectors(tag_list) + tag_set = tag_actions.add_parser("set") + tag_set.add_argument("policy") + _tag_options(tag_set) + _selectors(tag_set, mutation=True) + tag_remove = tag_actions.add_parser("remove") + tag_remove.add_argument("policy") + tag_remove.add_argument("keys", nargs="+") + _selectors(tag_remove, mutation=True) + + adopt = actions.add_parser("adopt", help="Adopt a customer-managed policy.") + adopt.add_argument("policy") + _tag_options(adopt) + _selectors(adopt, mutation=True) + release = actions.add_parser("release", help="Release Hacksaws ownership tags.") + release.add_argument("policy") + _selectors(release, mutation=True) + + +def _service(context: IamCommandContext) -> IamManagedPolicyService: + config = _state.load_config() + owned_path = str(config.get("iam", {}).get("path", "/hacksaws/")) + return IamManagedPolicyService( + context.iam, + context.sts, + context.access_analyzer, + PolicyServiceOptions( + account_id=context.account_id, + partition=context.partition, + owned_path=owned_path, + ), + ) + + +def _aws_error_code(error: ClientError) -> str: + detail = error.response.get("Error", {}) + return str(detail.get("Code", "")) if isinstance(detail, dict) else "" + + +def _policy_exists(context: IamCommandContext, arn: str) -> bool: + try: + context.iam.get_policy(PolicyArn=arn) + except ClientError as error: + if _aws_error_code(error) == "NoSuchEntity": + return False + raise + return True + + +def _version_payload(item: PolicyVersionRecord) -> dict[str, object]: + document = item.document + if document is None: + raise PolicyServiceError( + "A durable policy snapshot requires every version document." + ) + return { + "id": item.version_id, + "default": item.is_default, + "document": document, + } + + +def _entity_payload(item: object) -> dict[str, str]: + entity = cast("EntityReference", item) + return {"type": entity.kind, "name": entity.name, "id": entity.entity_id} + + +def _dependency_payload( + dependencies: PolicyDependencies, +) -> dict[str, list[dict[str, str]]]: + return { + "permissionUsers": [ + _entity_payload(item) for item in dependencies.permission_users + ], + "permissionGroups": [ + _entity_payload(item) for item in dependencies.permission_groups + ], + "permissionRoles": [ + _entity_payload(item) for item in dependencies.permission_roles + ], + "boundaryUsers": [ + _entity_payload(item) for item in dependencies.boundary_users + ], + "boundaryRoles": [ + _entity_payload(item) for item in dependencies.boundary_roles + ], + } + + +def _policy_state( + item: ManagedPolicyRecord, + *, + dependencies: PolicyDependencies | None = None, + create_only: bool = False, +) -> dict[str, object]: + versions = [_version_payload(version) for version in item.versions] + if not versions and item.document is not None: + versions = [ + { + "id": item.default_version_id, + "default": True, + "document": item.document, + } + ] + state: dict[str, object] = { + "exists": True, + "arn": item.arn.value, + "policyId": item.policy_id, + "name": item.name, + "path": item.path, + "description": item.description, + "defaultVersionId": item.default_version_id, + "tags": [tag.as_request() for tag in item.tags], + "versions": versions, + "createOnly": create_only, + } + if dependencies is not None: + state["dependencies"] = _dependency_payload(dependencies) + return state + + +def _absent_state(arn: str, name_value: str, path: str) -> dict[str, object]: + return {"exists": False, "arn": arn, "name": name_value, "path": path} + + +def _state_versions(state: Mapping[str, object]) -> list[dict[str, object]]: + value = state.get("versions", []) + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise OperationalError("IAM recovery policy versions are invalid.") + return cast("list[dict[str, object]]", value) + + +def _state_tags(state: Mapping[str, object]) -> list[dict[str, str]]: + value = state.get("tags", []) + if not isinstance(value, list): + raise OperationalError("IAM recovery policy tags are invalid.") + result: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, dict) or not isinstance(item.get("Key"), str): + raise OperationalError("IAM recovery policy tags are invalid.") + result.append({"Key": item["Key"], "Value": str(item.get("Value", ""))}) + return result + + +_DEPENDENCY_TYPES = { + "permissionUsers": "User", + "permissionGroups": "Group", + "permissionRoles": "Role", + "boundaryUsers": "User", + "boundaryRoles": "Role", +} + + +def _state_dependencies( + state: Mapping[str, object], +) -> dict[str, list[dict[str, str]]]: + raw = state.get("dependencies", {}) + if not isinstance(raw, dict): + raise OperationalError("IAM recovery policy dependencies are invalid.") + result: dict[str, list[dict[str, str]]] = {} + for key, expected_type in _DEPENDENCY_TYPES.items(): + values = raw.get(key, []) + if not isinstance(values, list): + raise OperationalError("IAM recovery policy dependencies are invalid.") + parsed: list[dict[str, str]] = [] + for value in values: + if not isinstance(value, dict): + raise OperationalError( + "IAM recovery dependencies must retain principal identity IDs." + ) + kind = value.get("type") + name_value = value.get("name") + entity_id = value.get("id") + if ( + kind != expected_type + or not isinstance(name_value, str) + or not name_value + or not isinstance(entity_id, str) + or not entity_id + ): + raise OperationalError("IAM recovery policy dependencies are invalid.") + parsed.append({"type": kind, "name": name_value, "id": entity_id}) + result[key] = parsed + return result + + +def _normalized_dependencies( + state: Mapping[str, object], +) -> dict[str, list[tuple[str, str, str]]]: + return { + key: sorted((item["type"], item["name"], item["id"]) for item in values) + for key, values in _state_dependencies(state).items() + } + + +def _versions_match(live: Mapping[str, object], expected: Mapping[str, object]) -> bool: + observed = _state_versions(live) + wanted = _state_versions(expected) + if len(observed) != len(wanted): + return False + unused = list(observed) + for item in wanted: + identifier = item.get("id") + document = item.get("document") + default = item.get("default") is True + if not isinstance(identifier, str) or not isinstance(document, dict): + raise OperationalError("IAM recovery policy versions are invalid.") + match = next( + ( + candidate + for candidate in unused + if isinstance(candidate.get("document"), dict) + and candidate.get("default") is default + and policy_digest( + cast("Mapping[str, JsonValue]", candidate["document"]) + ) + == policy_digest(cast("Mapping[str, JsonValue]", document)) + and ( + identifier.startswith("pending") + or candidate.get("id") == identifier + ) + ), + None, + ) + if match is None: + return False + unused.remove(match) + return True + + +def _version_matches( + observed: Mapping[str, object], expected: Mapping[str, object] +) -> bool: + observed_document = observed.get("document") + expected_document = expected.get("document") + identifier = expected.get("id") + return ( + isinstance(observed_document, dict) + and isinstance(expected_document, dict) + and isinstance(identifier, str) + and observed.get("default") is expected.get("default") + and policy_digest(cast("Mapping[str, JsonValue]", observed_document)) + == policy_digest(cast("Mapping[str, JsonValue]", expected_document)) + and (identifier.startswith("pending") or observed.get("id") == identifier) + ) + + +def _versions_subset(live: Mapping[str, object], allowed: Mapping[str, object]) -> bool: + remaining = list(_state_versions(allowed)) + for observed in _state_versions(live): + match = next( + (item for item in remaining if _version_matches(observed, item)), None + ) + if match is None: + return False + remaining.remove(match) + return True + + +def _dependency_sets( + state: Mapping[str, object], +) -> dict[str, set[tuple[str, str, str]]]: + return {key: set(values) for key, values in _normalized_dependencies(state).items()} + + +def _dependencies_subset( + live: Mapping[str, object], allowed: Mapping[str, object] +) -> bool: + observed = _dependency_sets(live) + wanted = _dependency_sets(allowed) + return all(observed[key] <= wanted[key] for key in _DEPENDENCY_TYPES) + + +def _identity_matches( + live: Mapping[str, object], expected: Mapping[str, object] +) -> bool: + if live.get("exists") is not True or expected.get("exists") is not True: + return False + for key in ("arn", "name", "path", "description"): + if live.get(key) != expected.get(key): + return False + policy_id = expected.get("policyId") + return not ( + isinstance(policy_id, str) + and not policy_id.startswith("pending") + and live.get("policyId") != policy_id + ) + + +def _tags_match(live: Mapping[str, object], expected: Mapping[str, object]) -> bool: + return sorted((item["Key"], item["Value"]) for item in _state_tags(live)) == sorted( + (item["Key"], item["Value"]) for item in _state_tags(expected) + ) + + +def _valid_delete_checkpoint( + live: Mapping[str, object], expected: Mapping[str, object] +) -> bool: + return any(_states_match(live, state) for state in _delete_stages(expected)) + + +def _valid_restore_checkpoint( + live: Mapping[str, object], target: Mapping[str, object] +) -> bool: + return any(_states_match(live, state) for state in _restore_stages(target)) + + +def _valid_existing_checkpoint( + live: Mapping[str, object], + expected: Mapping[str, object], + target: Mapping[str, object], +) -> bool: + return any( + _states_match(live, state) for state in _existing_stages(expected, target) + ) + + +def _valid_transition_checkpoint( + live: Mapping[str, object], + expected: Mapping[str, object], + target: Mapping[str, object], +) -> bool: + expected_exists = expected.get("exists") is True + target_exists = target.get("exists") is True + if expected_exists and not target_exists: + return _valid_delete_checkpoint(live, expected) + if not expected_exists and target_exists: + return _valid_restore_checkpoint(live, target) + if expected_exists and target_exists: + return _valid_existing_checkpoint(live, expected, target) + return False + + +def _states_match(live: Mapping[str, object], expected: Mapping[str, object]) -> bool: + if live.get("arn") != expected.get("arn"): + return False + live_exists = live.get("exists") is True + expected_exists = expected.get("exists") is True + if live_exists != expected_exists: + return False + if not expected_exists: + return True + if not _identity_matches(live, expected) or not _tags_match(live, expected): + return False + return _versions_match(live, expected) and _normalized_dependencies( + live + ) == _normalized_dependencies(expected) + + +def _clone_state(state: Mapping[str, object]) -> dict[str, object]: + return cast("dict[str, object]", json.loads(json.dumps(state))) + + +def _document_digest(version: Mapping[str, object]) -> str: + document = version.get("document") + if not isinstance(document, dict): + raise OperationalError("IAM recovery policy versions are invalid.") + return policy_digest(cast("Mapping[str, JsonValue]", document)) + + +def _delete_stages(expected: Mapping[str, object]) -> list[dict[str, object]]: + current = _clone_state(expected) + stages: list[dict[str, object]] = [] + dependencies = _state_dependencies(current) + for kind in _DEPENDENCY_TYPES: + while dependencies[kind]: + dependencies[kind].pop(0) + current["dependencies"] = { + key: [dict(item) for item in values] + for key, values in dependencies.items() + } + stages.append(_clone_state(current)) + for version in list(_state_versions(current)): + if version.get("default") is True: + continue + current["versions"] = [ + item + for item in _state_versions(current) + if item.get("id") != version.get("id") + ] + stages.append(_clone_state(current)) + return stages + + +def _restore_stages(target: Mapping[str, object]) -> list[dict[str, object]]: + desired_versions = _state_versions(target) + default = next( + (item for item in desired_versions if item.get("default") is True), None + ) + if default is None: + return [] + current = _clone_state(target) + current["versions"] = [dict(default)] + current["dependencies"] = _dependency_payload(PolicyDependencies()) + stages = [_clone_state(current)] + for version in desired_versions: + if version.get("default") is True: + continue + current["versions"] = [*_state_versions(current), dict(version)] + stages.append(_clone_state(current)) + desired_dependencies = _state_dependencies(target) + current_dependencies = _state_dependencies(current) + for kind in _DEPENDENCY_TYPES: + for item in desired_dependencies[kind]: + current_dependencies[kind].append(dict(item)) + current["dependencies"] = { + key: [dict(value) for value in values] + for key, values in current_dependencies.items() + } + stages.append(_clone_state(current)) + return stages + + +def _prune_stage_version( + versions: list[dict[str, object]], desired_digests: set[str] +) -> dict[str, object] | None: + candidate = next( + ( + version + for version in versions + if version.get("default") is not True + and _document_digest(version) not in desired_digests + ), + None, + ) + return candidate or next( + (version for version in versions if version.get("default") is not True), None + ) + + +def _existing_stages( + expected: Mapping[str, object], target: Mapping[str, object] +) -> list[dict[str, object]]: + current = _clone_state(expected) + stages: list[dict[str, object]] = [] + desired_versions = _state_versions(target) + desired_digests = {_document_digest(item) for item in desired_versions} + default = next( + (item for item in desired_versions if item.get("default") is True), None + ) + if default is None: + return stages + default_digest = _document_digest(default) + versions = _state_versions(current) + selected = next( + (item for item in versions if _document_digest(item) == default_digest), None + ) + if selected is None: + if len(versions) >= _MAX_POLICY_VERSIONS: + candidate = _prune_stage_version(versions, desired_digests) + if candidate is None: + return stages + versions = [item for item in versions if item is not candidate] + current["versions"] = versions + stages.append(_clone_state(current)) + for item in versions: + item["default"] = False + selected = dict(default) + selected["id"] = str(default.get("id", "pending:default")) + selected["default"] = True + versions.append(selected) + current["versions"] = versions + stages.append(_clone_state(current)) + elif selected.get("default") is not True: + for item in versions: + item["default"] = item is selected + stages.append(_clone_state(current)) + existing_digests = {_document_digest(item) for item in versions} + for desired in desired_versions: + digest = _document_digest(desired) + if digest in existing_digests: + continue + if len(versions) >= _MAX_POLICY_VERSIONS: + candidate = _prune_stage_version(versions, desired_digests) + if candidate is None: + return stages + versions = [item for item in versions if item is not candidate] + current["versions"] = versions + stages.append(_clone_state(current)) + addition = dict(desired) + addition["default"] = False + versions.append(addition) + current["versions"] = versions + existing_digests.add(digest) + stages.append(_clone_state(current)) + for version in list(versions): + if version.get("default") is True: + continue + if _document_digest(version) not in desired_digests: + versions = [item for item in versions if item is not version] + current["versions"] = versions + stages.append(_clone_state(current)) + observed_tags = {item["Key"]: item["Value"] for item in _state_tags(current)} + wanted_tags = {item["Key"]: item["Value"] for item in _state_tags(target)} + additions = { + key: value + for key, value in wanted_tags.items() + if observed_tags.get(key) != value + } + if additions: + observed_tags.update(additions) + current["tags"] = [ + {"Key": key, "Value": value} for key, value in sorted(observed_tags.items()) + ] + stages.append(_clone_state(current)) + removals = [key for key in observed_tags if key not in wanted_tags] + if removals: + for key in removals: + observed_tags.pop(key) + current["tags"] = [ + {"Key": key, "Value": value} for key, value in sorted(observed_tags.items()) + ] + stages.append(_clone_state(current)) + return stages + + +def _transition_stages( + expected: Mapping[str, object], target: Mapping[str, object] +) -> list[dict[str, object]]: + expected_exists = expected.get("exists") is True + target_exists = target.get("exists") is True + if expected_exists and not target_exists: + return _delete_stages(expected) + if not expected_exists and target_exists: + return _restore_stages(target) + if expected_exists and target_exists: + return _existing_stages(expected, target) + return [] + + +def _live_policy_state( + context: IamCommandContext, service: IamManagedPolicyService, arn: str +) -> dict[str, object]: + if not _policy_exists(context, arn): + parsed = ManagedPolicyArn.parse(arn) + return _absent_state(arn, parsed.name, parsed.path) + item = service.get_policy( + arn, include_document=True, include_versions=True, include_tags=True + ) + return _policy_state(item, dependencies=service.policy_dependencies(arn)) + + +def _delete_live_policy( + context: IamCommandContext, + service: IamManagedPolicyService, + arn: str, + expected: Mapping[str, object], + checkpoints: Sequence[Mapping[str, object]] = (), +) -> None: + if not _policy_exists(context, arn): + return + live = _live_policy_state(context, service, arn) + if not _states_match(live, expected) and not any( + _states_match(live, checkpoint) for checkpoint in checkpoints + ): + raise PolicyDriftError( + "Managed policy identity, versions, tags, or dependencies changed " + "before deletion; no destructive recovery action was taken." + ) + dependencies = _state_dependencies(expected) + present = _dependency_sets(live) + calls = { + "permissionUsers": lambda item: context.iam.detach_user_policy( + UserName=item["name"], PolicyArn=arn + ), + "permissionGroups": lambda item: context.iam.detach_group_policy( + GroupName=item["name"], PolicyArn=arn + ), + "permissionRoles": lambda item: context.iam.detach_role_policy( + RoleName=item["name"], PolicyArn=arn + ), + "boundaryUsers": lambda item: context.iam.delete_user_permissions_boundary( + UserName=item["name"] + ), + "boundaryRoles": lambda item: context.iam.delete_role_permissions_boundary( + RoleName=item["name"] + ), + } + for kind, callback in calls.items(): + for item in dependencies[kind]: + identity = (item["type"], item["name"], item["id"]) + if identity in present[kind]: + callback(item) + present_versions = {item.get("id") for item in _state_versions(live)} + for version in _state_versions(expected): + if version.get("default") is not True and version.get("id") in present_versions: + context.iam.delete_policy_version( + PolicyArn=arn, VersionId=str(version["id"]) + ) + context.iam.delete_policy(PolicyArn=arn) + + +def _ensure_version_capacity( + context: IamCommandContext, + current: ManagedPolicyRecord, + desired_digests: set[str], +) -> None: + if len(current.versions) < _MAX_POLICY_VERSIONS: + return + candidate = next( + ( + version + for version in current.versions + if not version.is_default + and version.document is not None + and policy_digest(version.document) not in desired_digests + ), + None, + ) + if candidate is None: + candidate = next( + (version for version in current.versions if not version.is_default), None + ) + if candidate is None: + raise OperationalError("No nondefault managed-policy version can be pruned.") + context.iam.delete_policy_version( + PolicyArn=current.arn.value, VersionId=candidate.version_id + ) + + +def _restore_dependencies( + context: IamCommandContext, + service: IamManagedPolicyService, + arn: str, + raw: object, +) -> None: + if not isinstance(raw, dict): + return + desired = _state_dependencies({"dependencies": raw}) + current = service.policy_dependencies(arn) + present = { + "permissionUsers": { + (item.name, item.entity_id) for item in current.permission_users + }, + "permissionGroups": { + (item.name, item.entity_id) for item in current.permission_groups + }, + "permissionRoles": { + (item.name, item.entity_id) for item in current.permission_roles + }, + "boundaryUsers": { + (item.name, item.entity_id) for item in current.boundary_users + }, + "boundaryRoles": { + (item.name, item.entity_id) for item in current.boundary_roles + }, + } + calls = { + "permissionUsers": lambda value: context.iam.attach_user_policy( + UserName=value, PolicyArn=arn + ), + "permissionGroups": lambda value: context.iam.attach_group_policy( + GroupName=value, PolicyArn=arn + ), + "permissionRoles": lambda value: context.iam.attach_role_policy( + RoleName=value, PolicyArn=arn + ), + "boundaryUsers": lambda value: context.iam.put_user_permissions_boundary( + UserName=value, PermissionsBoundary=arn + ), + "boundaryRoles": lambda value: context.iam.put_role_permissions_boundary( + RoleName=value, PermissionsBoundary=arn + ), + } + for kind, callback in calls.items(): + for value in desired[kind]: + identity = (value["name"], value["id"]) + if identity in present[kind]: + continue + _verify_principal_identity( + context, value["type"], value["name"], value["id"] + ) + callback(value["name"]) + + +def _verify_principal_identity( + context: IamCommandContext, + kind: str, + name_value: str, + expected_id: str, +) -> None: + requests = { + "User": (context.iam.get_user, "UserName", "User", "UserId"), + "Group": (context.iam.get_group, "GroupName", "Group", "GroupId"), + "Role": (context.iam.get_role, "RoleName", "Role", "RoleId"), + } + request, name_key, response_key, id_key = requests[kind] + try: + response = request(**{name_key: name_value}) + except ClientError as error: + if _aws_error_code(error) == "NoSuchEntity": + raise PolicyDriftError( + f"Cannot restore dependency for missing {kind.lower()} " + f"{name_value!r}; manual recovery is required." + ) from error + raise + entity = response.get(response_key, {}) + observed_id = entity.get(id_key) if isinstance(entity, dict) else None + if observed_id != expected_id: + raise PolicyDriftError( + f"Cannot restore dependency for {kind.lower()} {name_value!r}: " + "the same name now identifies a different IAM principal; manual " + "recovery is required." + ) + + +def _effect_policy_id(payload: Mapping[str, object]) -> str | None: + effect = payload.get("effect") + if effect is None: + return None + if not isinstance(effect, dict) or not isinstance(effect.get("policyId"), str): + raise OperationalError("IAM recovery policy identity receipt is invalid.") + return cast("str", effect["policyId"]) + + +def _payload_checkpoints( + payload: Mapping[str, object], policy_id: str | None +) -> list[dict[str, object]]: + raw = payload.get("checkpoints", []) + if not isinstance(raw, list) or not all(isinstance(item, dict) for item in raw): + raise OperationalError("IAM recovery policy checkpoints are invalid.") + return [_bind_policy_id(cast("dict[str, object]", item), policy_id) for item in raw] + + +def _bind_policy_id( + state: Mapping[str, object], policy_id: str | None +) -> dict[str, object]: + result = dict(state) + if ( + policy_id is not None + and result.get("exists") is True + and isinstance(result.get("policyId"), str) + and str(result["policyId"]).startswith("pending") + ): + result["policyId"] = policy_id + return result + + +def _created_base_state(target: Mapping[str, object]) -> dict[str, object]: + versions = _state_versions(target) + default = next((item for item in versions if item.get("default") is True), None) + if default is None: + raise OperationalError("IAM recovery create state has no default document.") + base = dict(target) + base["policyId"] = "pending:create-receipt" + base["defaultVersionId"] = "pending:create-default" + base["versions"] = [{**default, "id": "pending:create-default", "default": True}] + base["dependencies"] = _dependency_payload(PolicyDependencies()) + return base + + +def _create_policy_with_receipt( + payload: Mapping[str, object], raw_context: object +) -> Mapping[str, object] | None: + context = cast("IamCommandContext", raw_context) + target = payload.get("target") + if not isinstance(target, dict): + raise OperationalError("IAM recovery create payload is invalid.") + arn = str(target.get("arn", "")) + parsed = ManagedPolicyArn.parse(arn) + if parsed.partition != context.partition or parsed.account_id != context.account_id: + raise OperationalError( + "Recovery policy ARN does not match selected credentials." + ) + if _policy_exists(context, arn): + raise PolicyDriftError( + "A policy exists at the create target but this journal has no durable " + "AWS PolicyId receipt proving it created that policy; preserve it and " + "recover manually." + ) + versions = _state_versions(target) + default = next((item for item in versions if item.get("default") is True), None) + if default is None or not isinstance(default.get("document"), dict): + raise OperationalError("IAM recovery create state has no default document.") + request: dict[str, object] = { + "PolicyName": str(target["name"]), + "Path": str(target["path"]), + "PolicyDocument": canonical_policy_json( + cast("Mapping[str, JsonValue]", default["document"]) + ), + "Tags": _state_tags(target), + } + if target.get("description") is not None: + request["Description"] = str(target["description"]) + response = context.iam.create_policy(**request) + policy = response.get("Policy", {}) + policy_id = policy.get("PolicyId") if isinstance(policy, dict) else None + if not isinstance(policy_id, str) or not policy_id: + raise OperationalError( + "AWS created the policy without returning a PolicyId; the journal " + "cannot bind destructive compensation and requires manual recovery." + ) + return {"policyId": policy_id} + + +def _delete_created_policy_with_receipt( + payload: Mapping[str, object], raw_context: object +) -> None: + context = cast("IamCommandContext", raw_context) + expected = payload.get("expected") + target = payload.get("target") + policy_id = _effect_policy_id(payload) + if ( + not isinstance(expected, dict) + or not isinstance(target, dict) + or policy_id is None + ): + raise OperationalError( + "Create rollback has no durable AWS PolicyId receipt; preserve any " + "policy at the target ARN and recover manually." + ) + expected = _bind_policy_id(expected, policy_id) + arn = str(expected.get("arn", "")) + service = _service(context) + if not _policy_exists(context, arn): + return + _delete_live_policy(context, service, arn, expected) + + +def _reconcile_policy(payload: Mapping[str, object], raw_context: object) -> None: + context = cast("IamCommandContext", raw_context) + raw_expected = payload.get("expected") + raw_target = payload.get("target") + if not isinstance(raw_expected, dict) or not isinstance(raw_target, dict): + raise OperationalError( + "IAM recovery reconciliation requires exact expected and target states." + ) + policy_id = _effect_policy_id(payload) + expected = _bind_policy_id(raw_expected, policy_id) + target = _bind_policy_id(raw_target, policy_id) + checkpoints = _payload_checkpoints(payload, policy_id) + arn = str(target.get("arn", "")) + if expected.get("arn") != arn: + raise OperationalError("IAM recovery state ARNs do not match.") + parsed = ManagedPolicyArn.parse(arn) + if parsed.partition != context.partition or parsed.account_id != context.account_id: + raise OperationalError( + "Recovery policy ARN does not match selected credentials." + ) + service = _service(context) + live = _live_policy_state(context, service, arn) + if _states_match(live, target): + return + if not _states_match(live, expected) and not any( + _states_match(live, checkpoint) for checkpoint in checkpoints + ): + raise PolicyDriftError( + "Managed policy recovery found state that is neither the exact " + "expected predecessor, an exact recovery checkpoint, nor the exact " + "intended result; no mutation was attempted." + ) + if ( + expected.get("exists") is not True + and target.get("exists") is True + and live.get("exists") is not True + ): + raise PolicyDriftError( + "Managed policy deletion already crossed the irreversible AWS PolicyId " + "commit point. Hacksaws will not recreate a same-named policy or restore " + "dependencies to a different identity; rebuild it manually if required." + ) + if target.get("exists") is not True: + _delete_live_policy(context, service, arn, expected, checkpoints) + return + versions = _state_versions(target) + default = next((item for item in versions if item.get("default") is True), None) + if default is None or not isinstance(default.get("document"), dict): + raise OperationalError("IAM recovery policy state has no default document.") + desired_tags = _state_tags(target) + exists = _policy_exists(context, arn) + if exists and target.get("createOnly") is True: + current = service.get_policy( + arn, include_document=False, include_versions=False, include_tags=True + ) + wanted = {item["Key"]: item["Value"] for item in desired_tags} + observed = {tag.key: tag.value for tag in current.tags} + resource_id = wanted.get("hacksaws:resource-id") + if not resource_id or observed.get("hacksaws:resource-id") != resource_id: + raise OperationalError( + "Create recovery found a different policy at the target ARN." + ) + if not exists: + raise PolicyDriftError( + "Managed policy recovery cannot recreate a deleted IAM policy identity; " + "manual recovery is required." + ) + current = service.get_policy( + arn, include_document=True, include_versions=True, include_tags=True + ) + desired_documents = [ + cast("dict[str, JsonValue]", item["document"]) + for item in versions + if isinstance(item.get("document"), dict) + ] + desired_digests = {policy_digest(item) for item in desired_documents} + default_document = cast("dict[str, JsonValue]", default["document"]) + default_digest = policy_digest(default_document) + matches = { + policy_digest(version.document): version + for version in current.versions + if version.document is not None + } + selected = matches.get(default_digest) + if selected is None: + _ensure_version_capacity(context, current, desired_digests) + response = context.iam.create_policy_version( + PolicyArn=arn, + PolicyDocument=canonical_policy_json(default_document), + SetAsDefault=True, + ) + selected_id = str(response["PolicyVersion"]["VersionId"]) + else: + selected_id = selected.version_id + if not selected.is_default: + context.iam.set_default_policy_version( + PolicyArn=arn, VersionId=selected.version_id + ) + current = service.get_policy( + arn, include_document=True, include_versions=True, include_tags=True + ) + existing_digests = { + policy_digest(version.document) + for version in current.versions + if version.document is not None + } + for document in desired_documents: + digest = policy_digest(document) + if digest in existing_digests: + continue + _ensure_version_capacity(context, current, desired_digests) + context.iam.create_policy_version( + PolicyArn=arn, + PolicyDocument=canonical_policy_json(document), + SetAsDefault=False, + ) + existing_digests.add(digest) + current = service.get_policy( + arn, include_document=True, include_versions=True, include_tags=True + ) + for version in current.versions: + if ( + version.version_id == selected_id + or version.is_default + or version.document is None + ): + continue + if policy_digest(version.document) not in desired_digests: + context.iam.delete_policy_version( + PolicyArn=arn, VersionId=version.version_id + ) + observed_tags = {tag.key: tag.value for tag in current.tags} + wanted_tags = {item["Key"]: item["Value"] for item in desired_tags} + additions = [ + {"Key": key, "Value": value} + for key, value in wanted_tags.items() + if observed_tags.get(key) != value + ] + removals = [key for key in observed_tags if key not in wanted_tags] + if additions: + context.iam.tag_policy(PolicyArn=arn, Tags=additions) + if removals: + context.iam.untag_policy(PolicyArn=arn, TagKeys=removals) + _restore_dependencies(context, service, arn, target.get("dependencies")) + final = _live_policy_state(context, service, arn) + if not _states_match(final, target): + raise PolicyDriftError( + "Managed policy recovery did not reach its exact intended state; " + "manual recovery is required." + ) + + +def ensure_recovery_handlers() -> None: + """Register the policy adapter's idempotent durable state reconciler.""" + with contextlib.suppress(ValueError): + _iam_recovery.register_handler( + "policy", + "reconcile", + forward=_reconcile_policy, + compensate=_reconcile_policy, + ) + with contextlib.suppress(ValueError): + _iam_recovery.register_handler( + "policy", + "create-policy", + forward=_create_policy_with_receipt, + compensate=_delete_created_policy_with_receipt, + ) + + +def _replacement_target(state: Mapping[str, object]) -> dict[str, object]: + result = dict(state) + if result.get("exists") is not True: + return result + result["policyId"] = "pending:replacement" + versions: list[dict[str, object]] = [] + for index, version in enumerate(_state_versions(result)): + replacement = dict(version) + replacement["id"] = f"pending:replacement:{index}" + versions.append(replacement) + result["versions"] = versions + result["defaultVersionId"] = "pending:replacement" + return result + + +def _partial_delete_restore_target( + state: Mapping[str, object], +) -> dict[str, object]: + result = dict(state) + versions: list[dict[str, object]] = [] + for index, version in enumerate(_state_versions(result)): + replacement = dict(version) + if replacement.get("default") is not True: + replacement["id"] = f"pending:restore:{index}" + versions.append(replacement) + result["versions"] = versions + return result + + +def _recovery_payload( + expected: Mapping[str, object], + target: Mapping[str, object], + *, + include_reverse_checkpoints: bool = False, +) -> dict[str, object]: + intended = dict(target) + if expected.get("exists") is not True and target.get("exists") is True: + intended = _partial_delete_restore_target(target) + elif expected.get("exists") is True and target.get("exists") is True: + expected_ids = { + item.get("id") + for item in _state_versions(expected) + if isinstance(item.get("id"), str) + and not str(item.get("id")).startswith("pending") + } + versions: list[dict[str, object]] = [] + for index, version in enumerate(_state_versions(intended)): + replacement = dict(version) + identifier = replacement.get("id") + if ( + isinstance(identifier, str) + and not identifier.startswith("pending") + and identifier not in expected_ids + ): + replacement["id"] = f"pending:restore:{index}" + versions.append(replacement) + intended["versions"] = versions + predecessor = dict(expected) + checkpoints = _transition_stages(predecessor, intended) + if include_reverse_checkpoints: + checkpoints.extend(_transition_stages(intended, predecessor)) + return { + "expected": predecessor, + "target": intended, + "checkpoints": checkpoints, + } + + +def _durable_reconcile( + context: IamCommandContext, + operation: str, + forward: Mapping[str, object], + compensation: Mapping[str, object], +) -> str: + ensure_recovery_handlers() + journal = _iam_recovery.begin_journal( + "policy", context.account_id, operation, partition=context.partition + ) + if compensation.get("exists") is not True and forward.get("exists") is True: + created = _created_base_state(forward) + create_step = journal.record_before_mutation( + "create-policy", + forward={"target": created}, + compensation={ + "expected": created, + "target": compensation, + "effectSourceStep": "self", + }, + ) + journal.record_before_mutation( + "reconcile", + forward={ + **_recovery_payload(created, forward), + "effectSourceStep": create_step, + }, + compensation={ + **_recovery_payload(forward, created, include_reverse_checkpoints=True), + "effectSourceStep": create_step, + }, + ) + _iam_recovery.continue_journal(journal.id, context) + return journal.id + journal.record_before_mutation( + "reconcile", + forward=_recovery_payload(compensation, forward), + compensation=_recovery_payload( + forward, compensation, include_reverse_checkpoints=True + ), + ) + _iam_recovery.continue_journal(journal.id, context) + return journal.id + + +def _error( + code: str, message: str, exit_code: int = _configs.EXIT_ERROR +) -> _configs.Result: + return _configs.Result(code, message, exit_code, "stderr") + + +def _table( + columns: Sequence[str], + rows: Iterable[Sequence[object]], + *, + legend: Sequence[tuple[str, str]] = (), +) -> str: + target = StringIO() + console = Console(file=target, color_system=None, force_terminal=False, width=160) + console.print(_output.compact_table(columns, rows)) + if legend: + console.print(_output.legend(legend)) + return target.getvalue().rstrip() + + +def _tags(values: Sequence[str]) -> tuple[Tag, ...]: + result: list[Tag] = [] + seen: set[str] = set() + for value in values: + key, separator, item = value.partition("=") + if not separator or not key: + raise PolicyInputError(f"Tag {value!r} must use KEY=VALUE syntax.") + folded = key.casefold() + if folded in seen: + raise PolicyInputError(f"Tag key {key!r} was supplied more than once.") + seen.add(folded) + result.append(Tag(key, item)) + return tuple(result) + + +def _metadata_mode(args: argparse.Namespace) -> MetadataMode: + raw = getattr(args, "metadata", None) + if raw is not None: + return MetadataMode(raw) + if getattr(args, "metadata_file", None) is not None: + return MetadataMode.SIDECAR + return MetadataMode.NONE + + +def _load_from_file(args: argparse.Namespace, source: str) -> LoadedPolicyInput: + selected_format = getattr(args, "format", None) + mode = _metadata_mode(args) + if source != "-": + path = Path(source).expanduser() + if selected_format and path.suffix.casefold() not in { + f".{selected_format}", + ".yml" if selected_format == "yaml" else "", + }: + with tempfile.TemporaryDirectory(prefix="hacksaws-policy-") as directory: + staged = Path(directory) / f"input.{selected_format}" + staged.write_bytes(path.read_bytes()) + return load_policy_input( + staged, + metadata_mode=mode, + sidecar=getattr(args, "metadata_file", None), + ) + return load_policy_input( + path, + metadata_mode=mode, + sidecar=getattr(args, "metadata_file", None), + ) + if selected_format is None: + raise PolicyInputError("Policy input from stdin requires --format.") + if mode is MetadataMode.SIDECAR and getattr(args, "metadata_file", None) is None: + raise PolicyInputError( + "Policy input from stdin with sidecar metadata requires --metadata-file." + ) + source_stream = sys.stdin + payload = source_stream.read() + if not isinstance(payload, str): + payload = payload.decode("utf-8") + with tempfile.TemporaryDirectory(prefix="hacksaws-policy-") as directory: + staged = Path(directory) / f"stdin.{selected_format}" + staged.write_text(payload, encoding="utf-8") + return load_policy_input( + staged, + metadata_mode=mode, + sidecar=getattr(args, "metadata_file", None), + ) + + +def _stored_policy(stored_name: str) -> LoadedPolicyInput: + data = _state.load_config() + _, metadata = _state.get_resource(data, "policy", stored_name) + path = _state.root() / str(metadata["file"]) + document, _ = _policies.parse_policy(path) + return LoadedPolicyInput( + document=document, + metadata=InputMetadata( + name=stored_name, description=metadata.get("description") + ), + source=path, + source_format=PolicyFormat.YAML, + ) + + +def _loaded_update(args: argparse.Namespace) -> tuple[str, LoadedPolicyInput]: + if args.from_stored: + loaded = _stored_policy(args.from_stored) + reference = args.policy_or_file or loaded.metadata.name + if args.file is not None: + raise PolicyInputError( + "--from-stored cannot be combined with a policy file." + ) + elif args.file is not None: + reference = args.policy_or_file + loaded = _load_from_file(args, args.file) + elif args.policy_or_file is not None: + loaded = _load_from_file(args, args.policy_or_file) + reference = loaded.metadata.name + else: + raise PolicyInputError( + "Update requires POLICY FILE, metadata-bearing FILE, or --from-stored NAME." + ) + if not reference: + raise PolicyInputError( + "Policy reference is missing; supply POLICY or metadata.name." + ) + return reference, loaded + + +def _words(value: str) -> list[str]: + return [ + part + for part in re.split(r"[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])", value) + if part + ] + + +def _named(value: str, settings: Mapping[str, object]) -> str: + words = _words(value) + kind = str(settings.get("case", "Pascal")).casefold() + if kind == "camel": + core = ( + (words[0].lower() + "".join(word.capitalize() for word in words[1:])) + if words + else "" + ) + elif kind == "snake": + core = "_".join(word.lower() for word in words) + elif kind == "kebab": + core = "-".join(word.lower() for word in words) + else: + core = "".join(word.capitalize() for word in words) + return f"{settings.get('prefix', '')}{core}{settings.get('suffix', '')}" + + +def _create_name( + args: argparse.Namespace, loaded: LoadedPolicyInput +) -> tuple[str, list[str]]: + base = args.name or loaded.metadata.name + if base is None: + if args.file == "-": + raise PolicyInputError( + "Policy input from stdin requires NAME or metadata.name." + ) + base = Path(args.file).stem + config = _state.load_config() + settings = _state.resolve_naming( + config, resource="policy", account=getattr(args, "account", None) + ) + generated = _named(base, settings) + warnings: list[str] = [] + if args.name and generated != args.name: + enforcement = str(settings.get("enforcement", "off")) + message = ( + f"Explicit policy name {args.name!r} does not match configured " + f"name {generated!r}." + ) + if enforcement == "error": + raise PolicyInputError(message) + if enforcement == "warn": + warnings.append(message) + return args.name, warnings + if ( + args.name is None + and loaded.metadata.name is None + and not _configs.json_output_enabled() + and bool(getattr(sys.stdin, "isatty", lambda: False)()) + ): + choice = ( + input( + f"Suggested IAM policy name: {generated}\n" + "[A]ccept, [E]dit, or [C]ancel\n> " + ) + .strip() + .casefold() + ) + if choice in {"c", "cancel"}: + raise PolicyInputError( + "Policy creation cancelled; no AWS changes were made." + ) + if choice in {"e", "edit"}: + edited = input("IAM policy name\n> ").strip() + if not edited: + raise PolicyInputError("Policy name cannot be empty.") + return edited, warnings + if choice not in {"", "a", "accept"}: + raise PolicyInputError( + "Choose Accept, Edit, or Cancel; no AWS changes were made." + ) + return generated, warnings + + +def _select(service: IamManagedPolicyService, reference: str) -> ManagedPolicyRecord: + result = service.resolve(reference) + if not result.candidates: + raise PolicyServiceError(f"Policy reference {reference!r} was not found.") + if len(result.candidates) == 1: + return result.candidates[0] + if _configs.json_output_enabled() or not bool( + getattr(sys.stdin, "isatty", lambda: False)() + ): + choices = ", ".join(item.arn.value for item in result.candidates) + raise PolicyServiceError( + f"Policy reference {reference!r} is ambiguous: {choices}." + ) + rendered = "\n".join( + f" {index}. {item.arn.value}" + for index, item in enumerate(result.candidates, 1) + ) + answer = input( + f"Policy reference {reference!r} is ambiguous:\n{rendered}\nSelect number: " + ).strip() + if not answer.isdecimal() or not 1 <= int(answer) <= len(result.candidates): + raise PolicyServiceError("No valid policy selection was made.") + return result.candidates[int(answer) - 1] + + +def _reference(service: IamManagedPolicyService, value: str) -> str: + return _select(service, value).arn.value + + +def _confirm(args: argparse.Namespace, prompt: str) -> bool: + if bool(getattr(args, "yes", False)): + return True + if _configs.json_output_enabled() or not bool( + getattr(sys.stdin, "isatty", lambda: False)() + ): + return False + return ( + input(f"{prompt}\n\nType exactly 'yes' to continue:\n> ").strip().casefold() + == "yes" + ) + + +def _confirm_exact(args: argparse.Namespace, prompt: str, expected: str) -> bool: + if bool(getattr(args, "yes", False)): + return True + if _configs.json_output_enabled() or not bool( + getattr(sys.stdin, "isatty", lambda: False)() + ): + return False + return ( + input(f"{prompt}\n\nType exactly {expected!r} to continue:\n> ").strip() + == expected + ) + + +def _diagnostic_data(report: ValidationReport) -> list[dict[str, object]]: + return [ + { + "severity": item.severity.value, + "code": item.code, + "message": item.message, + "field": item.field, + "repair": asdict(item.repair) if item.repair else None, + } + for item in report.diagnostics + ] + + +def _diagnostic_text(report: ValidationReport) -> str: + return "\n".join( + f"{item.severity.value.upper()} {item.code}: {item.message}" + + (f" Repair: {item.repair.message}" if item.repair else "") + for item in report.diagnostics + ) + + +def _semantic_diff( + service: IamManagedPolicyService, plan: PolicyChangePlan +) -> list[str]: + if plan.policy_arn is None or plan.operation.action not in { + ChangeAction.UPDATE, + ChangeAction.ROLLBACK, + }: + return [] + current = service.get_policy( + plan.policy_arn.value, + include_document=True, + include_versions=False, + include_tags=False, + ) + if current.document is None: + return [] + before = json.dumps(current.document, indent=2, sort_keys=True).splitlines() + after = json.dumps(plan.document, indent=2, sort_keys=True).splitlines() + return list( + difflib.unified_diff( + before, + after, + fromfile=f"{current.default_version_id} (current)", + tofile="proposed", + lineterm="", + ) + ) + + +def _change_preview( + plan: PolicyChangePlan, + context: IamCommandContext, + diagnostics: list[dict[str, object]], + warnings: list[str], + diff: list[str], +) -> dict[str, object]: + arn = ( + plan.policy_arn.value + if plan.policy_arn is not None + else ( + f"arn:{context.partition}:iam::{context.account_id}:policy" + f"{plan.path}{plan.name}" + ) + ) + minified = canonical_policy_json(plan.document) + return { + "classification": ( + "no-change" if plan.operation.action is ChangeAction.NOOP else "planned" + ), + "action": plan.operation.action.value, + "accountId": context.account_id, + "callerArn": getattr(context, "arn", None), + "name": plan.name, + "path": plan.path, + "arn": arn, + "documentSha256": policy_digest(plan.document), + "minifiedBytes": len(minified.encode("utf-8")), + "tags": {tag.key: tag.value for tag in plan.tags}, + "validation": diagnostics, + "warnings": warnings, + "diff": diff, + "prunedVersion": plan.prune_version_id, + } + + +def _change_states( + service: IamManagedPolicyService, plan: PolicyChangePlan +) -> tuple[dict[str, object], dict[str, object]]: + if plan.policy_arn is None: + arn = ( + f"arn:{service.partition}:iam::{service.account_id}:policy" + f"{plan.path}{plan.name}" + ) + created = ManagedPolicyRecord( + arn=ManagedPolicyArn.parse(arn), + policy_id="pending", + name=plan.name, + path=plan.path, + default_version_id="pending", + attachment_count=0, + permissions_boundary_usage_count=0, + tags=plan.tags, + document=plan.document, + description=plan.description, + ) + return _policy_state( + created, dependencies=PolicyDependencies(), create_only=True + ), _absent_state(arn, plan.name, plan.path) + before = service.get_policy( + plan.policy_arn.value, + include_document=True, + include_versions=True, + include_tags=True, + ) + if before.document is None: + raise PolicyServiceError("Current policy document is unavailable.") + if ( + before.default_version_id != plan.expected_default_version_id + or policy_digest(before.document) != plan.expected_digest + ): + raise PolicyDriftError( + "Policy changed after planning; review the operation again." + ) + after_versions = [ + _version_payload(version) + for version in before.versions + if version.version_id != plan.prune_version_id + ] + if plan.operation.action is ChangeAction.UPDATE: + for version in after_versions: + version["default"] = False + after_versions.append( + {"id": "pending", "default": True, "document": plan.document} + ) + elif plan.operation.action is ChangeAction.ROLLBACK: + for version in after_versions: + version["default"] = version["id"] == plan.rollback_version_id + dependencies = service.policy_dependencies(plan.policy_arn.value) + after = _policy_state(before, dependencies=dependencies) + after["versions"] = after_versions + return after, _policy_state(before, dependencies=dependencies) + + +def _repair( + plan: PolicyChangePlan, args: argparse.Namespace, service: IamManagedPolicyService +) -> PolicyChangePlan: + document = dict(plan.document) + changed = False + for repair in plan.validation.repairs: + if repair.field != "Version" or repair.suggested_value is None: + continue + if bool(getattr(args, "yes", False)): + continue + if _configs.json_output_enabled() or not bool( + getattr(sys.stdin, "isatty", lambda: False)() + ): + continue + if ( + input(f"{repair.message} Apply {repair.suggested_value!r}? Type 'yes': ") + .strip() + .casefold() + == "yes" + ): + document["Version"] = repair.suggested_value + changed = True + if not changed: + return plan + if plan.operation.action is ChangeAction.CREATE: + return service.plan_create( + plan.name, + document, + options=CreatePolicyOptions( + description=plan.description, + path=plan.path, + user_tags=tuple( + tag + for tag in plan.tags + if not tag.key.casefold().startswith(_RESERVED_PREFIX) + ), + include_aws_validation=not bool( + getattr(args, "local_validation_only", False) + ), + ), + ) + if plan.policy_arn is None: + return plan + return service.plan_publish( + plan.policy_arn.value, + document, + include_aws_validation=not bool(getattr(args, "local_validation_only", False)), + ) + + +def _execute_plan( + service: IamManagedPolicyService, + plan: PolicyChangePlan, + args: argparse.Namespace, + context: IamCommandContext, +) -> _configs.Result: + plan = _repair(plan, args, service) + diagnostics = _diagnostic_data(plan.validation) + if not plan.validation.valid: + return _error( + "IAM_POLICY_VALIDATION_FAILED", + _diagnostic_text(plan.validation), + _configs.EXIT_POLICY, + ) + warnings = [ + item.message + for item in plan.validation.diagnostics + if item.severity is not DiagnosticSeverity.ERROR + ] + warnings.extend(plan.operation.warnings) + diff = _semantic_diff(service, plan) + preview = _change_preview(plan, context, diagnostics, warnings, diff) + if bool(getattr(args, "dry_run", False)): + data = {**preview, "dryRun": True} + message = ( + f"DRY RUN — {plan.operation.summary}\nNo AWS or local state was changed." + ) + if diff: + message += "\n" + "\n".join(diff) + return _configs.Result("IAM_POLICY_DRY_RUN", message, data=data) + confirmation = f"{plan.operation.summary}\nReview:\n" + json.dumps( + preview, indent=2, sort_keys=True + ) + if plan.operation.action is not ChangeAction.NOOP and not _confirm( + args, confirmation + ): + return _error( + "IAM_POLICY_CANCELLED", + "Policy change cancelled; no AWS changes were made.", + _configs.EXIT_CANCELLED, + ) + if plan.operation.action is ChangeAction.NOOP: + policy = service.get_policy( + cast("ManagedPolicyArn", plan.policy_arn).value, + include_document=True, + include_versions=True, + include_tags=True, + ) + journal_id = None + else: + forward, compensation = _change_states(service, plan) + journal_id = _durable_reconcile( + context, plan.operation.action.value, forward, compensation + ) + reference = str(forward["arn"]) + policy = service.get_policy( + reference, + include_document=True, + include_versions=True, + include_tags=True, + ) + data = { + "action": plan.operation.action.value, + "arn": policy.arn.value, + "name": policy.name, + "version": policy.default_version_id, + "warnings": warnings, + "diagnostics": diagnostics, + "prunedVersion": plan.prune_version_id, + "diff": diff, + "journalId": journal_id, + "consoleUrl": _console_url(context, policy.arn.value), + "preview": preview, + } + message = plan.operation.summary + if diff: + message += "\n" + "\n".join(diff) + if warnings: + message += "\n" + "\n".join(f"Warning: {item}" for item in warnings) + message += ( + f"\nARN: {policy.arn.value}\nPolicy ID: {policy.policy_id}" + f"\nAWS Console: {data['consoleUrl']}" + ) + return _configs.Result("IAM_POLICY_CHANGED", message, data=data) + + +def _create( + args: argparse.Namespace, + service: IamManagedPolicyService, + context: IamCommandContext, +) -> _configs.Result: + loaded = _load_from_file(args, args.file) + policy_name, naming_warnings = _create_name(args, loaded) + explicit_tags = _tags(args.tag) + tags = (*[Tag(key, value) for key, value in loaded.metadata.tags], *explicit_tags) + selected_path = args.iam_path or loaded.metadata.path + existing = service.resolve(f"custom:{policy_name}") + if existing.candidates: + if len(existing.candidates) > 1: + target = _select(service, f"custom:{policy_name}") + else: + target = existing.candidates[0] + target = service.get_policy( + target.arn.value, + include_document=True, + include_versions=True, + include_tags=True, + ) + current_user_tags = { + tag.key: tag.value + for tag in target.tags + if not tag.key.casefold().startswith(_RESERVED_PREFIX) + } + desired_user_tags = {tag.key: tag.value for tag in tags} + same_document = target.document is not None and policy_digest( + target.document + ) == policy_digest(loaded.document) + same_tags = current_user_tags == desired_user_tags + same_attributes = (selected_path is None or selected_path == target.path) and ( + (args.description is None and loaded.metadata.description is None) + or (args.description or loaded.metadata.description) == target.description + ) + if same_document and same_tags and same_attributes: + return _configs.Result( + "IAM_POLICY_NO_CHANGE", + f"NO CHANGE — Policy {policy_name!r} already matches " + f"{target.arn.value}.", + data={ + "classification": "no-change", + "action": "no-op", + "arn": target.arn.value, + "name": target.name, + "version": target.default_version_id, + }, + ) + if not args.replace: + return _error( + "IAM_POLICY_COLLISION", + f"Policy {policy_name!r} already exists at {target.arn.value}; " + "its document, tags, path, or description differs. Use 'iam policy " + "update' for routine changes, or repeat create with --replace after " + "reviewing the preview.", + _configs.EXIT_POLICY, + ) + plan = service.plan_publish( + target.arn.value, + loaded.document, + include_aws_validation=not args.local_validation_only, + ) + desired_tags = tuple( + [ + tag + for tag in target.tags + if tag.key.casefold().startswith(_RESERVED_PREFIX) + ] + + list(tags) + ) + if tuple(sorted((tag.key, tag.value) for tag in plan.tags)) != tuple( + sorted((tag.key, tag.value) for tag in desired_tags) + ): + plan = replace( + plan, + tags=desired_tags, + operation=replace( + plan.operation, + action=ChangeAction.UPDATE, + summary=f"Replace policy {target.arn.value} document and tags.", + ), + ) + else: + plan = service.plan_create( + policy_name, + loaded.document, + options=CreatePolicyOptions( + description=args.description or loaded.metadata.description, + path=selected_path, + user_tags=tags, + resource_id=uuid.uuid4().hex, + include_aws_validation=not args.local_validation_only, + ), + ) + result = _execute_plan(service, plan, args, context) + if naming_warnings and result.exit_code == _configs.EXIT_OK: + return _configs.Result( + result.code, + result.message + + "\n" + + "\n".join(f"Warning: {item}" for item in naming_warnings), + data=result.data, + ) + return result + + +def _list( + args: argparse.Namespace, service: IamManagedPolicyService +) -> _configs.Result: + scope = ( + PolicyScope.LOCAL + if args.custom + else PolicyScope.AWS + if args.aws + else PolicyScope.ALL + ) + policies = service.list_policies(scope=scope, include_tags=True) + patterns = args.patterns or ["*"] + policies = tuple( + item + for item in policies + if any( + fnmatch.fnmatchcase(item.name.casefold(), pattern.casefold()) + or fnmatch.fnmatchcase(item.arn.value.casefold(), pattern.casefold()) + for pattern in patterns + ) + ) + wide = bool(args.wide) + columns = ["Name", "Kind", "Owner", "Attached"] + if wide: + columns += ["Path", "Default", "Boundaries", "ARN"] + rows: list[list[object]] = [] + kinds: set[str] = set() + owners = False + for item in policies: + kind = "A" if item.arn.kind is PolicyKind.AWS_MANAGED else "C" + kinds.add(kind) + owner = "H" if item.owned else "-" + owners |= item.owned + row: list[object] = [item.name, kind, owner, item.attachment_count] + if wide: + row += [ + item.path, + item.default_version_id, + item.permissions_boundary_usage_count, + item.arn.value, + ] + rows.append(row) + legend: list[tuple[str, str]] = [] + if "A" in kinds: + legend.append(("A", "AWS-managed")) + if "C" in kinds: + legend.append(("C", "customer-managed")) + if owners: + legend.append(("H", "Hacksaws-owned")) + data = { + "scope": scope.value, + "patterns": patterns, + "policies": [_record_data(item) for item in policies], + "view": "wide" if wide else "compact", + "legend": dict(legend), + } + return _configs.Result( + "IAM_POLICY_LIST", _table(columns, rows, legend=legend), data=data + ) + + +def _record_data(item: ManagedPolicyRecord) -> dict[str, object]: + return { + "arn": item.arn.value, + "name": item.name, + "kind": item.arn.kind.value, + "path": item.path, + "defaultVersion": item.default_version_id, + "attachments": item.attachment_count, + "permissionsBoundaryUsage": item.permissions_boundary_usage_count, + "owned": item.owned, + "tags": {tag.key: tag.value for tag in item.tags}, + } + + +def _get(args: argparse.Namespace, service: IamManagedPolicyService) -> _configs.Result: + reference = _reference(service, args.policy) + item = service.get_policy( + reference, include_document=True, include_versions=True, include_tags=True + ) + data = _record_data(item) + data["versions"] = [version.version_id for version in item.versions] + data["document"] = item.document + rows = [ + ("ARN", item.arn.value), + ("Kind", item.arn.kind.value), + ("Path", item.path), + ("Default version", item.default_version_id), + ("Attachments", item.attachment_count), + ("Boundary uses", item.permissions_boundary_usage_count), + ("Owned", "yes" if item.owned else "no"), + ] + return _configs.Result( + "IAM_POLICY_GET", _table(("Field", "Value"), rows), data=data + ) + + +def _output_format(args: argparse.Namespace) -> PolicyFormat: + if args.format: + return PolicyFormat(args.format) + if args.output and args.output != "-": + try: + return PolicyFormat.from_path(Path(args.output)) + except PolicyInputError: + pass + return PolicyFormat.YAML + + +def _toml_scalar(value: JsonValue) -> str: + if value is None: + raise PolicyInputError( + "TOML has no null value and cannot represent this policy losslessly; " + "use YAML or JSON." + ) + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, list) and all(not isinstance(item, dict) for item in value): + return "[" + ", ".join(_toml_scalar(item) for item in value) + "]" + raise PolicyInputError( + "This policy shape cannot be represented losslessly as TOML; use YAML or JSON." + ) + + +def _toml_document(value: Mapping[str, JsonValue]) -> str: + lines: list[str] = [] + + def key(value: str) -> str: + return value if re.fullmatch(r"[A-Za-z0-9_-]+", value) else json.dumps(value) + + def table(mapping: Mapping[str, JsonValue], prefix: tuple[str, ...]) -> None: + deferred: list[tuple[str, JsonValue]] = [] + for item_key, item in mapping.items(): + if isinstance(item, dict) or ( + isinstance(item, list) + and any(isinstance(child, dict) for child in item) + ): + deferred.append((item_key, item)) + else: + lines.append(f"{key(item_key)} = {_toml_scalar(item)}") + for item_key, item in deferred: + path = ".".join(key(part) for part in (*prefix, item_key)) + if isinstance(item, dict): + lines.extend(("", f"[{path}]")) + table(item, (*prefix, item_key)) + else: + children = cast("list[JsonValue]", item) + for child in children: + if not isinstance(child, dict): + raise PolicyInputError( + "Mixed object/scalar TOML arrays are not supported." + ) + lines.extend(("", f"[[{path}]]")) + table(child, (*prefix, item_key)) + + table(value, ()) + return "\n".join(lines).lstrip() + "\n" + + +def _serialize(value: Mapping[str, JsonValue], selected: PolicyFormat) -> str: + if selected is PolicyFormat.JSON: + return json.dumps(value, indent=2, ensure_ascii=False) + "\n" + if selected is PolicyFormat.YAML: + return cast("str", yaml.safe_dump(dict(value), sort_keys=False)) + return _toml_document(value) + + +def _export_metadata(item: ManagedPolicyRecord) -> dict[str, JsonValue]: + return { + "name": item.name, + "path": item.path, + "description": item.description, + "tags": { + tag.key: tag.value + for tag in item.tags + if not tag.key.casefold().startswith(_RESERVED_PREFIX) + }, + } + + +def _export( + args: argparse.Namespace, service: IamManagedPolicyService +) -> _configs.Result: + if args.policy.startswith("stored:"): + stored = _stored_policy(args.policy.split(":", maxsplit=1)[1]) + selected = _output_format(args) + mode = MetadataMode(args.metadata) + metadata: dict[str, JsonValue] = { + "name": stored.metadata.name, + **( + {"description": stored.metadata.description} + if stored.metadata.description + else {} + ), + } + content_value: Mapping[str, JsonValue] = ( + {"metadata": metadata, "policy": stored.document} + if mode is MetadataMode.NESTED + else stored.document + ) + if mode is MetadataMode.SIDECAR: + raise PolicyInputError( + "Stored-policy sidecar export is not supported; use nested metadata." + ) + content = _serialize(content_value, selected) + if args.output and args.output != "-": + output_path = Path(args.output).expanduser() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + message = f"Exported stored policy {stored.metadata.name} to {output_path}." + else: + message = content.rstrip() + return _configs.Result( + "IAM_POLICY_EXPORT", + message, + data={ + "name": stored.metadata.name, + "provenance": "stored", + "source": str(stored.source), + "format": selected.value, + "metadata": mode.value, + "output": args.output, + "document": stored.document, + }, + ) + reference = _reference(service, args.policy) + exported = service.export_policy(reference, include_all_versions=args.all_versions) + selected = _output_format(args) + metadata = _export_metadata(exported.policy) + mode = MetadataMode(args.metadata) + version_data: list[dict[str, JsonValue]] = [ + { + "id": version.version_id, + "default": version.is_default, + "createdAt": ( + version.created_at.isoformat() if version.created_at else None + ), + "policy": version.document, + } + for version in exported.versions + ] + remote_content: Mapping[str, JsonValue] + if args.all_versions: + remote_content = { + **({"metadata": metadata} if mode is MetadataMode.NESTED else {}), + "policy": exported.active_document, + "versions": cast("JsonValue", version_data), + } + elif mode is MetadataMode.NESTED: + remote_content = {"metadata": metadata, "policy": exported.active_document} + else: + remote_content = exported.active_document + content = _serialize(remote_content, selected) + output = args.output + sidecar_path: Path | None = None + if mode is MetadataMode.SIDECAR: + if not output or output == "-": + raise PolicyInputError("Sidecar metadata export requires an output file.") + sidecar_path = args.metadata_file or Path(output).with_name( + f"{Path(output).stem}.metadata{Path(output).suffix}" + ) + if output and output != "-": + output_path = Path(output).expanduser() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + if sidecar_path: + sidecar_path.parent.mkdir(parents=True, exist_ok=True) + sidecar_path.write_text( + _serialize(metadata, PolicyFormat.from_path(sidecar_path)), + encoding="utf-8", + ) + message = f"Exported {exported.policy.arn.value} to {output_path}." + else: + message = content.rstrip() + data = { + "arn": exported.policy.arn.value, + "provenance": "aws-managed" + if exported.policy.arn.kind is PolicyKind.AWS_MANAGED + else "customer-managed", + "format": selected.value, + "metadata": mode.value, + "output": str(output) if output else None, + "sidecar": str(sidecar_path) if sidecar_path else None, + "versions": version_data, + "document": exported.active_document, + } + return _configs.Result("IAM_POLICY_EXPORT", message, data=data) + + +def _update( + args: argparse.Namespace, + service: IamManagedPolicyService, + context: IamCommandContext, +) -> _configs.Result: + reference, loaded = _loaded_update(args) + arn = _reference(service, reference) + plan = service.plan_publish( + arn, loaded.document, include_aws_validation=not args.local_validation_only + ) + return _execute_plan(service, plan, args, context) + + +def _edit( + args: argparse.Namespace, + service: IamManagedPolicyService, + context: IamCommandContext, +) -> _configs.Result: + arn = _reference(service, args.policy) + exported = service.export_policy(arn) + selected = PolicyFormat(args.format) + suffix = ".yaml" if selected is PolicyFormat.YAML else f".{selected.value}" + with tempfile.TemporaryDirectory(prefix="hacksaws-edit-") as directory: + path = Path(directory) / f"policy{suffix}" + path.write_text( + _serialize(exported.active_document, selected), encoding="utf-8" + ) + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "notepad.exe" + command = [*shlex.split(editor, posix=os.name != "nt"), str(path)] + completed = subprocess.run(command, check=False) # noqa: S603 + if completed.returncode != 0: + return _error( + "IAM_POLICY_EDITOR_FAILED", + f"Editor exited with status {completed.returncode}.", + ) + loaded = load_policy_input(path) + latest = service.get_policy( + arn, include_document=True, include_versions=False, include_tags=False + ) + if ( + latest.document is None + or latest.default_version_id != exported.policy.default_version_id + or policy_digest(latest.document) != policy_digest(exported.active_document) + ): + raise PolicyDriftError( + "Policy changed while the editor was open; no update was made." + ) + plan = service.plan_publish( + arn, loaded.document, include_aws_validation=not args.local_validation_only + ) + return _execute_plan(service, plan, args, context) + + +def _versions( + args: argparse.Namespace, service: IamManagedPolicyService +) -> _configs.Result: + arn = _reference(service, args.policy) + item = service.get_policy( + arn, include_document=False, include_versions=True, include_tags=False + ) + rows = [ + ( + version.version_id, + "yes" if version.is_default else "no", + version.created_at.isoformat() if version.created_at else "", + ) + for version in item.versions + ] + data = { + "arn": arn, + "versions": [ + { + "id": version.version_id, + "default": version.is_default, + "createdAt": version.created_at, + } + for version in item.versions + ], + } + return _configs.Result( + "IAM_POLICY_VERSIONS", + _table(("Version", "Default", "Created"), rows), + data=data, + ) + + +def _rollback( + args: argparse.Namespace, + service: IamManagedPolicyService, + context: IamCommandContext, +) -> _configs.Result: + arn = _reference(service, args.policy) + return _execute_plan( + service, service.plan_rollback(arn, args.version), args, context + ) + + +def _dependency_data(plan: PolicyDeletionPlan) -> dict[str, object]: + dependencies = plan.dependencies + return { + "permissionUsers": [item.name for item in dependencies.permission_users], + "permissionGroups": [item.name for item in dependencies.permission_groups], + "permissionRoles": [item.name for item in dependencies.permission_roles], + "boundaryUsers": [item.name for item in dependencies.boundary_users], + "boundaryRoles": [item.name for item in dependencies.boundary_roles], + } + + +def _delete( + args: argparse.Namespace, + service: IamManagedPolicyService, + context: IamCommandContext, +) -> _configs.Result: + arn = _reference(service, args.policy) + plan = service.plan_delete(arn, cascade=args.cascade) + if not plan.policy.owned and not args.allow_unmanaged: + return _error( + "IAM_POLICY_UNMANAGED", + "Refusing to delete an unmanaged policy; inspect it and repeat with " + "--allow-unmanaged.", + _configs.EXIT_POLICY, + ) + boundary_names = ( + *plan.dependencies.boundary_users, + *plan.dependencies.boundary_roles, + ) + if boundary_names and not args.remove_boundaries: + return _error( + "IAM_POLICY_BOUNDARIES", + "Policy is used as a permissions boundary. Removing boundary assignments " + "requires both --cascade and --remove-boundaries after review.", + _configs.EXIT_POLICY, + ) + if not plan.executable: + return _error( + "IAM_POLICY_DEPENDENCIES", + "Policy still has dependencies; use --cascade only after reviewing them.", + _configs.EXIT_POLICY, + ) + policy = service.get_policy( + arn, include_document=True, include_versions=True, include_tags=True + ) + dependencies = service.policy_dependencies(arn) + if not policy.owned and not args.allow_unmanaged: + return _error( + "IAM_POLICY_UNMANAGED", + "Policy ownership changed after deletion planning; refusing deletion " + "without --allow-unmanaged after a fresh review.", + _configs.EXIT_POLICY, + ) + if ( + policy.policy_id != plan.policy.policy_id + or policy.default_version_id != plan.policy.default_version_id + or sorted((tag.key, tag.value) for tag in policy.tags) + != sorted((tag.key, tag.value) for tag in plan.policy.tags) + or tuple(version.version_id for version in policy.versions) + != tuple(version.version_id for version in plan.policy.versions) + or dependencies != plan.dependencies + ): + raise PolicyDriftError( + "Policy or dependencies changed after deletion planning; review again." + ) + preview = { + "attachments": { + "users": [item.name for item in dependencies.permission_users], + "groups": [item.name for item in dependencies.permission_groups], + "roles": [item.name for item in dependencies.permission_roles], + }, + "permissionBoundaries": { + "users": [item.name for item in dependencies.boundary_users], + "roles": [item.name for item in dependencies.boundary_roles], + }, + "versions": [ + { + "id": version.version_id, + "default": version.is_default, + "digest": ( + policy_digest(version.document) if version.document else None + ), + } + for version in policy.versions + ], + } + confirmation = ( + f"{plan.operation.summary}\nExact deletion preview:\n" + f"{json.dumps(preview, indent=2, sort_keys=True)}\n" + ) + if bool(getattr(args, "dry_run", False)): + return _configs.Result( + "IAM_POLICY_DELETE_DRY_RUN", + f"DRY RUN — {confirmation.rstrip()}\nNo AWS or local state was changed.", + data={ + "dryRun": True, + "classification": "planned", + "arn": arn, + "cascade": args.cascade, + "removeBoundaries": args.remove_boundaries, + "dependencies": _dependency_data(plan), + "preview": preview, + }, + ) + if not _confirm_exact(args, confirmation, policy.name): + return _error( + "IAM_POLICY_CANCELLED", + "Policy deletion cancelled; no AWS changes were made.", + _configs.EXIT_CANCELLED, + ) + journal_id = _durable_reconcile( + context, + "delete", + _absent_state(arn, policy.name, policy.path), + _policy_state(policy, dependencies=dependencies), + ) + return _configs.Result( + "IAM_POLICY_DELETED", + confirmation.rstrip(), + data={ + "arn": arn, + "cascade": args.cascade, + "removeBoundaries": args.remove_boundaries, + "dependencies": _dependency_data(plan), + "preview": preview, + "journalId": journal_id, + }, + ) + + +def _check( + args: argparse.Namespace, service: IamManagedPolicyService +) -> _configs.Result: + arn = _reference(service, args.policy) + item = service.get_policy( + arn, include_document=True, include_versions=False, include_tags=True + ) + if item.document is None: + raise PolicyServiceError("Managed policy has no active document.") + report = service.validate_policy( + item.document, + name=item.name, + path=item.path, + tags=item.tags, + include_aws=not args.local_validation_only, + ) + probe_data: dict[str, object] | None = None + messages = ( + [_diagnostic_text(report)] + if report.diagnostics + else ["Policy validation passed."] + ) + if args.role: + role_arn = ( + args.role + if args.role.startswith("arn:") + else f"arn:{service.partition}:iam::{service.account_id}:role/{args.role}" + ) + config = _state.load_config() + threshold = int(config.get("session", {}).get("packed_policy_warning", 80)) + probe = service.probe_assume_role( + role_arn, + item.document, + options=AssumeRoleProbeOptions(packed_warning_threshold=threshold), + ) + probe_data = { + "roleArn": probe.role_arn, + "assumedRoleArn": probe.assumed_role_arn, + "expiresAt": probe.expires_at, + "packedPolicySize": probe.packed_policy_size, + "warning": probe.warning.message if probe.warning else None, + } + messages.append(f"Role assumability probe passed for {role_arn}.") + if probe.warning: + messages.append(f"Warning: {probe.warning.message}") + data = { + "arn": arn, + "valid": report.valid, + "diagnostics": _diagnostic_data(report), + "probe": probe_data, + } + if not report.valid: + return _configs.Result( + "IAM_POLICY_CHECK_FAILED", + "\n".join(filter(None, messages)), + _configs.EXIT_POLICY, + "stderr", + data, + ) + return _configs.Result( + "IAM_POLICY_CHECK", "\n".join(filter(None, messages)), data=data + ) + + +def _tag( + args: argparse.Namespace, + service: IamManagedPolicyService, + context: IamCommandContext, +) -> _configs.Result: + if args.policy_tag_action not in {"list", "set", "remove"}: + return _error( + "IAM_POLICY_TAG_HELP", + "Choose tag list, set, or remove.", + _configs.EXIT_USAGE, + ) + arn = _reference(service, args.policy) + item = service.get_policy( + arn, + include_document=args.policy_tag_action != "list", + include_versions=args.policy_tag_action != "list", + include_tags=True, + ) + if args.policy_tag_action == "list": + rows = [(tag.key, tag.value) for tag in item.tags] + return _configs.Result( + "IAM_POLICY_TAG_LIST", + _table(("Key", "Value"), rows), + data={"arn": arn, "tags": {tag.key: tag.value for tag in item.tags}}, + ) + if item.arn.kind is PolicyKind.AWS_MANAGED: + raise ImmutablePolicyError(f"AWS-managed policy {arn} is immutable.") + if args.policy_tag_action == "set": + tags = _tags(args.tag) + if not tags: + raise PolicyInputError("Tag set requires at least one --tag KEY=VALUE.") + if any(tag.key.casefold().startswith(_RESERVED_PREFIX) for tag in tags): + raise PolicyInputError( + "Use adopt/release to change reserved Hacksaws ownership tags." + ) + if bool(getattr(args, "dry_run", False)): + return _configs.Result( + "IAM_POLICY_TAG_DRY_RUN", + f"DRY RUN — Set {len(tags)} tag(s) on {arn}.\n" + "No AWS or local state was changed.", + data={ + "dryRun": True, + "action": "set", + "arn": arn, + "tags": {tag.key: tag.value for tag in tags}, + }, + ) + if not _confirm(args, f"Set {len(tags)} tag(s) on {arn}?"): + return _error( + "IAM_POLICY_CANCELLED", "Tag change cancelled.", _configs.EXIT_CANCELLED + ) + values = {tag.key: tag.value for tag in item.tags} + values.update({tag.key: tag.value for tag in tags}) + changed: object = {tag.key: tag.value for tag in tags} + else: + keys = args.keys + if any(key.casefold().startswith(_RESERVED_PREFIX) for key in keys): + raise PolicyInputError( + "Use release to remove reserved Hacksaws ownership tags." + ) + if bool(getattr(args, "dry_run", False)): + return _configs.Result( + "IAM_POLICY_TAG_DRY_RUN", + f"DRY RUN — Remove {len(keys)} tag(s) from {arn}.\n" + "No AWS or local state was changed.", + data={ + "dryRun": True, + "action": "remove", + "arn": arn, + "keys": list(keys), + }, + ) + if not _confirm(args, f"Remove {len(keys)} tag(s) from {arn}?"): + return _error( + "IAM_POLICY_CANCELLED", "Tag change cancelled.", _configs.EXIT_CANCELLED + ) + values = {tag.key: tag.value for tag in item.tags if tag.key not in set(keys)} + changed = keys + latest = service.get_policy( + arn, include_document=True, include_versions=True, include_tags=True + ) + if sorted((tag.key, tag.value) for tag in latest.tags) != sorted( + (tag.key, tag.value) for tag in item.tags + ): + raise PolicyDriftError( + f"Policy tags for {arn} changed after planning; review the change again." + ) + desired = replace( + latest, + tags=tuple(Tag(key, value) for key, value in sorted(values.items())), + ) + dependencies = service.policy_dependencies(arn) + journal_id = _durable_reconcile( + context, + f"tag-{args.policy_tag_action}", + _policy_state(desired, dependencies=dependencies), + _policy_state(latest, dependencies=dependencies), + ) + return _configs.Result( + "IAM_POLICY_TAG_CHANGED", + f"Updated tags on {arn}.", + data={ + "arn": arn, + "action": args.policy_tag_action, + "changed": changed, + "journalId": journal_id, + }, + ) + + +def _ownership( + args: argparse.Namespace, + service: IamManagedPolicyService, + context: IamCommandContext, +) -> _configs.Result: + arn = _reference(service, args.policy) + if args.policy_action == "adopt": + plan = service.plan_adopt(arn, uuid.uuid4().hex, user_tags=_tags(args.tag)) + else: + plan = service.plan_release(arn) + if bool(getattr(args, "dry_run", False)): + return _configs.Result( + "IAM_POLICY_OWNERSHIP_DRY_RUN", + f"DRY RUN — {plan.operation.summary}\nNo AWS or local state was changed.", + data={ + "dryRun": True, + "action": args.policy_action, + "arn": arn, + "classification": plan.operation.action.value, + }, + ) + if not _confirm(args, plan.operation.summary): + return _error( + "IAM_POLICY_CANCELLED", + "Ownership change cancelled.", + _configs.EXIT_CANCELLED, + ) + current = service.get_policy( + arn, include_document=True, include_versions=True, include_tags=True + ) + if sorted((tag.key, tag.value) for tag in current.tags) != sorted( + (tag.key, tag.value) for tag in plan.policy.tags + ): + raise PolicyDriftError( + f"Policy tags for {arn} changed after planning; review the change again." + ) + values = {tag.key: tag.value for tag in current.tags} + values.update({tag.key: tag.value for tag in plan.add}) + for key in plan.remove: + values.pop(key, None) + desired = replace( + current, + tags=tuple(Tag(key, value) for key, value in sorted(values.items())), + ) + dependencies = service.policy_dependencies(arn) + journal_id = _durable_reconcile( + context, + args.policy_action, + _policy_state(desired, dependencies=dependencies), + _policy_state(current, dependencies=dependencies), + ) + return _configs.Result( + "IAM_POLICY_OWNERSHIP_CHANGED", + plan.operation.summary, + data={ + "arn": arn, + "action": args.policy_action, + "owned": desired.owned, + "journalId": journal_id, + }, + ) + + +def dispatch( + args: argparse.Namespace, context: IamCommandContext +) -> _configs.Result | None: + """Dispatch one managed-policy leaf and normalize failures for JSON envelopes.""" + action = getattr(args, "policy_action", None) + if action is None: + return None + handlers = { + "list": _list, + "get": _get, + "export": _export, + "versions": _versions, + "check": _check, + } + try: + service = _service(context) + if action == "tag": + return _tag(args, service, context) + if action == "create": + return _create(args, service, context) + if action == "update": + return _update(args, service, context) + if action == "edit": + return _edit(args, service, context) + if action == "rollback": + return _rollback(args, service, context) + if action == "delete": + return _delete(args, service, context) + if action in {"adopt", "release"}: + return _ownership(args, service, context) + handler = handlers.get(action) + if handler is None: + return _error( + "IAM_POLICY_HELP", + "Choose a managed-policy command.", + _configs.EXIT_USAGE, + ) + return handler(args, service) + except PolicyValidationError as error: + return _configs.Result( + "IAM_POLICY_VALIDATION_FAILED", + _diagnostic_text(error.report), + _configs.EXIT_POLICY, + "stderr", + {"diagnostics": _diagnostic_data(error.report)}, + ) + except PolicyDriftError as error: + return _error("IAM_POLICY_DRIFT", str(error), _configs.EXIT_POLICY) + except PackedPolicyProbeError as error: + diagnostic = error.diagnostic + return _configs.Result( + "IAM_POLICY_PACKED_TOO_LARGE", + diagnostic.message, + _configs.EXIT_POLICY, + "stderr", + { + "packedPolicySize": diagnostic.packed_policy_size, + "repairs": [asdict(item) for item in diagnostic.repairs], + }, + ) + except ImmutablePolicyError as error: + return _error("IAM_POLICY_IMMUTABLE", str(error), _configs.EXIT_POLICY) + except (PolicyInputError, PolicyServiceError, _configs.OperationalError) as error: + return _error("IAM_POLICY_ERROR", str(error), _configs.EXIT_POLICY) + except ( + BotoCoreError, + ClientError, + OSError, + UnicodeError, + subprocess.SubprocessError, + ) as error: + return _error("IAM_POLICY_AWS_ERROR", str(error)) diff --git a/hacksaws/_iam_policy_documents.py b/hacksaws/_iam_policy_documents.py new file mode 100644 index 0000000..8c35b32 --- /dev/null +++ b/hacksaws/_iam_policy_documents.py @@ -0,0 +1,308 @@ +"""Strict IAM policy document loading, validation, and canonicalization.""" + +from __future__ import annotations + +import hashlib +import json +import math +import tomllib +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING +from urllib.parse import unquote + +import yaml + +if TYPE_CHECKING: + from pathlib import Path + +type JsonScalar = bool | int | float | str | None +type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue] + + +class PolicyInputError(ValueError): + """Report a strict policy input parsing failure.""" + + +class MetadataMode(StrEnum): + """Select where policy import metadata is loaded from.""" + + NESTED = "nested" + SIDECAR = "sidecar" + NONE = "none" + + +class PolicyFormat(StrEnum): + """Supported human-authored policy input formats.""" + + JSON = "json" + YAML = "yaml" + TOML = "toml" + + @classmethod + def from_path(cls, path: Path) -> PolicyFormat: + """Infer a supported policy format from a filename.""" + suffix = path.suffix.casefold() + if suffix == ".json": + return cls.JSON + if suffix in {".yaml", ".yml"}: + return cls.YAML + if suffix == ".toml": + return cls.TOML + message = f"Unsupported policy file extension {path.suffix!r}." + raise PolicyInputError(message) + + +@dataclass(frozen=True, slots=True) +class InputMetadata: + """Simple metadata accompanying an imported policy document.""" + + name: str | None = None + description: str | None = None + path: str | None = None + tags: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True, slots=True) +class LoadedPolicyInput: + """A strict JSON-compatible IAM policy plus optional metadata.""" + + document: dict[str, JsonValue] + metadata: InputMetadata + source: Path + source_format: PolicyFormat + sidecar: Path | None = None + + @property + def canonical_json(self) -> str: + """Return deterministic JSON without changing policy semantics.""" + return canonical_policy_json(self.document) + + @property + def digest(self) -> str: + """Return a stable SHA-256 digest of the semantic document.""" + return policy_digest(self.document) + + +class _UniqueKeyLoader(yaml.SafeLoader): + """Safe YAML loader that rejects duplicate mapping keys.""" + + +def _construct_unique_mapping( + loader: _UniqueKeyLoader, + node: yaml.nodes.MappingNode, + *, + deep: bool = False, +) -> dict[object, object]: + loader.flatten_mapping(node) + result: dict[object, object] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in result + except TypeError as error: + message = "YAML mapping keys must be scalar and hashable." + raise PolicyInputError(message) from error + if duplicate: + message = f"Duplicate YAML mapping key {key!r}." + raise PolicyInputError(message) + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +_UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_mapping, +) + + +def _reject_duplicate_json(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + message = f"Duplicate JSON mapping key {key!r}." + raise PolicyInputError(message) + result[key] = value + return result + + +def _json_value(value: object, *, location: str = "$") -> JsonValue: + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + if not math.isfinite(value): + message = f"Non-finite number at {location} is not valid JSON." + raise PolicyInputError(message) + return value + if isinstance(value, list): + return [ + _json_value(item, location=f"{location}[{index}]") + for index, item in enumerate(value) + ] + if isinstance(value, Mapping): + result: dict[str, JsonValue] = {} + for key, item in value.items(): + if not isinstance(key, str): + message = f"Object key at {location} must be a string, got {key!r}." + raise PolicyInputError(message) + result[key] = _json_value(item, location=f"{location}.{key}") + return result + message = f"Unsupported value at {location}: {type(value).__name__}." + raise PolicyInputError(message) + + +def _object(value: object, *, label: str) -> dict[str, JsonValue]: + converted = _json_value(value) + if not isinstance(converted, dict): + message = f"{label} must be an object." + raise PolicyInputError(message) + return converted + + +def _parse_text(text: str, policy_format: PolicyFormat) -> object: + try: + if policy_format is PolicyFormat.JSON: + return json.loads(text, object_pairs_hook=_reject_duplicate_json) + if policy_format is PolicyFormat.YAML: + return yaml.load(text, Loader=_UniqueKeyLoader) # noqa: S506 + return tomllib.loads(text) + except PolicyInputError: + raise + except (json.JSONDecodeError, tomllib.TOMLDecodeError, yaml.YAMLError) as error: + message = f"Invalid {policy_format.value.upper()} policy input: {error}" + raise PolicyInputError(message) from error + + +def _metadata_tags(raw_tags: JsonValue) -> tuple[tuple[str, str], ...]: + tags: list[tuple[str, str]] = [] + if isinstance(raw_tags, dict): + for tag_key, tag_value in raw_tags.items(): + if not isinstance(tag_value, str): + message = f"Policy metadata tag {tag_key!r} must have a string value." + raise PolicyInputError(message) + tags.append((tag_key, tag_value)) + return tuple(tags) + if not isinstance(raw_tags, list): + message = "Policy metadata tags must be an object or key/value object list." + raise PolicyInputError(message) + for index, item in enumerate(raw_tags): + if not isinstance(item, dict): + message = f"Policy metadata tags[{index}] must be an object." + raise PolicyInputError(message) + listed_key = item.get("key") + tag_value = item.get("value", "") + if not isinstance(listed_key, str) or not isinstance(tag_value, str): + message = f"Policy metadata tags[{index}] requires string key/value." + raise PolicyInputError(message) + tags.append((listed_key, tag_value)) + return tuple(tags) + + +def _metadata(value: object) -> InputMetadata: + if value is None: + return InputMetadata() + data = _object(value, label="Policy metadata") + allowed = {"name", "description", "path", "tags"} + unknown = sorted(set(data) - allowed) + if unknown: + message = f"Unknown policy metadata fields: {', '.join(unknown)}." + raise PolicyInputError(message) + + def optional_string(key: str) -> str | None: + item = data.get(key) + if item is None: + return None + if not isinstance(item, str): + message = f"Policy metadata {key!r} must be a string." + raise PolicyInputError(message) + return item + + raw_tags = data.get("tags", {}) + return InputMetadata( + name=optional_string("name"), + description=optional_string("description"), + path=optional_string("path"), + tags=_metadata_tags(raw_tags), + ) + + +def _default_sidecar(path: Path) -> Path: + return path.with_name(f"{path.stem}.metadata{path.suffix}") + + +def load_policy_input( + path: Path, + *, + metadata_mode: MetadataMode = MetadataMode.NONE, + sidecar: Path | None = None, +) -> LoadedPolicyInput: + """Load JSON, YAML, or TOML without lossy policy normalization.""" + policy_format = PolicyFormat.from_path(path) + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + message = f"Unable to read policy input {path}: {error}" + raise PolicyInputError(message) from error + parsed = _parse_text(text, policy_format) + metadata = InputMetadata() + document_source = parsed + used_sidecar: Path | None = None + + if metadata_mode is MetadataMode.NESTED: + wrapper = _object(parsed, label="Nested policy input") + unknown = sorted(set(wrapper) - {"metadata", "policy"}) + if unknown: + message = f"Unknown nested policy fields: {', '.join(unknown)}." + raise PolicyInputError(message) + if "policy" not in wrapper: + message = "Nested policy input requires a 'policy' object." + raise PolicyInputError(message) + document_source = wrapper["policy"] + metadata = _metadata(wrapper.get("metadata")) + elif metadata_mode is MetadataMode.SIDECAR: + used_sidecar = sidecar or _default_sidecar(path) + sidecar_format = PolicyFormat.from_path(used_sidecar) + try: + sidecar_text = used_sidecar.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + message = f"Unable to read policy metadata sidecar {used_sidecar}: {error}" + raise PolicyInputError(message) from error + metadata = _metadata(_parse_text(sidecar_text, sidecar_format)) + + document = _object(document_source, label="IAM policy document") + return LoadedPolicyInput( + document=document, + metadata=metadata, + source=path, + source_format=policy_format, + sidecar=used_sidecar, + ) + + +def canonical_policy_json(document: Mapping[str, JsonValue]) -> str: + """Serialize deterministically while preserving arrays and scalar forms.""" + return json.dumps( + dict(document), + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + +def policy_digest(document: Mapping[str, JsonValue]) -> str: + """Hash the canonical semantic representation of a policy document.""" + canonical = canonical_policy_json(document).encode() + return hashlib.sha256(canonical).hexdigest() + + +def decode_iam_document(value: object) -> dict[str, JsonValue]: + """Normalize boto3-decoded or raw RFC3986 IAM policy output.""" + if isinstance(value, str): + try: + value = json.loads(unquote(value), object_pairs_hook=_reject_duplicate_json) + except json.JSONDecodeError as error: + message = f"IAM returned an invalid policy document: {error}" + raise PolicyInputError(message) from error + return _object(value, label="IAM policy document") diff --git a/hacksaws/_iam_recovery.py b/hacksaws/_iam_recovery.py new file mode 100644 index 0000000..0c705dd --- /dev/null +++ b/hacksaws/_iam_recovery.py @@ -0,0 +1,753 @@ +"""Durable, credential-free recovery journals for remote IAM mutations.""" + +# Recovery errors deliberately carry complete operator-facing diagnostics. +# ruff: noqa: E501, TRY003 + +from __future__ import annotations + +import contextlib +import ctypes +import json +import os +import re +import time +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import UTC +from datetime import datetime +from typing import TYPE_CHECKING +from typing import Any +from typing import cast + +from hacksaws import _state +from hacksaws._configs import OperationalError + +if TYPE_CHECKING: + from collections.abc import Callable + from collections.abc import Iterator + from pathlib import Path + +SCHEMA_VERSION = 1 +_ID = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9])?$") +_FORBIDDEN_KEYS = ( + "accesskey", + "secretkey", + "sessiontoken", + "securitytoken", + "credential", + "password", +) +_handlers: dict[tuple[str, str], RecoveryHandler] = {} + + +def _now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def recovery_root() -> Path: + """Return the IAM-only journal directory, separate from login transactions.""" + return _state.root() / "iam-recovery" + + +def _validated_id(journal_id: str) -> str: + if not isinstance(journal_id, str) or not _ID.fullmatch(journal_id): + raise OperationalError(f"Invalid IAM recovery journal ID {journal_id!r}.") + return journal_id + + +def _contained_child(directory: Path, filename: str) -> Path: + """Resolve a child path and prove it remains in the IAM recovery root.""" + boundary = recovery_root().resolve() + resolved_directory = directory.resolve() + if resolved_directory != boundary and not resolved_directory.is_relative_to( + boundary + ): + raise OperationalError("IAM recovery storage resolves outside its state root.") + candidate = (resolved_directory / filename).resolve() + if candidate.parent != resolved_directory: + raise OperationalError("IAM recovery path escapes its state directory.") + return candidate + + +def _journal_path(journal_id: str) -> Path: + identifier = _validated_id(journal_id) + return _contained_child(recovery_root(), f"{identifier}.json") + + +def _lock_path(journal_id: str) -> Path: + identifier = _validated_id(journal_id) + return _contained_child(recovery_root() / ".locks", f"{identifier}.lock") + + +def _safe_payload(value: object, *, location: str = "payload") -> object: + """Validate JSON payloads and reject fields that could contain credentials.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + return [ + _safe_payload(item, location=f"{location}[{index}]") + for index, item in enumerate(value) + ] + if isinstance(value, tuple): + return [ + _safe_payload(item, location=f"{location}[{index}]") + for index, item in enumerate(value) + ] + if isinstance(value, Mapping): + result: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise OperationalError(f"{location} keys must be text.") + normalized = re.sub(r"[^a-z]", "", key.casefold()) + if any(part in normalized for part in _FORBIDDEN_KEYS): + raise OperationalError( + f"IAM recovery journals cannot store credential field {key!r}." + ) + result[key] = _safe_payload(item, location=f"{location}.{key}") + return result + raise OperationalError( + f"{location} must contain only JSON values, not {type(value).__name__}." + ) + + +def _write(journal: Mapping[str, object], *, journal_id: str) -> None: + identifier = _validated_id(journal_id) + if journal.get("id") != identifier: + raise OperationalError( + "IAM recovery journal identity does not match its locked filename.", + details={"requestedId": identifier, "embeddedId": journal.get("id")}, + ) + path = _journal_path(identifier) + _state.atomic_write( + path, + (json.dumps(journal, indent=2, sort_keys=True) + "\n").encode("utf-8"), + ) + + +def _lock_owner_alive(path: Path) -> bool: + try: + owner = int(path.read_text(encoding="ascii").splitlines()[0]) + except (OSError, ValueError, IndexError): + return False + if owner == os.getpid(): + return True + if os.name == "nt": + process = ctypes.windll.kernel32.OpenProcess(0x1000, 0, owner) # type: ignore[attr-defined] + if not process: + return False + ctypes.windll.kernel32.CloseHandle(process) # type: ignore[attr-defined] + return True + try: + os.kill(owner, 0) + except OSError: + return False + return True + + +@contextlib.contextmanager +def _locked(journal_id: str, *, timeout: float = 2.0) -> Iterator[None]: + """Serialize journal transitions with an atomic process lock file.""" + identifier = _validated_id(journal_id) + path = _lock_path(identifier) + path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + timeout + descriptor: int | None = None + while descriptor is None: + try: + descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.write(descriptor, f"{os.getpid()}\n{_now()}\n".encode("ascii")) + os.fsync(descriptor) + except FileExistsError: + if not _lock_owner_alive(path): + with contextlib.suppress(OSError): + path.unlink() + continue + if time.monotonic() >= deadline: + raise OperationalError( + f"IAM recovery journal {identifier!r} is busy in another process.", + details={"journalId": identifier, "lock": str(path)}, + ) from None + time.sleep(0.02) + try: + yield + finally: + os.close(descriptor) + with contextlib.suppress(OSError): + path.unlink() + + +def _validate(journal: object, *, path: Path) -> dict[str, Any]: + required = { + "schemaVersion", + "id", + "serviceType", + "accountId", + "operation", + "status", + "createdAt", + "updatedAt", + "steps", + } + if not isinstance(journal, dict) or not required.issubset(journal): + raise OperationalError( + f"IAM recovery journal {path} has an invalid schema.", + details={"path": str(path), "required": sorted(required)}, + repairs=["Preserve the file for inspection; do not retry the mutation."], + ) + if journal["schemaVersion"] != SCHEMA_VERSION: + raise OperationalError( + f"IAM recovery journal {path} uses unsupported schema " + f"{journal['schemaVersion']!r}.", + details={"path": str(path), "supportedSchemaVersion": SCHEMA_VERSION}, + ) + partition = journal.get("partition") + if partition is not None and ( + not isinstance(partition, str) + or re.fullmatch(r"[a-z][a-z0-9-]{0,31}", partition) is None + ): + raise OperationalError( + f"IAM recovery journal {path} contains an invalid AWS partition.", + details={"path": str(path), "partition": partition}, + ) + if journal["status"] not in { + "active", + "failed", + "completed", + "rolling_back", + "rolled_back", + } or not isinstance(journal["steps"], list): + raise OperationalError( + f"IAM recovery journal {path} contains invalid status or steps.", + details={"path": str(path)}, + ) + for step in journal["steps"]: + if not isinstance(step, dict) or not { + "id", + "handler", + "status", + "forward", + "compensation", + }.issubset(step): + raise OperationalError( + f"IAM recovery journal {path} contains an invalid step.", + details={"path": str(path)}, + ) + if step["status"] not in {"pending", "completed", "rolled_back"}: + raise OperationalError( + f"IAM recovery journal {path} contains an invalid step status.", + details={"path": str(path), "stepId": step.get("id")}, + ) + _safe_payload(step["forward"], location="forward") + _safe_payload(step["compensation"], location="compensation") + if "effect" in step: + _safe_payload(step["effect"], location="effect") + return journal + + +def _read_unlocked(journal_id: str) -> dict[str, Any]: + identifier = _validated_id(journal_id) + path = _journal_path(identifier) + if not path.exists(): + raise OperationalError( + f"IAM recovery journal {identifier!r} was not found.", + details={"journalId": identifier, "path": str(path)}, + ) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise OperationalError( + f"Unable to read IAM recovery journal {path}: {error}", + details={"journalId": identifier, "path": str(path)}, + repairs=["Preserve the corrupt file and inspect it before retrying."], + ) from error + journal = _validate(data, path=path) + if journal["id"] != identifier: + raise OperationalError( + "IAM recovery journal identity does not match its filename.", + details={ + "requestedId": identifier, + "embeddedId": journal["id"], + "path": str(path), + }, + repairs=["Preserve the journal and inspect it before recovery."], + ) + return journal + + +@dataclass(frozen=True) +class RecoveryHandler: + """Whitelisted forward and compensation executors for one service step.""" + + service_type: str + name: str + forward: Callable[[Mapping[str, object], object], Mapping[str, object] | None] + compensate: Callable[[Mapping[str, object], object], Mapping[str, object] | None] + + +def register_handler( + service_type: str, + name: str, + *, + forward: Callable[[Mapping[str, object], object], Mapping[str, object] | None], + compensate: Callable[[Mapping[str, object], object], Mapping[str, object] | None], +) -> None: + """Whitelist one durable step handler; arbitrary journal code is never executed.""" + if not _ID.fullmatch(service_type) or not _ID.fullmatch(name): + raise ValueError("Recovery service and handler names must be portable IDs.") + key = (service_type, name) + if key in _handlers: + raise ValueError( + f"Recovery handler {service_type}:{name} is already registered." + ) + _handlers[key] = RecoveryHandler(service_type, name, forward, compensate) + + +def clear_handlers() -> None: + """Clear registered executors for isolated tests.""" + _handlers.clear() + + +@dataclass(frozen=True) +class IamJournal: + """Adapter-facing handle for recording an IAM mutation before it occurs.""" + + id: str + + def record_before_mutation( + self, + handler: str, + *, + forward: Mapping[str, object], + compensation: Mapping[str, object], + ) -> str: + return record_before_mutation( + self.id, handler, forward=forward, compensation=compensation + ) + + def mark_completed(self, step_id: str) -> None: + mark_step_completed(self.id, step_id) + + def mark_failure(self, error: BaseException, *, step_id: str | None = None) -> None: + mark_failure(self.id, error, step_id=step_id) + + def finish(self, *, scrub_payloads: bool = False) -> None: + finish_journal(self.id, scrub_payloads=scrub_payloads) + + +def begin_journal( + service_type: str, + account_id: str, + operation: str, + *, + journal_id: str | None = None, + partition: str = "aws", +) -> IamJournal: + """Begin one durable IAM operation without storing any credential material.""" + if not _ID.fullmatch(service_type): + raise OperationalError(f"Invalid IAM recovery service type {service_type!r}.") + if not re.fullmatch(r"\d{12}", account_id): + raise OperationalError(f"Invalid IAM recovery account ID {account_id!r}.") + if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", partition): + raise OperationalError(f"Invalid IAM recovery partition {partition!r}.") + identifier = journal_id or uuid.uuid4().hex + path = _journal_path(identifier) + with _locked(identifier): + if path.exists(): + raise OperationalError( + f"IAM recovery journal {identifier!r} already exists." + ) + timestamp = _now() + journal = { + "schemaVersion": SCHEMA_VERSION, + "id": identifier, + "serviceType": service_type, + "accountId": account_id, + "partition": partition, + "operation": operation, + "status": "active", + "createdAt": timestamp, + "updatedAt": timestamp, + "steps": [], + } + _write(journal, journal_id=identifier) + return IamJournal(identifier) + + +def record_before_mutation( + journal_id: str, + handler: str, + *, + forward: Mapping[str, object], + compensation: Mapping[str, object], +) -> str: + """Persist a pending step before its corresponding AWS mutation is attempted.""" + with _locked(journal_id): + journal = _read_unlocked(journal_id) + if journal["status"] not in {"active", "failed"}: + raise OperationalError( + f"Cannot append to IAM recovery journal in {journal['status']} state." + ) + if (str(journal["serviceType"]), handler) not in _handlers: + raise OperationalError( + f"Recovery handler {journal['serviceType']}:{handler} is not registered." + ) + step_id = uuid.uuid4().hex + timestamp = _now() + journal["steps"].append( + { + "id": step_id, + "handler": handler, + "status": "pending", + "forward": _safe_payload(forward, location="forward"), + "compensation": _safe_payload(compensation, location="compensation"), + "createdAt": timestamp, + "updatedAt": timestamp, + } + ) + journal["status"] = "active" + journal["updatedAt"] = timestamp + journal.pop("failure", None) + _write(journal, journal_id=journal_id) + return step_id + + +def _find_step(journal: Mapping[str, Any], step_id: str) -> dict[str, Any]: + for step in journal["steps"]: + if step["id"] == step_id: + return cast("dict[str, Any]", step) + raise OperationalError(f"IAM recovery step {step_id!r} was not found.") + + +def mark_step_completed( + journal_id: str, + step_id: str, + *, + effect: Mapping[str, object] | None = None, +) -> None: + """Mark a recorded mutation complete only after AWS reports success.""" + with _locked(journal_id): + journal = _read_unlocked(journal_id) + step = _find_step(journal, step_id) + if step["status"] == "rolled_back": + raise OperationalError(f"IAM recovery step {step_id!r} is rolled back.") + timestamp = _now() + step["status"] = "completed" + if effect is not None: + step["effect"] = _safe_payload(effect, location="effect") + step["completedAt"] = timestamp + step["updatedAt"] = timestamp + step.pop("failure", None) + journal["updatedAt"] = timestamp + _write(journal, journal_id=journal_id) + + +def mark_failure( + journal_id: str, error: BaseException, *, step_id: str | None = None +) -> None: + """Persist failure metadata while leaving the failed mutation pending to resume.""" + with _locked(journal_id): + journal = _read_unlocked(journal_id) + timestamp = _now() + failure = {"type": type(error).__name__, "message": str(error), "at": timestamp} + journal["status"] = "failed" + journal["failure"] = failure + journal["updatedAt"] = timestamp + if step_id is not None: + step = _find_step(journal, step_id) + step["failure"] = failure + step["updatedAt"] = timestamp + _write(journal, journal_id=journal_id) + + +def mark_queue_attempt( + journal_id: str, + step_id: str, + *, + attempts: int, + error: BaseException, +) -> None: + """Persist a retryable queue attempt without changing the pending step state.""" + with _locked(journal_id): + journal = _read_unlocked(journal_id) + step = _find_step(journal, step_id) + if step["status"] != "pending": + raise OperationalError( + f"IAM recovery queue step {step_id!r} is not pending." + ) + timestamp = _now() + failure = {"type": type(error).__name__, "message": str(error), "at": timestamp} + step["attempts"] = attempts + step["lastFailure"] = failure + step["updatedAt"] = timestamp + # Persist the scheduler's "retry at the bottom" decision so a crash does + # not silently restore the pre-failure execution order. + journal["steps"].remove(step) + journal["steps"].append(step) + journal["updatedAt"] = timestamp + _write(journal, journal_id=journal_id) + + +def finish_journal(journal_id: str, *, scrub_payloads: bool = False) -> None: + """Mark an operation complete and optionally retain only diagnostic receipts.""" + with _locked(journal_id): + journal = _read_unlocked(journal_id) + pending = [ + step["id"] for step in journal["steps"] if step["status"] == "pending" + ] + if pending: + raise OperationalError( + "Cannot complete an IAM journal with pending steps.", + details={"journalId": journal_id, "pendingStepIds": pending}, + ) + timestamp = _now() + journal["status"] = "completed" + journal["updatedAt"] = timestamp + journal["completedAt"] = timestamp + journal.pop("failure", None) + if scrub_payloads: + for step in journal["steps"]: + forward = step["forward"] + step["forward"] = { + key: forward[key] + for key in ( + "planStepId", + "resourceKey", + "action", + "irreversible", + ) + if key in forward + } + step["compensation"] = {} + step.pop("effect", None) + step.pop("failure", None) + step.pop("lastFailure", None) + journal["payloadsScrubbed"] = True + _write(journal, journal_id=journal_id) + + +def get_journal(journal_id: str) -> dict[str, Any]: + """Read and validate one journal under its process lock.""" + with _locked(journal_id): + return _read_unlocked(journal_id) + + +def list_journals() -> list[dict[str, object]]: + """List durable journals, retaining corrupt-file diagnostics for the operator.""" + directory = recovery_root() + if not directory.exists(): + return [] + result: list[dict[str, object]] = [] + for path in sorted(directory.glob("*.json")): + try: + journal = get_journal(path.stem) + summary = { + key: journal[key] + for key in ( + "id", + "serviceType", + "accountId", + "operation", + "status", + "createdAt", + "updatedAt", + ) + } + if "partition" in journal: + summary["partition"] = journal["partition"] + result.append(summary) + except OperationalError as error: + result.append({"id": path.stem, "status": "corrupt", "error": str(error)}) + return result + + +def _handler( + journal: Mapping[str, object], step: Mapping[str, object] +) -> RecoveryHandler: + key = (str(journal["serviceType"]), str(step["handler"])) + handler = _handlers.get(key) + if handler is None: + raise OperationalError( + f"No whitelisted recovery handler is registered for {key[0]}:{key[1]}.", + details={"serviceType": key[0], "handler": key[1]}, + repairs=["Load the matching Hacksaws adapter and retry recovery."], + ) + return handler + + +def _handler_payload( + journal: Mapping[str, object], + step: Mapping[str, object], + direction: str, +) -> dict[str, object]: + raw = step[direction] + if not isinstance(raw, dict): + raise OperationalError("IAM recovery step payload is invalid.") + payload = dict(raw) + source = payload.pop("effectSourceStep", None) + if source is None: + return payload + source_step = step if source == "self" else _find_step(journal, str(source)) + effect = source_step.get("effect") + if not isinstance(effect, dict): + raise OperationalError( + "IAM recovery cannot prove the AWS identity created by the journal; " + "preserve the current resource and complete recovery manually.", + details={"journalId": journal["id"], "effectSourceStep": source}, + ) + payload["effect"] = effect + return payload + + +def _assert_recovery_scope(journal: Mapping[str, object], context: object) -> None: + """Require exact account and partition proof before any recovery mutation.""" + account_id = getattr(context, "account_id", None) + if account_id != journal["accountId"]: + raise OperationalError( + "Recovery credentials do not match the journal account.", + details={ + "journalAccountId": journal["accountId"], + "callerAccountId": account_id, + }, + ) + recorded_partition = journal.get("partition") + caller_partition = getattr(context, "partition", None) + if recorded_partition is None: + raise OperationalError( + "IAM recovery journal has no recorded AWS partition and cannot be " + "continued or rolled back safely.", + details={"journalId": journal.get("id")}, + repairs=["Preserve the journal and complete recovery manually."], + ) + if caller_partition != recorded_partition: + raise OperationalError( + "Recovery credentials do not match the journal partition.", + details={ + "journalPartition": recorded_partition, + "callerPartition": caller_partition, + }, + ) + + +def continue_journal(journal_id: str, context: object) -> dict[str, Any]: + """Resume all pending forward steps in order, persisting after every success.""" + with _locked(journal_id): + journal = _read_unlocked(journal_id) + _assert_recovery_scope(journal, context) + rolled_back_steps = [ + step["id"] for step in journal["steps"] if step["status"] == "rolled_back" + ] + if journal["status"] in {"rolling_back", "rolled_back"} or rolled_back_steps: + raise OperationalError( + "Cannot continue a journal that entered rollback.", + details={ + "journalId": journal_id, + "status": journal["status"], + "rolledBackStepIds": rolled_back_steps, + }, + repairs=["Resume rollback instead of forward recovery."], + ) + pending_steps = [ + step["id"] for step in journal["steps"] if step["status"] == "pending" + ] + if journal["status"] == "completed": + if pending_steps: + raise OperationalError( + "Completed IAM recovery journal contains pending steps.", + details={ + "journalId": journal_id, + "pendingStepIds": pending_steps, + }, + ) + return journal + for step in journal["steps"]: + if step["status"] != "pending": + continue + handler = _handler(journal, step) + try: + effect = handler.forward( + _handler_payload(journal, step, "forward"), context + ) + except Exception as error: + timestamp = _now() + failure = { + "type": type(error).__name__, + "message": str(error), + "at": timestamp, + } + journal["status"] = "failed" + journal["failure"] = failure + journal["updatedAt"] = timestamp + step["failure"] = failure + step["updatedAt"] = timestamp + _write(journal, journal_id=journal_id) + raise OperationalError( + f"IAM recovery forward step {step['id']} failed: {error}", + data={"journalId": journal_id, "stepId": step["id"]}, + ) from error + timestamp = _now() + if effect is not None: + step["effect"] = _safe_payload(effect, location="effect") + step["status"] = "completed" + step["completedAt"] = timestamp + step["updatedAt"] = timestamp + step.pop("failure", None) + journal["updatedAt"] = timestamp + journal["status"] = "active" + journal.pop("failure", None) + _write(journal, journal_id=journal_id) + timestamp = _now() + journal["status"] = "completed" + journal["completedAt"] = timestamp + journal["updatedAt"] = timestamp + _write(journal, journal_id=journal_id) + return journal + + +def rollback_journal(journal_id: str, context: object) -> dict[str, Any]: + """Compensate every potentially applied step in reverse and persist each success.""" + with _locked(journal_id): + journal = _read_unlocked(journal_id) + _assert_recovery_scope(journal, context) + journal["status"] = "rolling_back" + journal["updatedAt"] = _now() + _write(journal, journal_id=journal_id) + for step in reversed(journal["steps"]): + if step["status"] not in {"pending", "completed"}: + continue + handler = _handler(journal, step) + try: + handler.compensate( + _handler_payload(journal, step, "compensation"), context + ) + except Exception as error: + timestamp = _now() + failure = { + "type": type(error).__name__, + "message": str(error), + "at": timestamp, + } + journal["status"] = "failed" + journal["failure"] = failure + journal["updatedAt"] = timestamp + step["failure"] = failure + step["updatedAt"] = timestamp + _write(journal, journal_id=journal_id) + raise OperationalError( + f"IAM recovery compensation step {step['id']} failed: {error}", + data={"journalId": journal_id, "stepId": step["id"]}, + ) from error + timestamp = _now() + step["status"] = "rolled_back" + step["rolledBackAt"] = timestamp + step["updatedAt"] = timestamp + step.pop("failure", None) + journal["updatedAt"] = timestamp + _write(journal, journal_id=journal_id) + timestamp = _now() + journal["status"] = "rolled_back" + journal["rolledBackAt"] = timestamp + journal["updatedAt"] = timestamp + journal.pop("failure", None) + _write(journal, journal_id=journal_id) + return journal diff --git a/hacksaws/_iam_role_cli.py b/hacksaws/_iam_role_cli.py new file mode 100644 index 0000000..f5a1d7a --- /dev/null +++ b/hacksaws/_iam_role_cli.py @@ -0,0 +1,2285 @@ +"""Command-line adapter for IAM role, trust, and inline-policy workflows.""" + +# ruff: noqa: C901, PLR0911, PLR0912, PLR0915, TRY003 + +from __future__ import annotations + +import argparse +import fnmatch +import json +import os +import re +import shlex +import subprocess +import sys +import tempfile +from collections.abc import Mapping +from dataclasses import asdict +from dataclasses import replace +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from urllib.parse import quote + +import yaml +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError + +from hacksaws import _duration as duration_parser +from hacksaws import _iam_managed_policies as managed +from hacksaws import _iam_policy_cli as policy_cli +from hacksaws import _iam_policy_documents as documents +from hacksaws import _iam_recovery as recovery +from hacksaws import _iam_roles as roles +from hacksaws import _state +from hacksaws._configs import EXIT_CANCELLED +from hacksaws._configs import EXIT_USAGE +from hacksaws._configs import OperationalError +from hacksaws._configs import Result + +if TYPE_CHECKING: + from collections.abc import Iterable + + from hacksaws._iam_cli import IamCommandContext + +name = "role" +_input = input +_editor_runner = subprocess.run +_ROLE_NAME = re.compile(r"^[\w+=,.@-]{1,64}$", re.ASCII) +_PATHLIKE = re.compile(r"^(?:[A-Za-z]:[\\/]|[.~][\\/]|.*[\\/])") +_POLICY_EXTENSIONS = {".json", ".yaml", ".yml", ".toml"} +_RECOVERY_SERVICE = "iam-role" +_CREATE_ROLE_HANDLER = "create-role-with-receipt" + + +def _console_url(context: IamCommandContext, role_name: str) -> str: + region = ( + getattr(getattr(context, "session", None), "region_name", None) or "us-east-1" + ) + return ( + f"https://{region}.console.aws.amazon.com/iam/home?region={region}" + f"#/roles/details/{quote(role_name, safe='')}" + ) + + +class _MutationCancelledError(RuntimeError): + """Signal a fail-closed mutation confirmation without touching AWS.""" + + +class _DryRunCompletedError(RuntimeError): + """Return a fully materialized role plan without creating a journal.""" + + def __init__(self, plan: roles.MutationPlan) -> None: + self.plan = plan + super().__init__(_preview(plan)) + + +def _add_selector_arguments( + parser: argparse.ArgumentParser, *, mutation: bool = False +) -> None: + group = parser.add_argument_group("credential selection") + group.add_argument( + "--profile", + default=argparse.SUPPRESS, + metavar="PROFILE", + help="AWS profile to use.", + ) + group.add_argument( + "--location", + default=argparse.SUPPRESS, + metavar="NAME", + help="Named AWS config directory to use.", + ) + group.add_argument( + "-d", + "--directory", + default=argparse.SUPPRESS, + metavar="PATH", + help="Explicit AWS config directory; conflicts with --location.", + ) + group.add_argument( + "--target", + default=argparse.SUPPRESS, + metavar="NAME", + help="Saved target supplying the credential source.", + ) + group.add_argument( + "--account", + default=argparse.SUPPRESS, + metavar="NAME_OR_ID", + help="Assert the selected AWS account.", + ) + group.add_argument( + "--region", + default=argparse.SUPPRESS, + metavar="REGION", + help="Region used for AWS clients and console links.", + ) + if mutation: + safety = parser.add_argument_group("safety") + safety.add_argument( + "--dry-run", + action="store_true", + default=argparse.SUPPRESS, + help="Validate and show the plan without changing AWS or local state.", + ) + safety.add_argument( + "--yes", + action="store_true", + default=argparse.SUPPRESS, + help="Approve the displayed plan without prompting.", + ) + + +def _leaf( + actions: argparse._SubParsersAction[argparse.ArgumentParser], + command: str, + *, + help_text: str, + mutation: bool = False, +) -> argparse.ArgumentParser: + parser = actions.add_parser(command, help=help_text) + _add_selector_arguments(parser, mutation=mutation) + parser.set_defaults(role_command=command) + return parser + + +def _duration_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--duration", "--ttl") + parser.add_argument("--htl") + parser.add_argument("--mtl") + parser.add_argument("--stl") + + +def _metadata_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--metadata", choices=("nested", "sidecar", "none"), default="none" + ) + parser.add_argument("--sidecar", type=Path) + + +def _export_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--output", "-o", type=Path) + parser.add_argument("--format", choices=("yaml", "json"), default="yaml") + _metadata_arguments(parser) + + +def _condition_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--condition", + action="append", + default=[], + metavar="OPERATOR:KEY=VALUE", + help="Add an exact IAM trust condition; repeat for multiple conditions.", + ) + + +def register(parser: argparse.ArgumentParser) -> None: + """Register the complete role command tree below ``iam role``.""" + actions = parser.add_subparsers(dest="role_command") + + create = _leaf( + actions, "create", help_text="Create a managed IAM role.", mutation=True + ) + create.add_argument("role", help="IAM role name or same-account role ARN.") + create.add_argument("--description", help="Human-readable role description.") + create.add_argument("--path", help="IAM role path (default from configuration).") + create.add_argument( + "--permissions-boundary", + help="Managed policy name or ARN used as the role permissions boundary.", + ) + create.add_argument( + "--tag", + action="append", + default=[], + metavar="KEY=VALUE", + help="Add an IAM tag; repeat for multiple tags.", + ) + create.add_argument( + "--trust-caller", + action="store_true", + help="Trust the exact selected caller identity by default.", + ) + create.add_argument( + "--trust-policy", + type=Path, + help="JSON/YAML/TOML trust-policy file instead of interactive trust setup.", + ) + create.add_argument( + "--case", + choices=("Pascal", "camel", "snake", "kebab"), + help="Override configured name casing.", + ) + create.add_argument("--prefix", help="Override the configured role-name prefix.") + create.add_argument("--suffix", help="Override the configured role-name suffix.") + create.add_argument( + "--naming-enforcement", + choices=("off", "warn", "error"), + help="Override naming-rule enforcement for this command.", + ) + create.add_argument( + "--replace", + action="store_true", + help="Update an existing role only after a conflict preview; never delete it.", + ) + _metadata_arguments(create) + _duration_arguments(create) + + get = _leaf(actions, "get", help_text="Show comprehensive IAM role state.") + get.add_argument("role") + + listing = _leaf(actions, "list", help_text="List IAM roles.") + listing.add_argument("patterns", nargs="*") + scope = listing.add_mutually_exclusive_group() + scope.add_argument("--custom", action="store_true") + scope.add_argument("--all", action="store_true") + scope.add_argument("--service", action="store_true") + listing.add_argument("--wide", action="store_true") + listing.add_argument("--probe", action="store_true") + + update = _leaf( + actions, "update", help_text="Update mutable role fields.", mutation=True + ) + update.add_argument("role") + update.add_argument("--description") + update.add_argument("--clear-description", action="store_true") + update.add_argument("--permissions-boundary") + update.add_argument("--clear-permissions-boundary", action="store_true") + update.add_argument("--trust-policy", type=Path) + _metadata_arguments(update) + _duration_arguments(update) + + delete = _leaf( + actions, "delete", help_text="Delete an IAM role safely.", mutation=True + ) + delete.add_argument("role") + delete.add_argument("--cascade", action="store_true") + delete.add_argument("--remove-from-instance-profiles", action="store_true") + delete.add_argument("--unmanaged", action="store_true") + delete.add_argument("--service-role", action="store_true") + + attach = _leaf( + actions, "attach", help_text="Attach or publish a role policy.", mutation=True + ) + attach.add_argument("role") + attach.add_argument("policy") + attach.add_argument("--inline", action="store_true") + attach.add_argument("--policy-name") + attach.add_argument("--path") + _metadata_arguments(attach) + + detach = _leaf( + actions, "detach", help_text="Detach a managed role policy.", mutation=True + ) + detach.add_argument("role") + detach.add_argument("policy") + + adopt = _leaf(actions, "adopt", help_text="Adopt an existing role.", mutation=True) + adopt.add_argument("role") + adopt.add_argument("--owner") + adopt.add_argument("--audit-id") + release = _leaf( + actions, "release", help_text="Release a managed role.", mutation=True + ) + release.add_argument("role") + + tag = _leaf(actions, "tag", help_text="Manage role tags.") + tag_actions = tag.add_subparsers(dest="role_tag_action") + tag_list = tag_actions.add_parser("list") + _add_selector_arguments(tag_list) + tag_list.add_argument("role") + tag_set = tag_actions.add_parser("set") + _add_selector_arguments(tag_set, mutation=True) + tag_set.add_argument("role") + tag_set.add_argument("tags", nargs="+") + tag_remove = tag_actions.add_parser("remove") + _add_selector_arguments(tag_remove, mutation=True) + tag_remove.add_argument("role") + tag_remove.add_argument("keys", nargs="+") + + inline = _leaf(actions, "inline-policy", help_text="Manage inline role policies.") + inline_actions = inline.add_subparsers(dest="role_inline_action") + for action in ("list", "get"): + item = inline_actions.add_parser(action) + _add_selector_arguments(item) + item.add_argument("role") + if action == "get": + item.add_argument("policy") + inline_export = inline_actions.add_parser("export") + _add_selector_arguments(inline_export) + inline_export.add_argument("role") + inline_export.add_argument("policy") + _export_arguments(inline_export) + inline_put = inline_actions.add_parser("put") + _add_selector_arguments(inline_put, mutation=True) + inline_put.add_argument("role") + inline_put.add_argument("policy") + inline_put.add_argument("file", type=Path) + _metadata_arguments(inline_put) + inline_edit = inline_actions.add_parser("edit") + _add_selector_arguments(inline_edit, mutation=True) + inline_edit.add_argument("role") + inline_edit.add_argument("policy") + inline_delete = inline_actions.add_parser("delete") + _add_selector_arguments(inline_delete, mutation=True) + inline_delete.add_argument("role") + inline_delete.add_argument("policy") + + trust = _leaf(actions, "trust", help_text="Manage role trust policies.") + trust_actions = trust.add_subparsers(dest="role_trust_action") + for action in ("get", "set", "edit", "check"): + item = trust_actions.add_parser(action) + _add_selector_arguments(item, mutation=action in {"set", "edit"}) + item.add_argument("role") + if action == "set": + item.add_argument("file", type=Path) + _metadata_arguments(item) + if action == "check": + item.add_argument("--probe", action="store_true") + trust_export = trust_actions.add_parser("export") + _add_selector_arguments(trust_export) + trust_export.add_argument("role") + _export_arguments(trust_export) + + for action in ("add", "remove"): + command = trust_actions.add_parser(action) + _add_selector_arguments(command) + kinds = command.add_subparsers(dest="role_trust_kind") + for kind in ("user", "role", "account", "principal"): + item = kinds.add_parser(kind) + _add_selector_arguments(item, mutation=True) + item.add_argument("target_role") + item.add_argument("principal") + item.add_argument("--principal-account") + if action == "add": + item.add_argument("--sid") + _condition_arguments(item) + members = kinds.add_parser("group-members") + _add_selector_arguments(members, mutation=True) + members.add_argument("group") + members.add_argument("members", nargs="+") + + sync = trust_actions.add_parser("sync") + _add_selector_arguments(sync) + sync_kinds = sync.add_subparsers(dest="role_trust_kind") + sync_members = sync_kinds.add_parser("group-members") + _add_selector_arguments(sync_members, mutation=True) + sync_members.add_argument("group") + sync_members.add_argument("members", nargs="*") + + for action in ("grant", "revoke"): + command = trust_actions.add_parser(action) + _add_selector_arguments(command) + kinds = command.add_subparsers(dest="role_trust_kind") + group = kinds.add_parser("group") + _add_selector_arguments(group, mutation=True) + group.add_argument("target_role") + group.add_argument("group") + + +def _service(context: IamCommandContext) -> roles.IamRoleService: + return roles.IamRoleService(context.iam) + + +def _error_code(error: ClientError) -> str: + return str(error.response.get("Error", {}).get("Code", "")) + + +def _iam_call( + context: IamCommandContext, action: str, payload: Mapping[str, object] +) -> None: + params = payload.get("params") + if not isinstance(params, Mapping): + raise OperationalError("Role recovery step parameters are invalid.") + try: + getattr(context.iam, action)(**dict(params)) + except ClientError as error: + code = _error_code(error) + if code == "NoSuchEntity" and action.startswith( + ("delete_", "detach_", "remove_", "untag_") + ): + return + raise + + +def _effect_role_id(payload: Mapping[str, object]) -> str | None: + effect = payload.get("effect") + if effect is None: + return None + if not isinstance(effect, Mapping) or not isinstance(effect.get("roleId"), str): + raise OperationalError("IAM recovery role identity receipt is invalid.") + role_id = str(effect["roleId"]) + if not role_id: + raise OperationalError("IAM recovery role identity receipt is empty.") + return role_id + + +def _create_role_state_matches( + current: roles.RoleSnapshot, params: Mapping[str, object] +) -> bool: + raw_tags = params.get("Tags", []) + raw_duration = params.get("MaxSessionDuration", 3600) + if not isinstance(raw_tags, list) or not isinstance(raw_duration, int): + return False + desired_tags = { + str(item["Key"]): str(item.get("Value", "")) + for item in raw_tags + if isinstance(item, Mapping) and "Key" in item + } + return ( + current.name == str(params.get("RoleName", "")) + and current.path == str(params.get("Path", "/")) + and roles.document_hash(current.trust) + == roles.document_hash( + roles.decode_document(params.get("AssumeRolePolicyDocument", {})) + ) + and current.description == params.get("Description") + and current.max_session_duration == raw_duration + and current.permissions_boundary == params.get("PermissionsBoundary") + and dict(current.tags) == desired_tags + and not current.attached_policies + and not current.inline_policies + and not current.instance_profiles + ) + + +def _create_role_with_receipt( + payload: Mapping[str, object], context: IamCommandContext +) -> Mapping[str, object]: + params = payload.get("params") + if not isinstance(params, Mapping): + raise OperationalError("Role create recovery parameters are invalid.") + receipt_role_id = _effect_role_id(payload) + try: + response = context.iam.create_role(**dict(params)) + except ClientError as error: + if _error_code(error) != "EntityAlreadyExists": + raise + if receipt_role_id is None: + raise OperationalError( + "A role exists at the create target but this pending journal has no " + "durable AWS RoleId receipt proving it created that role. Preserve " + "the role and recover manually." + ) from error + current = _service(context).get_role(str(params["RoleName"])) + if current.role_id != receipt_role_id or not _create_role_state_matches( + current, params + ): + raise OperationalError( + "The live role does not match the journal's immutable RoleId receipt " + "and expected create state; refusing adoption." + ) from error + return {"roleId": receipt_role_id} + role = response.get("Role") if isinstance(response, Mapping) else None + role_id = role.get("RoleId") if isinstance(role, Mapping) else None + if not isinstance(role_id, str) or not role_id: + raise OperationalError( + "AWS created the role but returned no immutable RoleId receipt. The " + "result is ambiguous; preserve the role and recover manually." + ) + return {"roleId": role_id} + + +def _delete_created_role_with_receipt( + payload: Mapping[str, object], context: IamCommandContext +) -> None: + params = payload.get("params") + role_id = _effect_role_id(payload) + if not isinstance(params, Mapping) or role_id is None: + raise OperationalError( + "Create rollback has no durable AWS RoleId receipt. Preserve any role " + "at the target name and recover manually." + ) + role_name = str(params.get("RoleName", "")) + try: + current = _service(context).get_role(role_name) + except ClientError as error: + if _error_code(error) == "NoSuchEntity": + return + raise + if current.role_id != role_id: + raise OperationalError( + "The live role's immutable RoleId does not match the create receipt; " + "preserving it and requiring manual recovery." + ) + context.iam.delete_role(RoleName=role_name) + + +def _managed_service(context: IamCommandContext) -> managed.IamManagedPolicyService: + return managed.IamManagedPolicyService( + context.iam, + context.sts, + getattr(context, "access_analyzer", None), + managed.PolicyServiceOptions( + account_id=context.account_id, + partition=context.partition, + owned_path=_config_path(), + ), + ) + + +def _managed_record( + service: managed.IamManagedPolicyService, arn: str +) -> managed.ManagedPolicyRecord | None: + try: + return service.get_policy( + arn, include_document=True, include_versions=True, include_tags=True + ) + except ClientError as error: + if _error_code(error) == "NoSuchEntity": + return None + raise + + +def _managed_tag_hash(tags: Iterable[managed.Tag]) -> str: + return roles.document_hash({tag.key: tag.value for tag in tags}) + + +def _require_policy_identity( + record: managed.ManagedPolicyRecord, resource_id: str +) -> None: + values = {tag.key.casefold(): tag.value for tag in record.tags} + if not ( + values.get("hacksaws:managed-by") == "hacksaws" + and values.get("hacksaws:resource-kind") == "managed-policy" + and values.get("hacksaws:resource-id") == resource_id + ): + raise OperationalError( + f"Managed policy {record.arn.value} is not the exact Hacksaws-owned " + f"resource {resource_id!r}." + ) + + +def _policy_state_hash(state: Mapping[str, object]) -> str: + """Hash durable policy semantics while ignoring AWS-assigned version IDs.""" + if state.get("exists") is not True: + return roles.document_hash({"exists": False, "arn": str(state.get("arn", ""))}) + versions: list[dict[str, object]] = [] + raw_versions = state.get("versions", []) + if isinstance(raw_versions, list): + for item in raw_versions: + if isinstance(item, Mapping) and isinstance(item.get("document"), Mapping): + document = dict(item["document"]) + versions.append( + { + "default": item.get("default") is True, + "document": document, + "digest": managed.policy_digest(document), + } + ) + versions.sort(key=lambda item: (str(item["digest"]), bool(item["default"]))) + raw_tags = state.get("tags", []) + tags = ( + { + str(item["Key"]): str(item.get("Value", "")) + for item in raw_tags + if isinstance(item, Mapping) and "Key" in item + } + if isinstance(raw_tags, list) + else {} + ) + raw_dependencies = state.get("dependencies", {}) + dependencies = ( + { + str(key): sorted(str(value) for value in values) + for key, values in raw_dependencies.items() + if isinstance(values, list) + } + if isinstance(raw_dependencies, Mapping) + else {} + ) + return roles.document_hash( + { + "exists": True, + "arn": str(state.get("arn", "")), + "name": str(state.get("name", "")), + "path": str(state.get("path", "")), + "description": state.get("description"), + "tags": tags, + "versions": versions, + "dependencies": dependencies, + } + ) + + +def _policy_state( + service: managed.IamManagedPolicyService, + record: managed.ManagedPolicyRecord, +) -> dict[str, object]: + return policy_cli._policy_state( # noqa: SLF001 + record, dependencies=service.policy_dependencies(record.arn.value) + ) + + +def _materialize_managed_operation( + operation: roles.Operation, context: IamCommandContext +) -> roles.Operation: + """Convert a logical publication into complete durable reconcile states.""" + payload = operation.params + arn = str(payload["PolicyArn"]) + resource_id = str(payload["ResourceId"]) + document = roles.decode_document(payload["PolicyDocument"]) + service = _managed_service(context) + current = _managed_record(service, arn) + if current is None: + caller = managed.CallerIdentity( + context.account_id, context.partition, context.arn, context.arn + ) + change = service.plan_create( + str(payload["PolicyName"]), + document, + options=managed.CreatePolicyOptions( + path=str(payload["Path"]), + resource_id=resource_id, + caller=caller, + include_aws_validation=False, + ), + ) + else: + _require_policy_identity(current, resource_id) + change = service.plan_publish(arn, document, include_aws_validation=False) + forward, compensation = policy_cli._change_states( # noqa: SLF001 + service, change + ) + if current is not None: + dependencies = policy_cli._dependency_payload( # noqa: SLF001 + service.policy_dependencies(current.arn.value) + ) + forward["dependencies"] = dependencies + compensation["dependencies"] = dependencies + return replace( + operation, + params={ + "State": forward, + "ExpectedStateHash": _policy_state_hash(compensation), + "ResourceId": resource_id, + }, + compensate_params={ + "State": compensation, + "ExpectedStateHash": _policy_state_hash(forward), + "ResourceId": resource_id, + }, + ) + + +def _materialize_managed_operations( + plan: roles.MutationPlan, context: IamCommandContext +) -> roles.MutationPlan: + return replace( + plan, + operations=tuple( + _materialize_managed_operation(operation, context) + if operation.client == "managed_policy" and "State" not in operation.params + else operation + for operation in plan.operations + ), + ) + + +def _publish_owned_policy( + payload: Mapping[str, object], context: IamCommandContext +) -> None: + raw_state = payload.get("State") + if not isinstance(raw_state, Mapping): + raise OperationalError("Managed-policy recovery state is invalid.") + state = dict(raw_state) + arn = str(state.get("arn", "")) + resource_id = str(payload["ResourceId"]) + service = _managed_service(context) + current = _managed_record(service, arn) + if current is not None: + _require_policy_identity(current, resource_id) + live: Mapping[str, object] = _policy_state(service, current) + else: + live = {"exists": False, "arn": arn} + live_hash = _policy_state_hash(live) + desired_hash = _policy_state_hash(state) + if live_hash == desired_hash: + return + if live_hash != str(payload["ExpectedStateHash"]): + raise OperationalError( + f"Managed policy {arn} changed after planning; no reconciliation was made." + ) + policy_cli._reconcile_policy(state, context) # noqa: SLF001 + updated = _managed_record(service, arn) + if updated is not None: + _require_policy_identity(updated, resource_id) + observed: Mapping[str, object] = _policy_state(service, updated) + else: + observed = {"exists": False, "arn": arn} + if _policy_state_hash(observed) != desired_hash: + raise OperationalError(f"Managed policy {arn} reconciliation was incomplete.") + + +def _restore_owned_policy( + payload: Mapping[str, object], context: IamCommandContext +) -> None: + _publish_owned_policy(payload, context) + + +_IAM_HANDLER_PAIRS = { + ("delete_role", None), + ("update_role", "update_role"), + ("update_assume_role_policy", "update_assume_role_policy"), + ("put_role_permissions_boundary", "delete_role_permissions_boundary"), + ("put_role_permissions_boundary", "put_role_permissions_boundary"), + ("delete_role_permissions_boundary", "put_role_permissions_boundary"), + ("tag_role", "tag_role"), + ("tag_role", "untag_role"), + ("untag_role", "tag_role"), + ("untag_role", None), + ("attach_role_policy", "detach_role_policy"), + ("detach_role_policy", "attach_role_policy"), + ("put_role_policy", "put_role_policy"), + ("put_role_policy", "delete_role_policy"), + ("delete_role_policy", "put_role_policy"), + ("remove_role_from_instance_profile", "add_role_to_instance_profile"), + ("attach_group_policy", "detach_group_policy"), +} + + +def _handler_name(action: str, compensation: str | None) -> str: + return f"{action}--{compensation or 'none'}".replace("_", "-") + + +def ensure_role_recovery_handlers() -> None: + """Register only fixed IAM role mutations with idempotent recovery wrappers.""" + for action, compensation in _IAM_HANDLER_PAIRS: + name_value = _handler_name(action, compensation) + + def forward( + payload: Mapping[str, object], + context: object, + *, + selected: str = action, + ) -> None: + _iam_call(context, selected, payload) # type: ignore[arg-type] + + def compensate( + payload: Mapping[str, object], + context: object, + *, + selected: str | None = compensation, + forward_action: str = action, + ) -> None: + if selected is None and forward_action == "delete_role": + params = payload.get("params") + if not isinstance(params, Mapping): + raise OperationalError( + "Irreversible role-delete recovery state is invalid." + ) + role_name = str(params.get("RoleName", "")) + expected_role_id = str(params.get("ExpectedRoleId", "")) + try: + current = _service(context).get_role(role_name) # type: ignore[arg-type] + except ClientError as error: + if _error_code(error) != "NoSuchEntity": + raise + else: + if expected_role_id and current.role_id == expected_role_id: + return + raise OperationalError( + "Role deletion crossed an irreversible AWS principal-identity " + "commit point. Hacksaws will not recreate the role and claim a " + "complete rollback; restore it and dependent resource policies " + "manually." + ) + if selected is not None: + _iam_call(context, selected, payload) # type: ignore[arg-type] + + try: + recovery.register_handler( + _RECOVERY_SERVICE, + name_value, + forward=forward, + compensate=compensate, + ) + except ValueError as error: + if "already registered" not in str(error): + raise + try: + recovery.register_handler( + _RECOVERY_SERVICE, + _CREATE_ROLE_HANDLER, + forward=_create_role_with_receipt, # type: ignore[arg-type] + compensate=_delete_created_role_with_receipt, # type: ignore[arg-type] + ) + except ValueError as error: + if "already registered" not in str(error): + raise + try: + recovery.register_handler( + _RECOVERY_SERVICE, + "publish-owned-policy--restore-owned-policy", + forward=_publish_owned_policy, # type: ignore[arg-type] + compensate=_restore_owned_policy, # type: ignore[arg-type] + ) + except ValueError as error: + if "already registered" not in str(error): + raise + + +def _preview(plan: roles.MutationPlan) -> str: + lines = [f"Plan: {plan.kind}", "Resources:"] + lines.extend(f" - {resource}" for resource in plan.resources) + lines.append("AWS mutations:") + lines.extend( + f" - {operation.client}:{operation.action}" for operation in plan.operations + ) + lines.extend(f"Warning: {warning}" for warning in plan.warnings) + return "\n".join(lines) + + +ensure_role_recovery_handlers() + + +def _confirm_plan(args: argparse.Namespace, plan: roles.MutationPlan) -> bool: + if not plan.operations or bool(getattr(args, "yes", False)): + return True + if bool(getattr(args, "json", False)) or not sys.stdin.isatty(): + return False + if plan.kind == "role-delete": + role_name = plan.resources[0].rsplit("/", maxsplit=1)[-1] + return ( + _input( + f"{_preview(plan)}\nType the role name {role_name!r} to confirm the " + "irreversible delete commit: " + ).strip() + == role_name + ) + return ( + _input(f"{_preview(plan)}\nType 'yes' to apply this exact plan: ").strip() + == "yes" + ) + + +def _assert_preconditions(plan: roles.MutationPlan, context: IamCommandContext) -> None: + service = _service(context) + if "role" in plan.expected: + current = service.get_role(_role_name(plan.resources[0], context)) + if roles.role_snapshot_hash(current) != plan.expected["role"]: + raise OperationalError( + "IAM role changed after planning; no mutation was made." + ) + if "trust" in plan.expected: + current_trust = service.get_trust(_role_name(plan.resources[0], context)) + if roles.document_hash(current_trust) != plan.expected["trust"]: + raise OperationalError( + "Trust policy changed after planning; no mutation was made." + ) + if "inline" in plan.expected: + role_name, policy_name = plan.resources[:2] + try: + current_inline = service.get_inline_policy(role_name, policy_name) + digest = roles.document_hash(current_inline) + except ClientError as error: + if _error_code(error) != "NoSuchEntity": + raise + digest = "absent" + if digest != plan.expected["inline"]: + raise OperationalError( + "Inline policy changed after planning; no mutation was made." + ) + if "group" in plan.expected: + group_name = plan.expected.get("groupName") + if not group_name: + raise OperationalError("Group mutation plan has no group identity.") + current_group = _group_snapshot(group_name, context) + digest = ( + roles.document_hash(current_group.document) + if current_group.exists + else "absent" + ) + if ( + digest != plan.expected["group"] + or current_group.policy_arn != plan.expected.get("groupPolicyArn") + or str(current_group.attached).lower() != plan.expected.get("groupAttached") + ): + raise OperationalError( + "IAM group aggregate policy or attachment changed after planning; " + "no mutation was made." + ) + + +def _execute( + plan: roles.MutationPlan, + context: IamCommandContext, + args: argparse.Namespace, +) -> recovery.IamJournal | None: + plan = _materialize_managed_operations(plan, context) + if bool(getattr(args, "dry_run", False)): + raise _DryRunCompletedError(plan) + if not _confirm_plan(args, plan): + raise _MutationCancelledError(_preview(plan)) + if not plan.operations: + return None + _assert_preconditions(plan, context) + ensure_role_recovery_handlers() + prepared: list[tuple[str, dict[str, object], dict[str, object]]] = [] + for operation in plan.operations: + if operation.client == "managed_policy": + handler = "publish-owned-policy--restore-owned-policy" + forward = dict(operation.params) + compensation = dict(operation.compensate_params or {}) + elif operation.action == "create_role": + handler = _CREATE_ROLE_HANDLER + forward = {"params": dict(operation.params)} + compensation = { + "params": dict(operation.compensate_params or {}), + "effectSourceStep": "self", + } + else: + pair = (operation.action, operation.compensate_action) + if pair not in _IAM_HANDLER_PAIRS: + raise OperationalError( + f"Role mutation {pair!r} has no whitelisted recovery handler." + ) + handler = _handler_name(*pair) + forward = {"params": dict(operation.params)} + compensation = {"params": dict(operation.compensate_params or {})} + prepared.append((handler, forward, compensation)) + journal = recovery.begin_journal( + _RECOVERY_SERVICE, + context.account_id, + plan.kind, + partition=context.partition, + ) + for handler, forward, compensation in prepared: + journal.record_before_mutation( + handler, forward=forward, compensation=compensation + ) + recovery.continue_journal(journal.id, context) + return journal + + +def _role_name(reference: str, context: IamCommandContext) -> str: + if not reference.startswith("arn:"): + if not _ROLE_NAME.fullmatch(reference): + raise OperationalError(f"Invalid IAM role name {reference!r}.") + return reference + match = roles.ROLE_ARN.fullmatch(reference) + if match is None: + raise OperationalError(f"Invalid IAM role ARN {reference!r}.") + if match.group(1) != context.partition or match.group(2) != context.account_id: + raise OperationalError("Role ARN does not belong to the selected AWS account.") + return match.group(3).rsplit("/", maxsplit=1)[-1] + + +def _parse_tags(values: Iterable[str]) -> dict[str, str]: + result: dict[str, str] = {} + for value in values: + key, separator, tag_value = value.partition("=") + if not separator or not key: + raise OperationalError(f"Tag {value!r} must use KEY=VALUE syntax.") + if key in result: + raise OperationalError(f"Tag key {key!r} was supplied more than once.") + if key.casefold().startswith("hacksaws:"): + raise OperationalError( + "Reserved hacksaws: tags may be changed only with adopt or release." + ) + result[key] = tag_value + return result + + +def _account_refs() -> dict[str, roles.AccountRef]: + configured = _state.load_config().get("accounts", {}) + return { + key.casefold(): roles.AccountRef( + key, + str(value["id"]), + str(value.get("partition", "aws")), + ) + for key, value in configured.items() + if isinstance(value, dict) and "id" in value + } + + +def _account_name(context: IamCommandContext) -> str | None: + for account in _account_refs().values(): + if ( + account.account_id == context.account_id + and account.partition == context.partition + ): + return account.name + return None + + +def _config_path() -> str: + data = _state.load_config() + value = data.get("iam", {}).get("path", roles.DEFAULT_ROLE_PATH) + return roles.normalize_path(str(value)) + + +def _apply_case(value: str, style: str) -> str: + words = [item for item in re.split(r"[^A-Za-z0-9]+", value) if item] + if not words: + return value + if style == "snake": + return "_".join(item.lower() for item in words) + if style == "kebab": + return "-".join(item.lower() for item in words) + pascal = "".join(item[:1].upper() + item[1:] for item in words) + return pascal[:1].lower() + pascal[1:] if style == "camel" else pascal + + +def _configured_name( + raw: str, args: argparse.Namespace, context: IamCommandContext +) -> tuple[str, str | None]: + explicit = { + key: getattr(args, key) + for key in ("case", "prefix", "suffix", "naming_enforcement") + if getattr(args, key, None) is not None + } + if "naming_enforcement" in explicit: + explicit["enforcement"] = explicit.pop("naming_enforcement") + naming = _state.resolve_naming( + _state.load_config(), + resource="role", + account=_account_name(context), + explicit=explicit, + ) + desired = ( + f"{naming['prefix']}{_apply_case(raw, str(naming['case']))}{naming['suffix']}" + ) + enforcement = str(naming["enforcement"]) + if raw != desired and enforcement == "error": + raise OperationalError( + f"Role name {raw!r} violates naming policy; use {desired!r}." + ) + warning = ( + f"Naming policy suggests {desired!r}." + if raw != desired and enforcement == "warn" + else None + ) + return (desired if enforcement != "off" else raw), warning + + +def _duration(args: argparse.Namespace, default: int = 3600) -> int: + return duration_parser.session_duration( + duration=getattr(args, "duration", None), + htl=getattr(args, "htl", None), + mtl=getattr(args, "mtl", None), + stl=getattr(args, "stl", None), + default=default, + ) + + +def _load_document(path: Path, args: argparse.Namespace) -> dict[str, Any]: + try: + loaded = documents.load_policy_input( + path, + metadata_mode=documents.MetadataMode(getattr(args, "metadata", "none")), + sidecar=getattr(args, "sidecar", None), + ) + except (documents.PolicyInputError, OSError) as error: + raise OperationalError(str(error)) from error + return dict(loaded.document) + + +def _trust_for_create( + args: argparse.Namespace, context: IamCommandContext +) -> dict[str, Any]: + if args.trust_policy and args.trust_caller: + raise OperationalError("Use only one of --trust-policy and --trust-caller.") + if args.trust_policy: + return _load_document(args.trust_policy, args) + if not args.trust_caller and not sys.stdin.isatty(): + raise OperationalError( + "Noninteractive role creation requires --trust-caller or --trust-policy." + ) + caller = roles.resolve_principal( + roles.PrincipalRef("caller", context.arn), {}, caller_arn=context.arn + ) + return { + "Version": "2012-10-17", + "Statement": [roles.trust_statement(caller, "HacksawsExactCaller")], + } + + +def _role_data(role: roles.RoleSnapshot) -> dict[str, Any]: + return { + "name": role.name, + "arn": role.arn, + "path": role.path, + "description": role.description, + "maxSessionDuration": role.max_session_duration, + "permissionsBoundary": role.permissions_boundary, + "tags": dict(sorted(role.tags.items())), + "trust": role.trust, + "attachedPolicies": list(role.attached_policies), + "inlinePolicies": dict(sorted(role.inline_policy_documents.items())), + "instanceProfiles": list(role.instance_profiles), + } + + +def _mapping_text(data: Mapping[str, Any]) -> str: + return "\n".join( + f"{key}: {json.dumps(value, default=str)}" for key, value in data.items() + ) + + +def _caller_trust(role: roles.RoleSnapshot, context: IamCommandContext) -> bool | None: + caller = roles.normalize_caller_principal(context.arn) + account = f"arn:{context.partition}:iam::{context.account_id}:root" + found_condition = False + statements = role.trust.get("Statement", []) + if isinstance(statements, dict): + statements = [statements] + if not isinstance(statements, list): + return None + for statement in statements: + if not isinstance(statement, dict) or statement.get("Effect") != "Allow": + continue + principal = statement.get("Principal") + aws = principal.get("AWS") if isinstance(principal, dict) else None + values = aws if isinstance(aws, list) else [aws] + if caller in values or account in values: + if statement.get("Condition"): + found_condition = True + elif statement.get("Action") == "sts:AssumeRole": + return True + return None if found_condition else False + + +def _matches(role: roles.RoleSnapshot, patterns: Iterable[str]) -> bool: + values = tuple(patterns) + return not values or any( + fnmatch.fnmatchcase(role.name.casefold(), pattern.casefold()) + or fnmatch.fnmatchcase(role.arn.casefold(), pattern.casefold()) + for pattern in values + ) + + +def _list_result(args: argparse.Namespace, context: IamCommandContext) -> Result: + service = _service(context) + summaries = service.list_roles(path_prefix="/") + hydrated = [service.get_role(item.name) for item in summaries] + selected: list[tuple[roles.RoleSnapshot, str, str]] = [] + warnings: list[str] = [] + if args.probe: + warnings.append( + "Probe performs live sts:AssumeRole calls; attempts may be recorded " + "in CloudTrail." + ) + for role in hydrated: + owned = role.tags.get(roles.MANAGED_TAG) == "true" + service_role = role.path.startswith("/aws-service-role/") + trust = _caller_trust(role, context) + classification = roles.classify_assumability( + trust_allows=trust, identity_allows=None + ).classification + if args.service: + include = service_role + elif args.all: + include = True + elif args.custom: + include = not owned and not service_role + else: + include = owned and classification != "denied" + if not include or not _matches(role, args.patterns): + continue + probe = "" + if args.probe: + try: + roles.BotoStsProbe(context.sts).probe(role.arn, "hacksaws-role-check") + probe = "ok" + except ClientError as error: + probe = ( + "denied" + if _error_code(error) in {"AccessDenied", "AccessDeniedException"} + else "indeterminate" + ) + warnings.append(f"{role.name}: probe failed: {error}") + except BotoCoreError as error: + probe = "indeterminate" + warnings.append(f"{role.name}: probe failed: {error}") + selected.append((role, classification, probe)) + columns = ["NAME", "PATH", "FLAGS"] + if args.wide: + columns += ["ARN", "DURATION", "BOUNDARY", "TAGS"] + if args.probe: + columns.append("PROBE") + rows: list[list[str]] = [] + for role, classification_value, probe in selected: + flags = "".join( + flag + for enabled, flag in ( + (role.tags.get(roles.MANAGED_TAG) == "true", "O"), + (classification_value == "potentially-allowed", "A"), + (classification_value == "indeterminate", "?"), + (role.path.startswith("/aws-service-role/"), "S"), + ) + if enabled + ) + row = [role.name, role.path, flags or "-"] + if args.wide: + row += [ + role.arn, + str(role.max_session_duration), + role.permissions_boundary or "-", + ",".join(f"{k}={v}" for k, v in sorted(role.tags.items())) or "-", + ] + if args.probe: + row.append(probe) + rows.append(row) + widths = [ + max([len(column), *(len(row[index]) for row in rows)]) + for index, column in enumerate(columns) + ] + lines = [ + " ".join(column.ljust(widths[index]) for index, column in enumerate(columns)) + ] + lines.extend( + " ".join(value.ljust(widths[index]) for index, value in enumerate(row)) + for row in rows + ) + legend: list[str] = [] + if any(role.tags.get(roles.MANAGED_TAG) == "true" for role, _, _ in selected): + legend.append("O=Hacksaws-owned") + if any( + classification == "potentially-allowed" for _, classification, _ in selected + ): + legend.append("A=potentially assumable") + if any(classification == "indeterminate" for _, classification, _ in selected): + legend.append("?=assumability indeterminate") + if any(role.path.startswith("/aws-service-role/") for role, _, _ in selected): + legend.append("S=service-linked") + if legend: + lines.append("Legend: " + "; ".join(legend)) + lines.extend(f"Warning: {warning}" for warning in warnings) + data = { + "roles": [ + {**_role_data(role), "assumability": classification, "probe": probe or None} + for role, classification, probe in selected + ], + "warnings": warnings, + } + return Result("IAM_ROLE_LIST", "\n".join(lines), data=data) + + +def _confirm_exact(action: str, expected: str, *, yes: bool) -> bool: + if yes: + return True + if not sys.stdin.isatty(): + return False + return _input(f"{action}. Type {expected!r} to confirm: ").strip() == expected + + +def _looks_like_file(value: str) -> bool: + path = Path(value).expanduser() + return ( + bool(_PATHLIKE.match(value)) + or path.suffix.casefold() in _POLICY_EXTENSIONS + or path.is_file() + ) + + +def _resolve_policy_arn(reference: str, context: IamCommandContext) -> str: + if reference.startswith("arn:"): + return reference + matches: list[str] = [] + paginator = context.iam.get_paginator("list_policies") + for scope in ("Local", "AWS"): + for page in paginator.paginate(Scope=scope): + matches.extend( + str(item["Arn"]) + for item in page.get("Policies", []) + if str(item.get("PolicyName", "")).casefold() == reference.casefold() + ) + matches = sorted(set(matches)) + if not matches: + raise OperationalError(f"Managed policy {reference!r} was not found.") + if len(matches) > 1: + raise OperationalError( + f"Managed policy {reference!r} is ambiguous; specify its ARN." + ) + return matches[0] + + +def _owned_publish_attach_plan( + role: roles.RoleSnapshot, + policy_name: str, + document: Mapping[str, Any], + path: str, + context: IamCommandContext, +) -> roles.MutationPlan: + """Plan a rerunnable owned publication followed by an idempotent attachment.""" + selected_path = roles.normalize_path(path) + arn = ( + f"arn:{context.partition}:iam::{context.account_id}:policy" + f"{selected_path}{policy_name}" + ) + service = _managed_service(context) + current = _managed_record(service, arn) + resource_id = f"role-attachment-{roles.document_hash(arn)[:24]}" + if current is not None: + _require_policy_identity(current, resource_id) + if current is not None and current.document is None: + raise OperationalError(f"Managed policy {arn} has no active document.") + current_document = current.document if current is not None else None + expected = ( + roles.document_hash(current_document) + if current_document is not None + else "absent" + ) + desired_hash = roles.document_hash(document) + publication = roles.Operation( + "managed_policy", + "publish_owned_policy", + { + "PolicyArn": arn, + "PolicyName": policy_name, + "Path": selected_path, + "PolicyDocument": dict(document), + "ExpectedDocumentHash": expected, + **( + { + "ExpectedDefaultVersionId": current.default_version_id, + "ExpectedTagHash": _managed_tag_hash(current.tags), + } + if current is not None + else {} + ), + "ResourceId": resource_id, + }, + "restore_owned_policy", + { + "PolicyArn": arn, + "PolicyName": policy_name, + "Path": selected_path, + "PolicyDocument": ( + dict(current_document) if current_document is not None else {} + ), + "ExpectedDocumentHash": desired_hash, + **( + {"ExpectedTagHash": _managed_tag_hash(current.tags)} + if current is not None + else {} + ), + "DeleteIfCreated": current is None, + "ResourceId": resource_id, + }, + ) + attachment = roles.plan_attach_policy(role.name, arn, current=role) + return roles.MutationPlan( + "policy-publish-attach", + (role.arn, arn), + (publication, *attachment.operations), + {"role": roles.role_snapshot_hash(role)}, + ) + + +def _principal( + kind: str, value: str, account_name: str | None, context: IamCommandContext +) -> roles.DurablePrincipal: + accounts = _account_refs() + if kind == "principal": + return roles.resolve_principal(roles.PrincipalRef("principal", value), accounts) + if kind == "account": + if value.startswith("arn:"): + return roles.resolve_principal( + roles.PrincipalRef("principal", value), accounts + ) + account = accounts.get(value.casefold()) + if account: + return roles.DurablePrincipal( + "account", + f"arn:{account.partition}:iam::{account.account_id}:root", + account.account_id, + account.partition, + ) + if re.fullmatch(r"\d{12}", value): + return roles.DurablePrincipal( + "account", + f"arn:{context.partition}:iam::{value}:root", + value, + context.partition, + ) + raise OperationalError(f"Configured account {value!r} was not found.") + selected = account_name + principal_name = value + if not selected and ":" in value and not value.startswith("arn:"): + selected, principal_name = value.split(":", maxsplit=1) + if not selected: + local = roles.AccountRef("selected", context.account_id, context.partition) + accounts = {**accounts, "selected": local} + selected = "selected" + selected = selected.casefold() + if kind == "user" and not value.startswith("arn:"): + account = accounts.get(selected) + if account and account.account_id == context.account_id: + try: + response = context.iam.get_user(UserName=principal_name) + arn = str(response["User"]["Arn"]) + return roles.resolve_principal( + roles.PrincipalRef("principal", arn), accounts + ) + except ClientError as error: + raise OperationalError( + f"Unable to resolve IAM user {principal_name!r}: {error}" + ) from error + if kind == "role" and not value.startswith("arn:"): + account = accounts.get(selected) + if account is None: + raise OperationalError(f"Configured account {selected!r} was not found.") + if ( + account.account_id != context.account_id + or account.partition != context.partition + ): + raise OperationalError( + "Cross-account named roles cannot be verified; specify the exact " + "role ARN." + ) + try: + response = context.iam.get_role(RoleName=principal_name) + arn = str(response["Role"]["Arn"]) + except (ClientError, KeyError) as error: + raise OperationalError( + f"Unable to resolve IAM role {principal_name!r}: {error}" + ) from error + return roles.resolve_principal(roles.PrincipalRef("principal", arn), accounts) + return roles.resolve_principal( + roles.PrincipalRef(kind, principal_name, selected), # type: ignore[arg-type] + accounts, + ) + + +def _conditions(values: Iterable[str]) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for value in values: + operator_key, separator, condition_value = value.partition("=") + operator, colon, key = operator_key.partition(":") + if ( + not separator + or not colon + or not operator + or not key + or "*" in condition_value + ): + raise OperationalError( + f"Condition {value!r} must use OPERATOR:KEY=VALUE without wildcards." + ) + result.setdefault(operator, {})[key] = condition_value + return result + + +def _trust_mutation(args: argparse.Namespace, context: IamCommandContext) -> Result: + service = _service(context) + role_name = _role_name(args.target_role, context) + current = service.get_trust(role_name) + principal = _principal( + args.role_trust_kind, + args.principal, + getattr(args, "principal_account", None), + context, + ) + if args.role_trust_action == "remove": + plan = roles.plan_remove_trust(role_name, current, principal) + action = "removed" + else: + conditions = _conditions(args.condition) + if conditions: + statement = roles.trust_statement(principal, args.sid) + statement["Condition"] = conditions + statements = current.get("Statement", []) + if isinstance(statements, dict): + statements = [statements] + desired = {**current, "Statement": [*statements, statement]} + plan = roles.plan_set_trust(role_name, current, desired) + else: + plan = roles.plan_add_trust(role_name, current, principal, sid=args.sid) + action = "added" + _execute(plan, context, args) + data = { + "role": role_name, + "principal": asdict(principal), + "changed": bool(plan.operations), + } + return Result( + "IAM_ROLE_TRUST_MUTATED", + f"Trust {action} for {principal.arn} on {role_name}.", + data=data, + ) + + +def _policy_payload( + document: Mapping[str, Any], metadata: str, name_value: str +) -> object: + if metadata == "nested": + return {"metadata": {"name": name_value}, "policy": dict(document)} + return dict(document) + + +def _serialize( + document: Mapping[str, Any], format_name: str, metadata: str, name_value: str +) -> str: + payload = _policy_payload(document, metadata, name_value) + if format_name == "json": + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + return str(yaml.safe_dump(payload, sort_keys=False)) + + +def _export( + document: Mapping[str, Any], args: argparse.Namespace, name_value: str +) -> Result: + text = _serialize(document, args.format, args.metadata, name_value) + output: Path | None = args.output + if args.metadata == "sidecar" and output is None: + raise OperationalError("Sidecar metadata export requires --output.") + written: list[str] = [] + if output: + output = output.expanduser().absolute() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(text, encoding="utf-8") + written.append(str(output)) + if args.metadata == "sidecar": + sidecar = args.sidecar or output.with_name( + f"{output.stem}.metadata{output.suffix}" + ) + sidecar.write_text( + _serialize({"name": name_value}, args.format, "none", name_value), + encoding="utf-8", + ) + written.append(str(sidecar)) + return Result( + "IAM_ROLE_POLICY_EXPORT", + f"Exported {name_value} to {', '.join(written)}." if written else text.rstrip(), + data={"document": dict(document), "files": written}, + ) + + +def _edit_document( + document: Mapping[str, Any], label: str, *, write_backup: bool = True +) -> dict[str, Any]: + if write_backup: + backup = _state.root() / "backups" + backup.mkdir(parents=True, exist_ok=True) + digest = roles.document_hash(document) + backup_path = backup / f"{label}-{digest[:12]}.yaml" + backup_path.write_text( + yaml.safe_dump(dict(document), sort_keys=False), encoding="utf-8" + ) + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") + if not editor: + raise OperationalError("Set VISUAL or EDITOR before using edit commands.") + with tempfile.TemporaryDirectory(prefix="hacksaws-role-") as directory: + path = Path(directory) / f"{label}.yaml" + path.write_text( + yaml.safe_dump(dict(document), sort_keys=False), encoding="utf-8" + ) + command = [*shlex.split(editor, posix=os.name != "nt"), str(path)] + result = _editor_runner(command, check=False) + if result.returncode != 0: + raise OperationalError(f"Editor exited with status {result.returncode}.") + edited = documents.load_policy_input(path).document + return dict(edited) + + +def _group_snapshot( + group_name: str, context: IamCommandContext +) -> roles.GroupGrantSnapshot: + try: + context.iam.get_group(GroupName=group_name) + except ClientError as error: + raise OperationalError( + f"Unable to resolve IAM group {group_name!r}: {error}" + ) from error + policy_name = f"hacksaws-{group_name}-assume-roles" + path = _config_path() + policy_arn = ( + f"arn:{context.partition}:iam::{context.account_id}:policy{path}{policy_name}" + ) + attached = False + for page in context.iam.get_paginator("list_attached_group_policies").paginate( + GroupName=group_name + ): + attached = attached or any( + item.get("PolicyArn") == policy_arn + for item in page.get("AttachedPolicies", []) + ) + role_arns: tuple[str, ...] = () + exists = False + document: dict[str, Any] = {"Version": "2012-10-17", "Statement": []} + default_version_id: str | None = None + tags: dict[str, str] = {} + owned = False + try: + policy = context.iam.get_policy(PolicyArn=policy_arn)["Policy"] + default_version_id = str(policy["DefaultVersionId"]) + version = context.iam.get_policy_version( + PolicyArn=policy_arn, VersionId=default_version_id + )["PolicyVersion"] + document = roles.decode_document(version["Document"]) + tag_response = context.iam.list_policy_tags(PolicyArn=policy_arn) + tags = { + str(item["Key"]): str(item.get("Value", "")) + for item in tag_response.get("Tags", []) + if isinstance(item, Mapping) and "Key" in item + } + owned = ( + tags.get("hacksaws:managed-by") == "hacksaws" + and tags.get("hacksaws:resource-kind") == "managed-policy" + and tags.get("hacksaws:resource-id") == f"group-{group_name}" + ) + if not owned: + raise OperationalError( + f"Aggregate policy {policy_arn} is not verified as Hacksaws-owned." + ) + statements = document.get("Statement", []) + if isinstance(statements, dict): + statements = [statements] + resources: list[str] = [] + for statement in statements if isinstance(statements, list) else []: + if not isinstance(statement, dict) or statement.get("Sid") != ( + "HacksawsGroupAssumeRoles" + ): + continue + if ( + statement.get("Effect") != "Allow" + or statement.get("Action") != "sts:AssumeRole" + ): + raise OperationalError( + "Aggregate group statement has unexpected semantics." + ) + value = statement.get("Resource") if isinstance(statement, dict) else None + resources.extend( + value + if isinstance(value, list) + else [value] + if isinstance(value, str) + else [] + ) + role_arns = tuple(sorted(set(resources))) + exists = True + except ClientError as error: + if str(error.response.get("Error", {}).get("Code")) != "NoSuchEntity": + raise + account = roles.AccountRef( + _account_name(context) or context.account_id, + context.account_id, + context.partition, + ) + return roles.GroupGrantSnapshot( + group_name, + account, + policy_name, + policy_arn, + role_arns, + exists, + attached, + document, + default_version_id, + (), + tags, + owned, + path, + ) + + +def _member_arns(values: Iterable[str], context: IamCommandContext) -> tuple[str, ...]: + return tuple( + _service(context).get_role(_role_name(value, context)).arn for value in values + ) + + +def _remaining_group_grants( + role_arn: str, excluding_policy_arn: str, context: IamCommandContext +) -> tuple[str, ...]: + """Return other verified Hacksaws aggregate policies still granting this role.""" + matches: list[str] = [] + paginator = context.iam.get_paginator("list_policies") + for page in paginator.paginate(Scope="Local", PathPrefix=_config_path()): + for item in page.get("Policies", []): + if not isinstance(item, Mapping): + continue + arn = str(item.get("Arn", "")) + if not arn or arn == excluding_policy_arn: + continue + tags = context.iam.list_policy_tags(PolicyArn=arn).get("Tags", []) + values = { + str(tag["Key"]): str(tag.get("Value", "")) + for tag in tags + if isinstance(tag, Mapping) and "Key" in tag + } + if not ( + values.get("hacksaws:managed-by") == "hacksaws" + and values.get("hacksaws:resource-kind") == "managed-policy" + and str(values.get("hacksaws:resource-id", "")).startswith("group-") + ): + continue + group_name = str(values["hacksaws:resource-id"])[len("group-") :] + expected_name = f"hacksaws-{group_name}-assume-roles" + expected_arn = ( + f"arn:{context.partition}:iam::{context.account_id}:policy" + f"{_config_path()}{expected_name}" + ) + if str(item.get("PolicyName", "")) != expected_name or arn != expected_arn: + continue + try: + context.iam.get_group(GroupName=group_name) + except ClientError as error: + if _error_code(error) == "NoSuchEntity": + continue + raise + attached = any( + attached_policy.get("PolicyArn") == arn + for attached_page in context.iam.get_paginator( + "list_attached_group_policies" + ).paginate(GroupName=group_name) + for attached_policy in attached_page.get("AttachedPolicies", []) + if isinstance(attached_policy, Mapping) + ) + if not attached: + continue + policy = context.iam.get_policy(PolicyArn=arn)["Policy"] + version = context.iam.get_policy_version( + PolicyArn=arn, VersionId=policy["DefaultVersionId"] + )["PolicyVersion"] + document = roles.decode_document(version["Document"]) + statements = document.get("Statement", []) + if isinstance(statements, Mapping): + statements = [statements] + for statement in statements if isinstance(statements, list) else []: + if not isinstance(statement, Mapping) or statement.get("Sid") != ( + "HacksawsGroupAssumeRoles" + ): + continue + resources = statement.get("Resource", []) + selected = resources if isinstance(resources, list) else [resources] + if role_arn in selected: + matches.append(arn) + break + return tuple(sorted(matches)) + + +def _group_command(args: argparse.Namespace, context: IamCommandContext) -> Result: + group = _group_snapshot(args.group, context) + action = args.role_trust_action + if action in {"add", "sync", "remove"}: + members = _member_arns(args.members, context) + if action == "add": + plan = roles.plan_sync_group_members(group, (*group.role_arns, *members)) + elif action == "sync": + plan = roles.plan_sync_group_members(group, members) + else: + plan = roles.plan_sync_group_members( + group, (arn for arn in group.role_arns if arn not in members) + ) + _execute(plan, context, args) + return Result( + "IAM_ROLE_GROUP_MEMBERS", + f"Updated aggregate AssumeRole grants for group {group.group_name}.", + data={"group": group.group_name, "roles": list(plan.resources[1:])}, + ) + role = _service(context).get_role(_role_name(args.target_role, context)) + if action == "grant": + plan = roles.plan_group_grant(role, role.trust, group) + else: + account = roles.DurablePrincipal( + "account", + f"arn:{context.partition}:iam::{context.account_id}:root", + context.account_id, + context.partition, + ) + member_plan = roles.plan_remove_group_member(group, role.arn) + remaining = _remaining_group_grants(role.arn, group.policy_arn, context) + trust = ( + roles.MutationPlan("trust-retained", (role.arn,), ()) + if remaining + else roles.plan_remove_owned_group_trust(role.name, role.trust, account) + ) + plan = roles.MutationPlan( + "group-revoke", + (role.arn, group.group_name), + (*trust.operations, *member_plan.operations), + {**trust.expected, **member_plan.expected}, + ( + ( + "Account-root trust is retained because other aggregate group " + "grants still reference this role." + ), + ) + if remaining + else (), + ) + _execute(plan, context, args) + return Result( + "IAM_ROLE_GROUP_TRUST", + f"{action.title()}ed group {group.group_name} for role {role.name}.", + data={"role": role.arn, "group": group.group_name, "action": action}, + ) + + +def _inline_command(args: argparse.Namespace, context: IamCommandContext) -> Result: + service = _service(context) + role_name = _role_name(args.role, context) + action = args.role_inline_action + if action == "list": + values = list(service.list_inline_policies(role_name)) + return Result( + "IAM_ROLE_INLINE_LIST", "\n".join(values), data={"policies": values} + ) + try: + current = service.get_inline_policy(role_name, args.policy) + except ClientError as error: + error_code = str(error.response.get("Error", {}).get("Code")) + if action != "put" or error_code != "NoSuchEntity": + raise + current = None + if action == "put": + desired = _load_document(args.file, args) + plan = roles.plan_put_inline_policy( + role_name, + args.policy, + desired, + current=current, + expected_hash=( + roles.document_hash(current) if current is not None else None + ), + ) + if current is not None: + latest = service.get_inline_policy(role_name, args.policy) + if roles.document_hash(latest) != roles.document_hash(current): + raise OperationalError( + "Inline policy changed before mutation; no update was made." + ) + _execute(plan, context, args) + return Result( + "IAM_ROLE_INLINE_MUTATED", + f"Inline policy {args.policy} put completed for {role_name}.", + data={"role": role_name, "policy": args.policy, "action": action}, + ) + if current is None: + raise OperationalError(f"Inline policy {args.policy!r} was not found.") + if action == "get": + return Result( + "IAM_ROLE_INLINE_GET", + yaml.safe_dump(current, sort_keys=False).rstrip(), + data=current, + ) + if action == "export": + return _export(current, args, args.policy) + if action == "edit": + desired = _edit_document( + current, + f"{role_name}-{args.policy}", + write_backup=not bool(getattr(args, "dry_run", False)), + ) + latest = service.get_inline_policy(role_name, args.policy) + if roles.document_hash(latest) != roles.document_hash(current): + raise OperationalError( + "Inline policy changed while the editor was open; no update was made." + ) + plan = roles.plan_put_inline_policy( + role_name, + args.policy, + desired, + current=current, + expected_hash=roles.document_hash(current), + ) + else: + plan = roles.plan_delete_inline_policy( + role_name, + args.policy, + current=current, + expected_hash=roles.document_hash(current), + ) + latest = service.get_inline_policy(role_name, args.policy) + if roles.document_hash(latest) != roles.document_hash(current): + raise OperationalError( + "Inline policy changed before mutation; no update was made." + ) + _execute(plan, context, args) + return Result( + "IAM_ROLE_INLINE_MUTATED", + f"Inline policy {args.policy} {action} completed for {role_name}.", + data={"role": role_name, "policy": args.policy, "action": action}, + ) + + +def _trust_command(args: argparse.Namespace, context: IamCommandContext) -> Result: + action = args.role_trust_action + trust_kind = getattr(args, "role_trust_kind", None) + if action in {"add", "remove"} and trust_kind != "group-members": + return _trust_mutation(args, context) + if trust_kind in {"group", "group-members"}: + return _group_command(args, context) + service = _service(context) + role_name = _role_name(args.role, context) + current = service.get_trust(role_name) + if action == "get": + return Result( + "IAM_ROLE_TRUST_GET", + yaml.safe_dump(current, sort_keys=False).rstrip(), + data=current, + ) + if action == "export": + return _export(current, args, f"{role_name}-trust") + if action == "check": + role = service.get_role(role_name) + trust = _caller_trust(role, context) + static = roles.classify_assumability(trust_allows=trust, identity_allows=None) + data: dict[str, Any] = { + "role": role.arn, + "static": asdict(static), + "probe": None, + } + lines = [f"Static: {static.classification}", *static.reasons] + if args.probe: + lines.append( + "Warning: probe performs a live sts:AssumeRole request recorded by AWS." + ) + try: + data["probe"] = dict( + roles.BotoStsProbe(context.sts).probe( + role.arn, "hacksaws-role-check" + ) + ) + lines.append("Probe: allowed") + except (BotoCoreError, ClientError) as error: + data["probe"] = {"ok": False, "error": str(error)} + lines.append("Probe: denied") + return Result("IAM_ROLE_TRUST_CHECK", "\n".join(lines), data=data) + if action == "set": + desired = _load_document(args.file, args) + else: + desired = _edit_document( + current, + f"{role_name}-trust", + write_backup=not bool(getattr(args, "dry_run", False)), + ) + latest = service.get_trust(role_name) + if roles.document_hash(latest) != roles.document_hash(current): + raise OperationalError( + "Trust policy changed during editing; no update was made." + ) + plan = roles.plan_set_trust( + role_name, current, desired, expected_hash=roles.document_hash(current) + ) + _execute(plan, context, args) + return Result( + "IAM_ROLE_TRUST_SET", + f"Updated trust policy for {role_name}.", + data={"role": role_name, "trust": desired}, + ) + + +def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | None: + command = getattr(args, "role_command", None) + service = _service(context) + if command is None: + return None + if command == "list": + return _list_result(args, context) + if command == "get": + role = service.get_role(_role_name(args.role, context)) + data = _role_data(role) + return Result("IAM_ROLE_GET", _mapping_text(data), data=data) + if command == "create": + role_name, warning = _configured_name(args.role, args, context) + spec = roles.RoleSpec( + role_name, + _trust_for_create(args, context), + path=args.path or _config_path(), + description=args.description, + max_session_duration=_duration(args), + permissions_boundary=args.permissions_boundary, + tags=_parse_tags(args.tag), + owner=roles.normalize_caller_principal(context.arn), + ) + try: + current = service.get_role(role_name) + except ClientError as error: + if _error_code(error) != "NoSuchEntity": + raise + current = None + if current is None: + plan = roles.plan_create_role(spec) + else: + plan = roles.plan_update_role(current, spec) + if not plan.operations: + return Result( + "IAM_ROLE_NO_CHANGE", + f"NO CHANGE — IAM role {role_name} already matches {current.arn}.", + data={ + "classification": "no-change", + "role": role_name, + "arn": current.arn, + "roleId": current.role_id, + }, + ) + if not args.replace: + return Result( + "IAM_ROLE_COLLISION", + f"CONFLICT — IAM role {role_name} already exists and differs. " + "Use 'iam role update' for routine changes, or repeat create " + "with --replace after reviewing the exact plan.", + EXIT_USAGE, + "stderr", + { + "classification": "conflict", + "role": role_name, + "arn": current.arn, + "operations": [item.action for item in plan.operations], + }, + ) + _execute(plan, context, args) + try: + result_role = service.get_role(role_name) + except ClientError as error: + if _error_code(error) != "NoSuchEntity": + raise + result_role = None + message = ( + f"Updated existing IAM role {role_name}." + if current is not None + else f"Created IAM role {role_name}." + ) + if warning: + message += f" Warning: {warning}" + console_url = _console_url(context, role_name) + role_arn = ( + result_role.arn + if result_role is not None + else f"arn:{context.partition}:iam::{context.account_id}:role/{role_name}" + ) + role_id = result_role.role_id if result_role is not None else None + message += ( + f"\nARN: {role_arn}" + + (f"\nRole ID: {role_id}" if role_id else "") + + f"\nAWS Console: {console_url}" + ) + return Result( + "IAM_ROLE_REPLACED" if current is not None else "IAM_ROLE_CREATED", + message, + data={ + "role": role_name, + "arn": role_arn, + "roleId": role_id, + "warning": warning, + "consoleUrl": console_url, + }, + ) + if command == "update": + current = service.get_role(_role_name(args.role, context)) + if current.tags.get(roles.MANAGED_TAG) != "true": + raise OperationalError( + "Role is not Hacksaws-owned; adopt it before updating managed fields." + ) + description = ( + None + if args.clear_description + else args.description + if args.description is not None + else current.description + ) + boundary = ( + None + if args.clear_permissions_boundary + else args.permissions_boundary + if args.permissions_boundary is not None + else current.permissions_boundary + ) + trust = ( + _load_document(args.trust_policy, args) + if args.trust_policy + else current.trust + ) + desired = roles.RoleSpec( + current.name, + trust, + path=current.path, + description=description, + max_session_duration=_duration(args, current.max_session_duration), + permissions_boundary=boundary, + tags={ + key: value + for key, value in current.tags.items() + if key not in {roles.MANAGED_TAG, roles.OWNER_TAG, roles.AUDIT_TAG} + }, + owner=current.tags.get(roles.OWNER_TAG, "hacksaws"), + audit_id=current.tags.get(roles.AUDIT_TAG), + ownership_origin=current.tags.get(roles.ORIGIN_TAG, "created"), + ) + plan = roles.plan_update_role(current, desired) + _execute(plan, context, args) + return Result( + "IAM_ROLE_UPDATED", + f"Updated IAM role {current.name}.", + data={ + "role": current.name, + "operations": [item.action for item in plan.operations], + }, + ) + if command == "delete": + current = service.get_role(_role_name(args.role, context)) + if current.path.startswith("/aws-service-role/") and not args.service_role: + raise OperationalError( + "Service-linked role deletion requires --service-role." + ) + plan = roles.plan_delete_role( + current, + cascade=args.cascade, + remove_from_instance_profiles=args.remove_from_instance_profiles, + allow_unmanaged=args.unmanaged, + ) + _execute(plan, context, args) + return Result( + "IAM_ROLE_DELETED", + f"Deleted IAM role {current.name}.", + data={"role": current.arn, "warnings": list(plan.warnings)}, + ) + if command in {"attach", "detach"}: + role_name = _role_name(args.role, context) + current_role = service.get_role(role_name) + if command == "attach" and _looks_like_file(args.policy): + path = Path(args.policy).expanduser() + if not path.is_file(): + raise OperationalError(f"Local policy file {path} does not exist.") + document = _load_document(path, args) + policy_name = args.policy_name or path.stem + if args.inline: + try: + current_inline = service.get_inline_policy(role_name, policy_name) + except ClientError as error: + if _error_code(error) != "NoSuchEntity": + raise + current_inline = None + plan = roles.plan_put_inline_policy( + role_name, + policy_name, + document, + current=current_inline, + expected_hash=( + roles.document_hash(current_inline) + if current_inline is not None + else None + ), + ) + else: + plan = _owned_publish_attach_plan( + current_role, + policy_name, + document, + args.path or _config_path(), + context, + ) + reference = plan.resources[-1] + else: + arn = _resolve_policy_arn(args.policy, context) + plan = ( + roles.plan_attach_policy(role_name, arn, current=current_role) + if command == "attach" + else roles.plan_detach_policy(role_name, arn, current=current_role) + ) + reference = arn + _execute(plan, context, args) + return Result( + f"IAM_ROLE_POLICY_{command.upper()}", + f"{command.title()}ed {reference} for {role_name}.", + data={"role": role_name, "policy": reference}, + ) + if command == "tag": + role_name = _role_name(args.role, context) + action = args.role_tag_action + current_role = service.get_role(role_name) + if action == "list": + values = dict(current_role.tags) + return Result( + "IAM_ROLE_TAG_LIST", _mapping_text(values), data={"tags": values} + ) + if action == "set": + plan = roles.plan_put_tags( + role_name, + _parse_tags(args.tags), + current=current_role.tags, + expected_role=current_role, + ) + else: + if any(key.casefold().startswith("hacksaws:") for key in args.keys): + raise OperationalError( + "Reserved hacksaws: tags may be changed only with adopt or release." + ) + plan = roles.plan_remove_tags( + role_name, + args.keys, + current=current_role.tags, + expected_role=current_role, + ) + _execute(plan, context, args) + return Result( + "IAM_ROLE_TAG_MUTATED", + f"Role tags {action} completed for {role_name}.", + data={"role": role_name, "action": action}, + ) + if command in {"adopt", "release"}: + current = service.get_role(_role_name(args.role, context)) + plan = ( + roles.plan_adopt_role( + current, + args.owner or roles.normalize_caller_principal(context.arn), + args.audit_id, + ) + if command == "adopt" + else roles.plan_release_role(current) + ) + _execute(plan, context, args) + return Result( + "IAM_ROLE_OWNERSHIP", + f"{command.title()}ed IAM role {current.name}.", + data={"role": current.arn, "action": command}, + ) + if command == "inline-policy": + if not getattr(args, "role_inline_action", None): + return Result( + "IAM_ROLE_INLINE_HELP", + "Choose an inline-policy action.", + EXIT_USAGE, + "stderr", + ) + return _inline_command(args, context) + if command == "trust": + if not getattr(args, "role_trust_action", None): + return Result( + "IAM_ROLE_TRUST_HELP", "Choose a trust action.", EXIT_USAGE, "stderr" + ) + return _trust_command(args, context) + return None + + +def dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | None: + """Dispatch a parsed role leaf and normalize expected operational failures.""" + try: + return _dispatch(args, context) + except _DryRunCompletedError as completed: + plan = completed.plan + data = { + "dryRun": True, + "classification": "planned" if plan.operations else "no-change", + "kind": plan.kind, + "resources": list(plan.resources), + "operations": [ + {"client": item.client, "action": item.action} + for item in plan.operations + ], + "warnings": list(plan.warnings), + } + return Result( + "IAM_ROLE_DRY_RUN", + f"DRY RUN — {_preview(plan)}\nNo AWS or local state was changed.", + data=data, + ) + except _MutationCancelledError as error: + return Result( + "IAM_ROLE_MUTATION_CANCELLED", + f"Mutation cancelled; no AWS changes were made.\n{error}", + EXIT_CANCELLED, + "stderr", + {"preview": str(error)}, + ) + except OperationalError: + raise + except (roles.IamRoleError, documents.PolicyInputError) as error: + raise OperationalError(str(error)) from error + except (BotoCoreError, ClientError) as error: + raise OperationalError(f"AWS IAM role operation failed: {error}") from error diff --git a/hacksaws/_iam_roles.py b/hacksaws/_iam_roles.py new file mode 100644 index 0000000..bee2c0b --- /dev/null +++ b/hacksaws/_iam_roles.py @@ -0,0 +1,1359 @@ +"""IAM role, trust, attachment, inline-policy, and group-grant primitives. + +This module deliberately contains no CLI, prompts, configuration persistence, or +format rendering. It exposes immutable snapshots and mutation plans so callers +can preview, journal, execute, or compensate multi-account changes explicitly. +""" + +# ruff: noqa: ANN401, TRY003 + +from __future__ import annotations + +import hashlib +import json +import re +import time +from collections.abc import Mapping +from dataclasses import dataclass +from dataclasses import field +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import Protocol +from urllib.parse import unquote + +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError + +if TYPE_CHECKING: + from collections.abc import Callable + from collections.abc import Iterable + +DEFAULT_ROLE_PATH = "/hacksaws/" +MANAGED_TAG = "hacksaws:managed" +OWNER_TAG = "hacksaws:owner" +AUDIT_TAG = "hacksaws:audit-id" +ORIGIN_TAG = "hacksaws:ownership-origin" +ROLE_ARN = re.compile( + r"^arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/([\w+=,.@/-]+)$" +) +IAM_PRINCIPAL_ARN = re.compile( + r"^arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):(user|role)/([\w+=,.@/-]+)$" +) +ACCOUNT_PRINCIPAL_ARN = re.compile(r"^arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):root$") +ASSUMED_ROLE_ARN = re.compile( + r"^arn:(aws|aws-us-gov|aws-cn):sts::(\d{12}):" + r"assumed-role/([\w+=,.@/-]+)/[\w+=,.@-]+$" +) +ACCOUNT_ID = re.compile(r"^\d{12}$") + + +class IamRoleError(RuntimeError): + """Base error for IAM role service operations.""" + + +class ConflictError(IamRoleError): + """Raised when optimistic state or ownership no longer matches.""" + + +class DependencyError(IamRoleError): + """Raised when deletion would cross an unapproved dependency boundary.""" + + +class AmbiguousTrustError(IamRoleError): + """Raised when a logical trust mutation cannot preserve a complex statement.""" + + +def canonical_json(value: object) -> str: + """Return deterministic compact JSON.""" + return json.dumps(value, separators=(",", ":"), sort_keys=True) + + +def document_hash(value: object) -> str: + """Return the optimistic-concurrency digest for a policy document.""" + return hashlib.sha256(canonical_json(value).encode()).hexdigest() + + +def validate_trust_document(document: Mapping[str, Any]) -> None: + """Reject trust-policy principal forms that cannot be bounded safely.""" + statements = _trust_statements(document) + for index, statement in enumerate(statements): + if "NotPrincipal" in statement: + raise IamRoleError( + f"Trust statement {index} uses NotPrincipal, which is not supported." + ) + principal = statement.get("Principal") + if principal is None: + continue + values: list[object] = [] + if isinstance(principal, Mapping): + for value in principal.values(): + values.extend(value if isinstance(value, list) else [value]) + else: + values.append(principal) + for value in values: + if not isinstance(value, str): + raise IamRoleError( + f"Trust statement {index} contains an invalid Principal value." + ) + if "*" in value: + raise IamRoleError("Wildcard trust principals are not supported.") + + +def decode_document(value: object) -> dict[str, Any]: + """Normalize an IAM API policy document into a mapping.""" + if isinstance(value, str): + value = json.loads(unquote(value)) + if not isinstance(value, dict): + raise IamRoleError("IAM policy document must be an object.") + return value + + +def normalize_path(value: str) -> str: + """Validate and normalize an IAM role path.""" + if not value.startswith("/") or not value.endswith("/") or "//" in value: + raise IamRoleError("IAM role path must begin and end with one '/'.") + return value + + +def normalize_caller_principal(arn: str) -> str: + """Convert a caller ARN to an exact durable IAM user, role, or account ARN.""" + if "*" in arn: + raise IamRoleError("Wildcard principals are not supported.") + if IAM_PRINCIPAL_ARN.fullmatch(arn) or ACCOUNT_PRINCIPAL_ARN.fullmatch(arn): + return arn + assumed_role = ASSUMED_ROLE_ARN.fullmatch(arn) + if assumed_role: + return ( + f"arn:{assumed_role.group(1)}:iam::{assumed_role.group(2)}:" + f"role/{assumed_role.group(3)}" + ) + raise IamRoleError(f"Caller ARN {arn!r} is not a durable IAM user or role.") + + +@dataclass(frozen=True) +class AccountRef: + """Account identity used for deterministic ARN resolution.""" + + name: str + account_id: str + partition: str = "aws" + + def __post_init__(self) -> None: + if not ACCOUNT_ID.fullmatch(self.account_id): + raise IamRoleError("AWS account IDs must contain 12 digits.") + if self.partition not in {"aws", "aws-us-gov", "aws-cn"}: + raise IamRoleError("Unsupported AWS partition.") + + +@dataclass(frozen=True) +class PrincipalRef: + """Unresolved exact trust principal supplied by a caller.""" + + kind: Literal["caller", "principal", "user", "role", "account"] + value: str + account: str | None = None + + +@dataclass(frozen=True) +class DurablePrincipal: + """Exact durable principal suitable for a trust policy.""" + + kind: Literal["user", "role", "account"] + arn: str + account_id: str + partition: str + + +def resolve_principal( + reference: PrincipalRef, + accounts: Mapping[str, AccountRef], + *, + caller_arn: str | None = None, +) -> DurablePrincipal: + """Resolve name, ARN, account-qualified, or caller principal data.""" + value = reference.value + if reference.kind == "caller": + value = normalize_caller_principal(caller_arn or value) + if reference.kind == "principal" or value.startswith("arn:"): + durable = normalize_caller_principal(value) + account_match = ACCOUNT_PRINCIPAL_ARN.fullmatch(durable) + if account_match: + return DurablePrincipal( + "account", + durable, + account_match.group(2), + account_match.group(1), + ) + match = IAM_PRINCIPAL_ARN.fullmatch(durable) + if match is None: # pragma: no cover - guaranteed by normalization + raise IamRoleError("Normalized principal was not an IAM principal.") + kind: Literal["user", "role"] = "user" if match.group(3) == "user" else "role" + return DurablePrincipal(kind, durable, match.group(2), match.group(1)) + account = accounts.get(reference.account or "") + if account is None: + raise IamRoleError("A configured account is required for named principals.") + if reference.kind == "account": + account_id = value if ACCOUNT_ID.fullmatch(value) else account.account_id + return DurablePrincipal( + "account", + f"arn:{account.partition}:iam::{account_id}:root", + account_id, + account.partition, + ) + if reference.kind not in {"user", "role"}: + raise IamRoleError(f"Unsupported principal kind {reference.kind!r}.") + arn = f"arn:{account.partition}:iam::{account.account_id}:{reference.kind}/{value}" + return DurablePrincipal(reference.kind, arn, account.account_id, account.partition) + + +@dataclass(frozen=True) +class Operation: + """One journalable AWS mutation and its optional compensation.""" + + client: str + action: str + params: Mapping[str, Any] + compensate_action: str | None = None + compensate_params: Mapping[str, Any] | None = None + + +@dataclass(frozen=True) +class MutationPlan: + """Immutable, previewable multi-resource mutation plan.""" + + kind: str + resources: tuple[str, ...] + operations: tuple[Operation, ...] + expected: Mapping[str, str] = field(default_factory=dict) + warnings: tuple[str, ...] = () + + +@dataclass +class ExecutionJournal: + """In-memory journal usable by a durable integration adapter.""" + + completed: list[Operation] = field(default_factory=list) + compensated: list[Operation] = field(default_factory=list) + + +class ClientResolver(Protocol): + """Resolve a named account/service client for plan execution.""" + + def __call__(self, name: str) -> Any: ... + + +def execute_plan( + plan: MutationPlan, resolve_client: ClientResolver +) -> ExecutionJournal: + """Execute a plan and compensate completed operations in reverse on failure.""" + journal = ExecutionJournal() + try: + for operation in plan.operations: + getattr(resolve_client(operation.client), operation.action)( + **operation.params + ) + journal.completed.append(operation) + except Exception: + for operation in reversed(journal.completed): + if operation.compensate_action and operation.compensate_params is not None: + getattr(resolve_client(operation.client), operation.compensate_action)( + **operation.compensate_params + ) + journal.compensated.append(operation) + raise + return journal + + +@dataclass(frozen=True) +class RoleSpec: + """Desired IAM role fields independent of CLI naming choices.""" + + name: str + trust: Mapping[str, Any] + path: str = DEFAULT_ROLE_PATH + description: str | None = None + max_session_duration: int = 3600 + permissions_boundary: str | None = None + tags: Mapping[str, str] = field(default_factory=dict) + owner: str = "hacksaws" + audit_id: str | None = None + ownership_origin: str = "created" + + +@dataclass(frozen=True) +class RoleSnapshot: + """Remote role state used for updates and dependency-safe deletion.""" + + name: str + arn: str + path: str + trust: Mapping[str, Any] + description: str | None = None + max_session_duration: int = 3600 + permissions_boundary: str | None = None + tags: Mapping[str, str] = field(default_factory=dict) + attached_policies: tuple[str, ...] = () + inline_policies: tuple[str, ...] = () + instance_profiles: tuple[str, ...] = () + inline_policy_documents: Mapping[str, Mapping[str, Any]] = field( + default_factory=dict + ) + role_id: str = "" + + +def ownership_tags(spec: RoleSpec) -> dict[str, str]: + """Merge explicit naming/audit tags with required ownership markers.""" + result = { + **spec.tags, + MANAGED_TAG: "true", + OWNER_TAG: spec.owner, + ORIGIN_TAG: spec.ownership_origin, + } + if spec.audit_id: + result[AUDIT_TAG] = spec.audit_id + return result + + +def plan_create_role(spec: RoleSpec, *, client: str = "iam") -> MutationPlan: + """Plan role creation under the safe Hacksaws path by default.""" + normalize_path(spec.path) + validate_trust_document(spec.trust) + params: dict[str, Any] = { + "RoleName": spec.name, + "Path": spec.path, + "AssumeRolePolicyDocument": canonical_json(spec.trust), + "MaxSessionDuration": spec.max_session_duration, + "Tags": [ + {"Key": key, "Value": value} for key, value in ownership_tags(spec).items() + ], + } + if spec.description is not None: + params["Description"] = spec.description + if spec.permissions_boundary: + params["PermissionsBoundary"] = spec.permissions_boundary + operation = Operation( + client, + "create_role", + params, + "delete_role", + {"RoleName": spec.name}, + ) + return MutationPlan("role-create", (spec.name,), (operation,)) + + +def plan_update_role(current: RoleSnapshot, desired: RoleSpec) -> MutationPlan: + """Plan mutable role fields with an optimistic trust precondition.""" + if current.name != desired.name or current.path != desired.path: + raise ConflictError("IAM role name and path require replacement, not update.") + validate_trust_document(desired.trust) + operations: list[Operation] = [] + if ( + current.description != desired.description + or current.max_session_duration != desired.max_session_duration + ): + operations.append( + Operation( + "iam", + "update_role", + { + "RoleName": current.name, + "Description": desired.description or "", + "MaxSessionDuration": desired.max_session_duration, + }, + "update_role", + { + "RoleName": current.name, + "Description": current.description or "", + "MaxSessionDuration": current.max_session_duration, + }, + ) + ) + if document_hash(current.trust) != document_hash(desired.trust): + operations.append( + Operation( + "iam", + "update_assume_role_policy", + { + "RoleName": current.name, + "PolicyDocument": canonical_json(desired.trust), + }, + "update_assume_role_policy", + { + "RoleName": current.name, + "PolicyDocument": canonical_json(current.trust), + }, + ) + ) + if current.permissions_boundary != desired.permissions_boundary: + if desired.permissions_boundary: + operations.append( + Operation( + "iam", + "put_role_permissions_boundary", + { + "RoleName": current.name, + "PermissionsBoundary": desired.permissions_boundary, + }, + ( + "put_role_permissions_boundary" + if current.permissions_boundary + else "delete_role_permissions_boundary" + ), + ( + { + "RoleName": current.name, + "PermissionsBoundary": current.permissions_boundary, + } + if current.permissions_boundary + else {"RoleName": current.name} + ), + ) + ) + else: + operations.append( + Operation( + "iam", + "delete_role_permissions_boundary", + {"RoleName": current.name}, + "put_role_permissions_boundary", + { + "RoleName": current.name, + "PermissionsBoundary": current.permissions_boundary, + }, + ) + ) + operations.extend( + plan_sync_tags(current.name, current.tags, ownership_tags(desired)).operations + ) + return MutationPlan( + "role-update", + (current.arn,), + tuple(operations), + expected={"role": role_snapshot_hash(current)}, + ) + + +def role_snapshot_hash(role: RoleSnapshot) -> str: + """Hash every mutable role field used by a mutation plan.""" + return document_hash( + { + "arn": role.arn, + "roleId": role.role_id, + "path": role.path, + "trust": role.trust, + "description": role.description, + "maxSessionDuration": role.max_session_duration, + "permissionsBoundary": role.permissions_boundary, + "tags": dict(role.tags), + "attachedPolicies": sorted(role.attached_policies), + "inlinePolicies": dict(role.inline_policy_documents), + "instanceProfiles": sorted(role.instance_profiles), + } + ) + + +def plan_adopt_role( + role: RoleSnapshot, owner: str, audit_id: str | None = None +) -> MutationPlan: + """Plan explicit adoption without changing role permissions or trust.""" + current_owner = role.tags.get(OWNER_TAG) + if role.tags.get(MANAGED_TAG) == "true" and current_owner not in {None, owner}: + raise ConflictError( + f"Role is already managed by {current_owner!r}; release it before adoption." + ) + tags = {MANAGED_TAG: "true", OWNER_TAG: owner, ORIGIN_TAG: "adopted"} + if audit_id: + tags[AUDIT_TAG] = audit_id + return plan_put_tags( + role.name, tags, current=role.tags, kind="role-adopt", expected_role=role + ) + + +def plan_release_role(role: RoleSnapshot) -> MutationPlan: + """Plan removal of Hacksaws ownership tags without deleting the role.""" + keys = tuple( + key + for key in (MANAGED_TAG, OWNER_TAG, AUDIT_TAG, ORIGIN_TAG) + if key in role.tags + ) + return plan_remove_tags( + role.name, keys, current=role.tags, kind="role-release", expected_role=role + ) + + +def plan_put_tags( + role_name: str, + tags: Mapping[str, str], + *, + current: Mapping[str, str] | None = None, + kind: str = "role-tag", + expected_role: RoleSnapshot | None = None, +) -> MutationPlan: + """Plan additive/update role tags.""" + operations = tuple( + Operation( + "iam", + "tag_role", + { + "RoleName": role_name, + "Tags": [{"Key": key, "Value": value}], + }, + ("tag_role" if current is not None and key in current else "untag_role"), + ( + { + "RoleName": role_name, + "Tags": [{"Key": key, "Value": current[key]}], + } + if current is not None and key in current + else {"RoleName": role_name, "TagKeys": [key]} + ), + ) + for key, value in tags.items() + ) + expected = ( + {"role": role_snapshot_hash(expected_role)} if expected_role is not None else {} + ) + return MutationPlan(kind, (role_name,), operations, expected) + + +def plan_remove_tags( + role_name: str, + keys: Iterable[str], + *, + current: Mapping[str, str] | None = None, + kind: str = "role-untag", + expected_role: RoleSnapshot | None = None, +) -> MutationPlan: + """Plan role tag removal.""" + values = tuple(keys) + operations = tuple( + Operation( + "iam", + "untag_role", + {"RoleName": role_name, "TagKeys": [key]}, + "tag_role" if current is not None and key in current else None, + ( + { + "RoleName": role_name, + "Tags": [{"Key": key, "Value": current[key]}], + } + if current is not None and key in current + else None + ), + ) + for key in values + ) + expected = ( + {"role": role_snapshot_hash(expected_role)} if expected_role is not None else {} + ) + return MutationPlan(kind, (role_name,), operations, expected) + + +def plan_sync_tags( + role_name: str, current: Mapping[str, str], desired: Mapping[str, str] +) -> MutationPlan: + """Plan exact tag synchronization.""" + put = {key: value for key, value in desired.items() if current.get(key) != value} + remove = [key for key in current if key not in desired] + operations = ( + *plan_put_tags(role_name, put, current=current).operations, + *plan_remove_tags(role_name, remove, current=current).operations, + ) + return MutationPlan("role-tags-sync", (role_name,), operations) + + +def _trust_statements(document: Mapping[str, Any]) -> list[dict[str, Any]]: + statements = document.get("Statement", []) + if isinstance(statements, dict): + statements = [statements] + if not isinstance(statements, list) or not all( + isinstance(item, dict) for item in statements + ): + raise IamRoleError("Trust policy Statement must be an object or list.") + return [dict(item) for item in statements] + + +def trust_statement( + principal: DurablePrincipal, sid: str | None = None +) -> dict[str, Any]: + """Create one distinct exact-principal trust statement.""" + statement: dict[str, Any] = { + "Effect": "Allow", + "Principal": {"AWS": principal.arn}, + "Action": "sts:AssumeRole", + } + if sid: + statement["Sid"] = sid + return statement + + +def _is_simple_exact_trust(item: Mapping[str, Any], principal_arn: str) -> bool: + principal = item.get("Principal") + if not isinstance(principal, dict) or set(principal) != {"AWS"}: + return False + aws = principal["AWS"] + values = aws if isinstance(aws, list) else [aws] + return ( + values == [principal_arn] + and item.get("Effect") == "Allow" + and item.get("Action") == "sts:AssumeRole" + and not item.get("Condition") + and set(item) <= {"Sid", "Effect", "Principal", "Action"} + ) + + +def plan_set_trust( + role_name: str, + current: Mapping[str, Any], + desired: Mapping[str, Any], + *, + expected_hash: str | None = None, +) -> MutationPlan: + """Plan exact trust replacement with optimistic concurrency.""" + validate_trust_document(desired) + current_hash = document_hash(current) + if expected_hash and expected_hash != current_hash: + raise ConflictError("Trust policy changed since it was read.") + operation = Operation( + "iam", + "update_assume_role_policy", + {"RoleName": role_name, "PolicyDocument": canonical_json(desired)}, + "update_assume_role_policy", + {"RoleName": role_name, "PolicyDocument": canonical_json(current)}, + ) + return MutationPlan( + "trust-set", (role_name,), (operation,), {"trust": current_hash} + ) + + +def plan_add_trust( + role_name: str, + current: Mapping[str, Any], + principal: DurablePrincipal, + *, + sid: str | None = None, +) -> MutationPlan: + """Plan an idempotent distinct-statement trust grant.""" + statements = _trust_statements(current) + addition = trust_statement(principal, sid) + if any(_is_simple_exact_trust(item, principal.arn) for item in statements): + return MutationPlan( + "trust-add", + (role_name, principal.arn), + (), + {"trust": document_hash(current)}, + ) + desired = {**current, "Statement": [*statements, addition]} + return plan_set_trust(role_name, current, desired) + + +def plan_add_owned_group_trust( + role_name: str, + current: Mapping[str, Any], + principal: DurablePrincipal, +) -> MutationPlan: + """Add the distinct Hacksaws-owned account statement used by group grants.""" + statements = _trust_statements(current) + owned = [item for item in statements if item.get("Sid") == "HacksawsGroupAccount"] + if len(owned) > 1 or ( + owned and not _is_simple_exact_trust(owned[0], principal.arn) + ): + raise AmbiguousTrustError( + "HacksawsGroupAccount exists with unexpected semantics; edit it explicitly." + ) + if owned: + return MutationPlan( + "trust-add-owned-group", + (role_name, principal.arn), + (), + {"trust": document_hash(current)}, + ) + desired = { + **current, + "Statement": [*statements, trust_statement(principal, "HacksawsGroupAccount")], + } + return plan_set_trust(role_name, current, desired) + + +def plan_remove_owned_group_trust( + role_name: str, + current: Mapping[str, Any], + principal: DurablePrincipal, +) -> MutationPlan: + """Remove only Hacksaws' distinct group-account statement.""" + statements = _trust_statements(current) + owned_indexes = [ + index + for index, item in enumerate(statements) + if item.get("Sid") == "HacksawsGroupAccount" + ] + if len(owned_indexes) > 1: + raise AmbiguousTrustError( + "Multiple HacksawsGroupAccount statements exist; edit trust explicitly." + ) + if not owned_indexes: + return MutationPlan( + "trust-remove-owned-group", + (role_name, principal.arn), + (), + {"trust": document_hash(current)}, + ) + selected = statements[owned_indexes[0]] + if not _is_simple_exact_trust(selected, principal.arn): + raise AmbiguousTrustError( + "HacksawsGroupAccount has unexpected semantics; edit trust explicitly." + ) + desired = { + **current, + "Statement": [ + item for index, item in enumerate(statements) if index != owned_indexes[0] + ], + } + return plan_set_trust(role_name, current, desired) + + +def plan_remove_trust( + role_name: str, + current: Mapping[str, Any], + principal: DurablePrincipal, +) -> MutationPlan: + """Remove only an exact standalone statement, rejecting complex ambiguity.""" + statements = _trust_statements(current) + matches: list[int] = [] + for index, item in enumerate(statements): + aws = ( + item.get("Principal", {}).get("AWS") + if isinstance(item.get("Principal"), dict) + else None + ) + values = aws if isinstance(aws, list) else [aws] + if principal.arn not in values: + continue + if not _is_simple_exact_trust(item, principal.arn): + raise AmbiguousTrustError( + "Principal occurs in a complex trust statement; edit explicitly." + ) + matches.append(index) + if not matches: + return MutationPlan( + "trust-remove", + (role_name, principal.arn), + (), + {"trust": document_hash(current)}, + ) + desired = { + **current, + "Statement": [ + item for index, item in enumerate(statements) if index not in matches + ], + } + return plan_set_trust(role_name, current, desired) + + +def plan_attach_policy( + role_name: str, policy_arn: str, *, current: RoleSnapshot | None = None +) -> MutationPlan: + """Plan attachment of an existing managed policy reference.""" + if current is not None and policy_arn in current.attached_policies: + return MutationPlan( + "role-policy-attach", + (role_name, policy_arn), + (), + {"role": role_snapshot_hash(current)}, + ) + return MutationPlan( + "role-policy-attach", + (role_name, policy_arn), + ( + Operation( + "iam", + "attach_role_policy", + {"RoleName": role_name, "PolicyArn": policy_arn}, + "detach_role_policy", + {"RoleName": role_name, "PolicyArn": policy_arn}, + ), + ), + ({"role": role_snapshot_hash(current)} if current is not None else {}), + ) + + +def plan_detach_policy( + role_name: str, policy_arn: str, *, current: RoleSnapshot | None = None +) -> MutationPlan: + """Plan detachment of an existing managed policy reference.""" + if current is not None and policy_arn not in current.attached_policies: + return MutationPlan( + "role-policy-detach", + (role_name, policy_arn), + (), + {"role": role_snapshot_hash(current)}, + ) + return MutationPlan( + "role-policy-detach", + (role_name, policy_arn), + ( + Operation( + "iam", + "detach_role_policy", + {"RoleName": role_name, "PolicyArn": policy_arn}, + "attach_role_policy", + {"RoleName": role_name, "PolicyArn": policy_arn}, + ), + ), + ({"role": role_snapshot_hash(current)} if current is not None else {}), + ) + + +def plan_publish_and_attach( + role_name: str, + policy_name: str, + document: Mapping[str, Any], + account: AccountRef, + *, + path: str = DEFAULT_ROLE_PATH, +) -> MutationPlan: + """Plan publishing a local document and attaching its deterministic ARN.""" + normalize_path(path) + arn = f"arn:{account.partition}:iam::{account.account_id}:policy{path}{policy_name}" + create = Operation( + "iam", + "create_policy", + { + "PolicyName": policy_name, + "Path": path, + "PolicyDocument": canonical_json(document), + }, + "delete_policy", + {"PolicyArn": arn}, + ) + attach = plan_attach_policy(role_name, arn).operations[0] + return MutationPlan( + "policy-publish-attach", + (role_name, arn), + (create, attach), + expected={"document": document_hash(document)}, + ) + + +def plan_put_inline_policy( + role_name: str, + policy_name: str, + document: Mapping[str, Any], + *, + current: Mapping[str, Any] | None = None, + expected_hash: str | None = None, +) -> MutationPlan: + """Plan no-history PutRolePolicy with optional optimistic concurrency.""" + if expected_hash and (current is None or document_hash(current) != expected_hash): + raise ConflictError("Inline policy changed since it was read.") + compensation = ( + ( + "put_role_policy", + { + "RoleName": role_name, + "PolicyName": policy_name, + "PolicyDocument": canonical_json(current), + }, + ) + if current is not None + else ("delete_role_policy", {"RoleName": role_name, "PolicyName": policy_name}) + ) + operation = Operation( + "iam", + "put_role_policy", + { + "RoleName": role_name, + "PolicyName": policy_name, + "PolicyDocument": canonical_json(document), + }, + compensation[0], + compensation[1], + ) + expected = {"inline": document_hash(current) if current is not None else "absent"} + return MutationPlan( + "inline-policy-put", (role_name, policy_name), (operation,), expected + ) + + +def plan_delete_inline_policy( + role_name: str, + policy_name: str, + *, + current: Mapping[str, Any] | None = None, + expected_hash: str | None = None, +) -> MutationPlan: + """Plan deletion of one inline policy with optional safe compensation.""" + if expected_hash and (current is None or document_hash(current) != expected_hash): + raise ConflictError("Inline policy changed since it was read.") + compensate_action = "put_role_policy" if current is not None else None + compensate_params = ( + { + "RoleName": role_name, + "PolicyName": policy_name, + "PolicyDocument": canonical_json(current), + } + if current is not None + else None + ) + return MutationPlan( + "inline-policy-delete", + (role_name, policy_name), + ( + Operation( + "iam", + "delete_role_policy", + {"RoleName": role_name, "PolicyName": policy_name}, + compensate_action, + compensate_params, + ), + ), + expected={ + "inline": document_hash(current) if current is not None else "absent" + }, + ) + + +def export_inline_policy(document: Mapping[str, Any]) -> dict[str, Any]: + """Return a detached JSON-compatible inline-policy snapshot for serializers.""" + return decode_document(canonical_json(document)) + + +def plan_delete_role( + role: RoleSnapshot, + *, + cascade: bool = False, + remove_from_instance_profiles: bool = False, + allow_unmanaged: bool = False, +) -> MutationPlan: + """Plan dependency-complete deletion without deleting shared dependencies.""" + if role.tags.get(MANAGED_TAG) != "true" and not allow_unmanaged: + raise DependencyError( + "Role is not adopted by Hacksaws; adopt or explicitly override." + ) + dependencies = bool( + role.attached_policies + or role.inline_policies + or role.permissions_boundary + or role.instance_profiles + ) + if dependencies and not cascade: + raise DependencyError("Role has dependencies; explicit cascade is required.") + if role.instance_profiles and not remove_from_instance_profiles: + raise DependencyError( + "Instance-profile membership requires an explicit safeguard override." + ) + operations: list[Operation] = [] + for arn in role.attached_policies: + operations.extend(plan_detach_policy(role.name, arn).operations) + for name in role.inline_policies: + operations.extend( + plan_delete_inline_policy( + role.name, + name, + current=role.inline_policy_documents.get(name), + ).operations + ) + if role.permissions_boundary: + operations.append( + Operation( + "iam", + "delete_role_permissions_boundary", + {"RoleName": role.name}, + "put_role_permissions_boundary", + { + "RoleName": role.name, + "PermissionsBoundary": role.permissions_boundary, + }, + ) + ) + operations.extend( + ( + Operation( + "iam", + "remove_role_from_instance_profile", + {"InstanceProfileName": profile, "RoleName": role.name}, + "add_role_to_instance_profile", + {"InstanceProfileName": profile, "RoleName": role.name}, + ) + ) + for profile in role.instance_profiles + ) + operations.append( + Operation( + "iam", + "delete_role", + {"RoleName": role.name}, + None, + {"RoleName": role.name, "ExpectedRoleId": role.role_id}, + ) + ) + warnings_list = [ + ( + "Role deletion is irreversible: AWS assigns a new principal identity if " + "the role is recreated, so rollback stops at the delete commit point." + ) + ] + if role.instance_profiles: + warnings_list.append( + "Instance profiles are preserved; only role membership is removed." + ) + warnings = tuple(warnings_list) + return MutationPlan( + "role-delete", + (role.arn,), + tuple(operations), + expected={"role": role_snapshot_hash(role)}, + warnings=warnings, + ) + + +@dataclass(frozen=True) +class GroupGrantSnapshot: + """One group aggregate-policy snapshot for durable AssumeRole grants.""" + + group_name: str + account: AccountRef + policy_name: str + policy_arn: str + role_arns: tuple[str, ...] = () + exists: bool = True + attached: bool = True + document: Mapping[str, Any] = field( + default_factory=lambda: {"Version": "2012-10-17", "Statement": []} + ) + default_version_id: str | None = None + version_ids: tuple[str, ...] = () + tags: Mapping[str, str] = field(default_factory=dict) + owned: bool = False + path: str = DEFAULT_ROLE_PATH + + +def _group_document( + role_arns: Iterable[str], current: Mapping[str, Any] | None = None +) -> dict[str, Any]: + """Replace only Hacksaws' aggregate statement and preserve all other semantics.""" + base = decode_document(canonical_json(current or {"Version": "2012-10-17"})) + statements = _trust_statements(base) + unrelated: list[dict[str, Any]] = [] + for statement in statements: + if statement.get("Sid") != "HacksawsGroupAssumeRoles": + unrelated.append(statement) + continue + if ( + statement.get("Effect") != "Allow" + or statement.get("Action") != "sts:AssumeRole" + or set(statement) - {"Sid", "Effect", "Action", "Resource"} + ): + raise ConflictError( + "The aggregate group statement has unexpected semantics; refusing " + "to replace it." + ) + selected = sorted(set(role_arns)) + if selected: + unrelated.append( + { + "Sid": "HacksawsGroupAssumeRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": selected, + } + ) + return {**base, "Statement": unrelated} + + +def plan_group_grant( + role: RoleSnapshot, + trust: Mapping[str, Any], + group: GroupGrantSnapshot, +) -> MutationPlan: + """Plan account trust plus one aggregate managed group policy.""" + match = ROLE_ARN.fullmatch(role.arn) + if match is None or match.group(2) != group.account.account_id: + raise IamRoleError( + "Group aggregate grants require same-account role membership." + ) + account_principal = DurablePrincipal( + "account", + f"arn:{group.account.partition}:iam::{group.account.account_id}:root", + group.account.account_id, + group.account.partition, + ) + trust_plan = plan_add_owned_group_trust(role.name, trust, account_principal) + roles = tuple(sorted({*group.role_arns, role.arn})) + policy_plan = plan_put_group_snapshot(group, roles) + return MutationPlan( + "group-grant", + (role.arn, group.group_name, group.policy_arn), + (*trust_plan.operations, *policy_plan.operations), + expected={**trust_plan.expected, **policy_plan.expected}, + ) + + +def plan_put_group_snapshot( + group: GroupGrantSnapshot, role_arns: Iterable[str] +) -> MutationPlan: + """Plan replacement of the one aggregate managed group policy document.""" + roles = tuple(sorted(set(role_arns))) + for arn in roles: + match = ROLE_ARN.fullmatch(arn) + if match is None or match.group(2) != group.account.account_id: + raise IamRoleError("Every group grant role must be in the group's account.") + if group.exists and not group.owned: + raise ConflictError( + "Aggregate group policy exists but is not verified as Hacksaws-owned." + ) + document = _group_document(roles, group.document) + operations: list[Operation] = [] + operations.append( + Operation( + "managed_policy", + "publish_owned_policy", + { + "PolicyArn": group.policy_arn, + "PolicyName": group.policy_name, + "Path": group.path, + "PolicyDocument": document, + "ExpectedDocumentHash": ( + document_hash(group.document) if group.exists else "absent" + ), + **( + { + "ExpectedDefaultVersionId": group.default_version_id, + "ExpectedTagHash": document_hash(dict(group.tags)), + } + if group.exists + else {} + ), + "ResourceId": f"group-{group.group_name}", + }, + "restore_owned_policy", + { + "PolicyArn": group.policy_arn, + "PolicyName": group.policy_name, + "Path": group.path, + "PolicyDocument": dict(group.document), + "ExpectedDocumentHash": document_hash(document), + **( + {"ExpectedTagHash": document_hash(dict(group.tags))} + if group.exists + else {} + ), + "DeleteIfCreated": not group.exists, + "ResourceId": f"group-{group.group_name}", + }, + ) + ) + if not group.attached: + operations.append( + Operation( + "iam", + "attach_group_policy", + {"GroupName": group.group_name, "PolicyArn": group.policy_arn}, + "detach_group_policy", + {"GroupName": group.group_name, "PolicyArn": group.policy_arn}, + ) + ) + return MutationPlan( + "group-snapshot", + (group.group_name, *roles), + tuple(operations), + expected={ + "group": document_hash(group.document) if group.exists else "absent", + "groupPolicyArn": group.policy_arn, + "groupName": group.group_name, + "groupAttached": str(group.attached).lower(), + }, + ) + + +def plan_add_group_member(group: GroupGrantSnapshot, role_arn: str) -> MutationPlan: + """Add one role to a same-account group aggregate snapshot.""" + return plan_put_group_snapshot(group, (*group.role_arns, role_arn)) + + +def plan_sync_group_members( + group: GroupGrantSnapshot, role_arns: Iterable[str] +) -> MutationPlan: + """Synchronize all same-account role grants in one aggregate snapshot.""" + return plan_put_group_snapshot(group, role_arns) + + +def plan_remove_group_member(group: GroupGrantSnapshot, role_arn: str) -> MutationPlan: + """Remove one role from a same-account group aggregate snapshot.""" + return plan_put_group_snapshot( + group, (arn for arn in group.role_arns if arn != role_arn) + ) + + +@dataclass(frozen=True) +class Assumability: + """Static potential-assumability classification.""" + + classification: Literal["potentially-allowed", "denied", "indeterminate"] + reasons: tuple[str, ...] + + +def classify_assumability( + *, + trust_allows: bool | None, + identity_allows: bool | None, + explicit_deny: bool = False, +) -> Assumability: + """Classify static evidence without claiming a live authorization result.""" + if explicit_deny or trust_allows is False or identity_allows is False: + return Assumability( + "denied", + ("Static policy evidence contains a denial or missing required allow.",), + ) + if trust_allows is True and identity_allows is True: + return Assumability( + "potentially-allowed", + ("Trust and identity policies contain required allows.",), + ) + policy_qualifiers = "Conditions, boundaries, SCPs, or unavailable policy data" + return Assumability( + "indeterminate", + (f"{policy_qualifiers} may decide access.",), + ) + + +DENY_ALL = canonical_json( + { + "Version": "2012-10-17", + "Statement": [{"Effect": "Deny", "Action": "*", "Resource": "*"}], + } +) + + +class StsProbe(Protocol): + """Explicit live AssumeRole probe interface.""" + + def probe( + self, role_arn: str, session_name: str, external_id: str | None = None + ) -> Mapping[str, Any]: ... + + +@dataclass +class BotoStsProbe: + """STS probe that returns metadata and never persists returned credentials.""" + + client: Any + + def probe( + self, role_arn: str, session_name: str, external_id: str | None = None + ) -> Mapping[str, Any]: + request: dict[str, Any] = { + "RoleArn": role_arn, + "RoleSessionName": session_name, + "DurationSeconds": 900, + "Policy": DENY_ALL, + } + if external_id: + request["ExternalId"] = external_id + response = self.client.assume_role(**request) + return {"ok": True, "packed_policy_size": response.get("PackedPolicySize")} + + +@dataclass +class IamRoleService: + """Read-side IAM role service with pagination and injected consistency retry.""" + + client: Any + attempts: int = 4 + delay: float = 0.1 + sleep: Callable[[float], None] = time.sleep + + def _retry(self, operation: Callable[[], Any]) -> Any: + last: Exception | None = None + for attempt in range(self.attempts): + try: + return operation() + except (BotoCoreError, ClientError) as error: + last = error + if attempt + 1 < self.attempts: + self.sleep(self.delay * (attempt + 1)) + if last is None: # pragma: no cover - attempts is validated by construction + raise IamRoleError("Retry loop did not execute.") + raise last + + def get_role(self, name: str) -> RoleSnapshot: + """Read a role and all deletion-relevant dependencies.""" + response = self._retry(lambda: self.client.get_role(RoleName=name))["Role"] + trust = decode_document(response["AssumeRolePolicyDocument"]) + attached = tuple( + item["PolicyArn"] + for item in self._pages( + "list_attached_role_policies", "AttachedPolicies", RoleName=name + ) + ) + inline = tuple(self._pages("list_role_policies", "PolicyNames", RoleName=name)) + inline_documents = { + policy_name: self.get_inline_policy(name, policy_name) + for policy_name in inline + } + profiles = tuple( + item["InstanceProfileName"] + for item in self._pages( + "list_instance_profiles_for_role", "InstanceProfiles", RoleName=name + ) + ) + tags = {item["Key"]: item["Value"] for item in response.get("Tags", [])} + boundary = response.get("PermissionsBoundary", {}).get("PermissionsBoundaryArn") + return RoleSnapshot( + name, + response["Arn"], + response.get("Path", "/"), + trust, + response.get("Description"), + response.get("MaxSessionDuration", 3600), + boundary, + tags, + attached, + inline, + profiles, + inline_documents, + str(response.get("RoleId", "")), + ) + + def list_roles( + self, *, path_prefix: str = DEFAULT_ROLE_PATH + ) -> tuple[RoleSnapshot, ...]: + """List role summaries across all IAM pages.""" + return tuple( + RoleSnapshot( + item["RoleName"], + item["Arn"], + item.get("Path", "/"), + decode_document(item["AssumeRolePolicyDocument"]), + item.get("Description"), + item.get("MaxSessionDuration", 3600), + role_id=str(item.get("RoleId", "")), + ) + for item in self._pages("list_roles", "Roles", PathPrefix=path_prefix) + ) + + def list_inline_policies(self, role_name: str) -> tuple[str, ...]: + """List every named inline policy for a role.""" + return tuple( + self._pages("list_role_policies", "PolicyNames", RoleName=role_name) + ) + + def get_inline_policy(self, role_name: str, policy_name: str) -> dict[str, Any]: + """Read one named inline policy document with consistency retry.""" + response = self._retry( + lambda: self.client.get_role_policy( + RoleName=role_name, PolicyName=policy_name + ) + ) + return decode_document(response["PolicyDocument"]) + + def get_trust(self, role_name: str) -> dict[str, Any]: + """Read one role trust policy.""" + response = self._retry(lambda: self.client.get_role(RoleName=role_name)) + return decode_document(response["Role"]["AssumeRolePolicyDocument"]) + + def _pages(self, operation: str, key: str, **params: Any) -> Iterable[Any]: + paginator = self.client.get_paginator(operation) + for page in paginator.paginate(**params): + yield from page.get(key, []) diff --git a/hacksaws/_output.py b/hacksaws/_output.py new file mode 100644 index 0000000..6ef1e87 --- /dev/null +++ b/hacksaws/_output.py @@ -0,0 +1,109 @@ +"""Terminal and machine-output primitives for the Hacksaws command line.""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from typing import TYPE_CHECKING +from typing import Literal + +from rich.console import Console +from rich.table import Table +from rich.text import Text + +if TYPE_CHECKING: + from collections.abc import Iterable + +ColorMode = Literal["auto", "always", "never"] +SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class OutputOptions: + """Invocation-level output controls, independent of business logic.""" + + color: ColorMode = "auto" + json: bool = False + + +def color_enabled( + options: OutputOptions, + *, + stream: object | None = None, + environ: dict[str, str] | None = None, +) -> bool: + """Return whether terminal decoration is safe and requested.""" + if options.json or options.color == "never": + return False + if options.color == "always": + return True + values = os.environ if environ is None else environ + target = sys.stdout if stream is None else stream + return ( + not values.get("NO_COLOR") + and values.get("TERM") != "dumb" + and bool(getattr(target, "isatty", lambda: False)()) + ) + + +def console_for(options: OutputOptions, *, stream: object) -> Console: + """Construct a Rich console with deterministic color behavior.""" + enabled = color_enabled(options, stream=stream) + return Console( + file=stream, # type: ignore[arg-type] + force_terminal=enabled, + color_system="auto" if enabled else None, + no_color=not enabled, + highlight=False, + ) + + +def print_message( + message: str, + *, + stream: object, + options: OutputOptions, + kind: str = "info", +) -> None: + """Print a single semantic message while retaining plain-text compatibility.""" + styles = {"success": "green", "warning": "yellow", "error": "bold red"} + console_for(options, stream=stream).print(Text(message, style=styles.get(kind, ""))) + + +def compact_table( + columns: Iterable[str], + rows: Iterable[Iterable[object]], + *, + title: str | None = None, +) -> Table: + """Create the compact table style used by human-oriented list commands.""" + table = Table(title=title, box=None, pad_edge=False, show_header=True) + for column in columns: + table.add_column(column, no_wrap=True) + for row in rows: + table.add_row(*(str(value) for value in row)) + return table + + +def legend(items: Iterable[tuple[str, str]]) -> Text: + """Return a compact, presentation-independent legend primitive.""" + return Text(" ".join(f"{label}: {meaning}" for label, meaning in items)) + + +def confirm( + prompt: str, + *, + assume_yes: bool = False, + stdin: object | None = None, + interactive: bool = True, +) -> bool: + """Ask a safe default-no confirmation without allowing noninteractive hangs.""" + if assume_yes: + return True + if not interactive: + return False + source = sys.stdin if stdin is None else stdin + if not bool(getattr(source, "isatty", lambda: False)()): + return False + return input(f"{prompt} [y/N] ").strip().casefold() in {"y", "yes"} diff --git a/hacksaws/_policies.py b/hacksaws/_policies.py index 25e9902..a957fe3 100644 --- a/hacksaws/_policies.py +++ b/hacksaws/_policies.py @@ -2,9 +2,11 @@ from __future__ import annotations +import fnmatch import json import re import tomllib +from contextlib import suppress from dataclasses import dataclass from datetime import UTC from datetime import datetime @@ -203,15 +205,21 @@ def cache_write( source_identity: str, ) -> str: compact = minify(document) - record = { - "schema_version": 1, + bound = { + "identity": identity, "origin": origin, "resolver": resolver, "source_identity": source_identity, - "fetched_at": datetime.now(UTC).isoformat(), - "digest": _state.digest(compact.encode()), "document": json.loads(compact), } + record = { + "schema_version": 2, + **bound, + "fetched_at": datetime.now(UTC).isoformat(), + "digest": _state.digest( + json.dumps(bound, sort_keys=True, separators=(",", ":")).encode() + ), + } _state.atomic_write( _cache_path(identity), (json.dumps(record, indent=2) + "\n").encode() ) @@ -225,18 +233,148 @@ def cache_read(identity: str, max_age: int) -> tuple[dict[str, Any], float] | No if not path.exists(): return None try: - value = json.loads(path.read_text(encoding="utf-8")) - fetched = datetime.fromisoformat(value["fetched_at"]) + value, fetched, _digest = _validated_cache_record( + json.loads(path.read_text(encoding="utf-8")), identity=identity + ) age = (datetime.now(UTC) - fetched).total_seconds() except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error: raise OperationalError(f"Invalid policy cache entry {path}: {error}") from error if age > max_age: return None - if not isinstance(value, dict): - raise OperationalError(f"Invalid policy cache entry {path}.") return value, age +def _validated_cache_record( + value: object, *, identity: str +) -> tuple[dict[str, Any], datetime, str]: + if not isinstance(value, dict): + raise TypeError("entry is not an object") + fetched = datetime.fromisoformat(str(value["fetched_at"])) + document = json.loads(minify(value["document"])) + if value.get("schema_version") != 2: + raise ValueError("unsupported schema version") + if value.get("identity") != identity: + raise ValueError("entry identity does not match its cache key") + bound = { + "identity": identity, + "origin": str(value["origin"]), + "resolver": str(value["resolver"]), + "source_identity": str(value["source_identity"]), + "document": document, + } + digest = _state.digest( + json.dumps(bound, sort_keys=True, separators=(",", ":")).encode() + ) + if value.get("digest") != digest: + raise ValueError("document digest mismatch") + return value, fetched, digest + + +def _public_cache_entry(path: Path, max_age: int) -> dict[str, Any]: + identity = path.stem + base: dict[str, Any] = { + "identity": identity, + "path": str(path), + } + try: + base["size"] = path.stat().st_size + value, fetched, digest = _validated_cache_record( + json.loads(path.read_text(encoding="utf-8")), identity=identity + ) + age = max(0.0, (datetime.now(UTC) - fetched).total_seconds()) + base.update( + { + "state": "fresh" if max_age > 0 and age <= max_age else "stale", + "origin": value.get("origin"), + "resolver": value.get("resolver"), + "source_identity": value.get("source_identity"), + "fetched_at": value["fetched_at"], + "age_seconds": int(age), + "digest": digest, + } + ) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error: + base.update( + {"size": int(base.get("size", 0)), "state": "invalid", "error": str(error)} + ) + return base + + +def cache_inventory(*, max_age: int | None = None) -> dict[str, Any]: + """Return secret-free policy-cache metadata, including invalid entries.""" + configured_age = ( + _state.load_config()["cache"]["max_age"] if max_age is None else max_age + ) + paths = sorted(cache_root().glob("*.json")) if cache_root().exists() else [] + entries = [_public_cache_entry(path, configured_age) for path in paths] + counts = {"fresh": 0, "stale": 0, "invalid": 0} + for entry in entries: + counts[str(entry["state"])] += 1 + return { + "root": str(cache_root()), + "max_age": configured_age, + "entries": entries, + "counts": counts, + "total_bytes": sum(int(item["size"]) for item in entries), + } + + +def _validated_cache_path(identity: str) -> Path: + normalized = identity.removesuffix(".json") + if not re.fullmatch(r"[A-Za-z0-9._-]+", normalized): + raise OperationalError(f"Invalid policy cache identity {identity!r}.") + return _cache_path(normalized) + + +def cache_show(identity: str) -> dict[str, Any]: + """Read one explicitly requested policy-cache entry, including its document.""" + path = _validated_cache_path(identity) + if not path.is_file(): + raise OperationalError(f"Policy cache entry does not exist: {identity}") + try: + value, _fetched, _digest = _validated_cache_record( + json.loads(path.read_text(encoding="utf-8")), identity=path.stem + ) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as error: + raise OperationalError(f"Invalid policy cache entry {path}: {error}") from error + return {"identity": path.stem, "path": str(path), **value} + + +def clear_cache_entries( + identities: list[str] | None = None, *, stale_only: bool = False +) -> list[str]: + """Remove selected policy-cache records and return removed identities.""" + if identities: + inventory = cache_inventory() + paths = [ + Path(str(item["path"])) + for item in inventory["entries"] + if any( + fnmatch.fnmatchcase( + str(item["identity"]).casefold(), selector.casefold() + ) + for selector in identities + ) + ] + else: + inventory = cache_inventory() + allowed = {"stale", "invalid"} if stale_only else {"fresh", "stale", "invalid"} + paths = [ + Path(str(item["path"])) + for item in inventory["entries"] + if item["state"] in allowed + ] + removed = [] + for path in paths: + if path.is_file(): + path.unlink() + removed.append(path.stem) + if cache_root().exists(): + with suppress(OSError): + cache_root().rmdir() + return removed + + def resolve( value: str, *, @@ -324,6 +462,25 @@ def _get_document(client: Any, arn: str, version: str) -> object: return document +def _require_cache_metadata( + record: dict[str, Any], + *, + identity: str, + origin: str, + resolver: str, + source_identity: str | None = None, +) -> None: + expected = {"origin": origin, "resolver": resolver} + mismatches = [key for key, value in expected.items() if record.get(key) != value] + if source_identity is not None and record.get("source_identity") != source_identity: + mismatches.append("source_identity") + if mismatches: + raise OperationalError( + f"Policy cache entry {identity!r} metadata does not match the requested " + f"policy ({', '.join(mismatches)}). Clear the entry and retry." + ) + + def _fetch_aws_managed( arn: str, *, @@ -339,6 +496,13 @@ def _fetch_aws_managed( ) cached = cache_read(identity, configured_age) if cached: + _require_cache_metadata( + cached[0], + identity=identity, + origin="aws-managed", + resolver="arn", + source_identity=arn, + ) compact = minify(cached[0]["document"]) enforce_inline_limit(compact) return ResolvedPolicy( @@ -385,32 +549,30 @@ def _resolve_remote_name( ) cached = cache_read(identity, configured_age) if cached: - origin = str(cached[0]["origin"]) - compact = minify(cached[0]["document"]) - if origin == "remote-customer": - cached_arn = str(cached[0]["source_identity"]) - cached_match = POLICY_ARN.fullmatch(cached_arn) - if ( - cached_match is None - or cached_match.group(1) != partition - or cached_match.group(2) != account_id - ): - raise OperationalError( - "Cached customer policy identity does not match the target account." - ) - return ResolvedPolicy( - identity, - origin, - f"cached policy ({cached[1]:.0f}s old)", - arn=cached_arn, - cached=True, + _require_cache_metadata( + cached[0], + identity=identity, + origin="remote-customer", + resolver="name", + ) + origin = "remote-customer" + cached_arn = str(cached[0]["source_identity"]) + cached_match = POLICY_ARN.fullmatch(cached_arn) + if ( + cached_match is None + or cached_match.group(1) != partition + or cached_match.group(2) != account_id + or cached_match.group(3).rsplit("/", 1)[-1] != name + ): + raise OperationalError( + "Cached customer policy identity does not match the requested policy " + "name and target account." ) - enforce_inline_limit(compact) return ResolvedPolicy( identity, origin, f"cached policy ({cached[1]:.0f}s old)", - document=compact, + arn=cached_arn, cached=True, ) resolution_session = session or boto3.Session(profile_name=profile) diff --git a/hacksaws/_sessions.py b/hacksaws/_sessions.py index f0f313e..cc1a661 100644 --- a/hacksaws/_sessions.py +++ b/hacksaws/_sessions.py @@ -5,7 +5,9 @@ import base64 import configparser import copy +import fnmatch import getpass +import importlib import json import os import re @@ -20,6 +22,7 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import Any +from typing import cast import boto3 import yaml @@ -45,6 +48,7 @@ "AWS_DEFAULT_PROFILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", + "AWS_LOGIN_CACHE_DIRECTORY", } @@ -106,6 +110,7 @@ def _begin( ) -> dict[str, Any]: cache_snapshots = [] for cache_root in cache_roots or []: + root_exists = cache_root.exists() existing = ( [ _snapshot(path.absolute()) @@ -115,7 +120,23 @@ def _begin( if cache_root.exists() else [] ) - cache_snapshots.append({"root": str(cache_root.absolute()), "files": existing}) + directories = ( + [ + str(path.absolute()) + for path in [cache_root, *cache_root.rglob("*")] + if path.is_dir() + ] + if root_exists + else [] + ) + cache_snapshots.append( + { + "root": str(cache_root.absolute()), + "root_exists": root_exists, + "directories": directories, + "files": existing, + } + ) journal = { "schema_version": 1, "started_at": _state.iso_now(), @@ -165,6 +186,24 @@ def _rollback(journal: dict[str, Any]) -> None: _restore(cache_file_snapshot) except OSError as error: failures.append(f"cache {cache_file_snapshot['path']}: {error}") + before_directories = { + str(Path(value).absolute()) for value in snapshot.get("directories", []) + } + if "directories" in snapshot and cache_root.exists(): + current_directories = sorted( + (path for path in cache_root.rglob("*") if path.is_dir()), + key=lambda path: len(path.parts), + reverse=True, + ) + if not snapshot.get("root_exists", True): + current_directories.append(cache_root) + for directory in current_directories: + if str(directory.absolute()) in before_directories: + continue + try: + directory.rmdir() + except OSError as error: + failures.append(f"cache directory {directory}: {error}") for snapshot in reversed(journal["files"]): try: _restore(snapshot) @@ -202,6 +241,33 @@ def _commit() -> None: _journal_path().unlink(missing_ok=True) +def _changed_cache_files(snapshot: dict[str, Any]) -> list[str]: + """Return cache files created or changed by the current transaction.""" + cache_root = Path(snapshot["root"]).absolute() + before = { + item["path"]: base64.b64decode(item["data"]) + for item in snapshot.get("files", []) + } + if not cache_root.exists(): + return [] + changed = [] + for path in cache_root.rglob("*"): + if not path.is_file(): + continue + absolute = str(path.absolute()) + if absolute not in before or path.read_bytes() != before[absolute]: + changed.append(absolute) + return sorted(changed) + + +def _cache_fingerprints(paths: list[str]) -> dict[str, str]: + """Fingerprint tracked cache content so logout cannot remove replacements.""" + return { + str(Path(value).absolute()): _state.digest(Path(value).read_bytes()) + for value in paths + } + + def _read_ini(path: Path) -> configparser.ConfigParser: parser = configparser.ConfigParser(interpolation=None) if path.exists(): @@ -226,6 +292,75 @@ def _section(profile: str, *, config: bool) -> str: return profile if not config or profile == "default" else f"profile {profile}" +def _section_values( + parser: configparser.ConfigParser, section: str +) -> dict[str, str] | None: + if section not in parser: + return None + return dict(parser[section].items()) + + +def _section_fingerprint(values: dict[str, str] | None) -> str: + if values is None: + return _state.digest(b"hacksaws:absent-section") + encoded = json.dumps(values, sort_keys=True, separators=(",", ":")).encode() + return _state.digest(encoded) + + +def _section_state(path: Path, section: str) -> dict[str, Any]: + values = _section_values(_read_ini(path), section) + return { + "exists": values is not None, + "fingerprint": _section_fingerprint(values), + } + + +def _snapshot_bytes(journal: dict[str, Any], path: Path) -> bytes | None: + absolute = path.absolute() + for snapshot in journal.get("files", []): + if Path(str(snapshot.get("path", ""))).absolute() != absolute: + continue + if not snapshot.get("exists"): + return None + return base64.b64decode(str(snapshot["data"])) + return path.read_bytes() if path.exists() else None + + +def _section_backup( + destination: Path, + profile: str, + journal: dict[str, Any], + previous: dict[str, Any] | None, +) -> dict[str, Any]: + result: dict[str, Any] = {} + previous_sections = previous.get("section_backup", {}) if previous else {} + for kind, filename, is_config in ( + ("credentials", "credentials", False), + ("config", "config", True), + ): + path = destination / filename + section = _section(profile, config=is_config) + previous_item = previous_sections.get(kind) + if isinstance(previous_item, dict) and isinstance( + previous_item.get("original"), dict + ): + original = copy.deepcopy(previous_item["original"]) + else: + original_parser = _parser_from_bytes(_snapshot_bytes(journal, path), path) + original_values = _section_values(original_parser, section) + original = { + "exists": original_values is not None, + "values": original_values or {}, + } + result[kind] = { + "path": str(path.absolute()), + "section": section, + "original": original, + "installed": _section_state(path, section), + } + return result + + def _partition(arn: str) -> str: parts = arn.split(":", 2) if len(parts) < 2 or parts[0] != "arn" or parts[1] not in _state.PARTITIONS: @@ -260,34 +395,45 @@ def _paths(args: Any) -> tuple[Path, str, Path, str]: if target.get("source_directory") else _state.aws_directory(target.get("source_location")) ) - source_profile = str(target.get("source_profile", "default")) + source_profile = _normalize_profile(target.get("source_profile")) if target.get("destination_directory"): destination_dir = Path(target["destination_directory"]) elif target.get("destination_location"): destination_dir = _state.aws_directory(target["destination_location"]) else: destination_dir = source_dir - destination_profile = str(target.get("destination_profile", source_profile)) + destination_profile = _normalize_profile( + target.get("destination_profile", source_profile) + ) return source_dir, source_profile, destination_dir, destination_profile source = Path(args.directory).expanduser().absolute() - profile = args.profile or "default" + profile = _normalize_profile(args.profile) if getattr(args, "aws_account_name", None): source = _state.aws_directory(args.aws_account_name) if getattr(args, "to", None): location, separator, destination_profile = args.to.partition(":") if not separator or not destination_profile: raise _configs.OperationalError("--to must be LOCATION:PROFILE.") - return source, profile, _state.aws_directory(location), destination_profile + return ( + source, + profile, + _state.aws_directory(location), + _normalize_profile(destination_profile), + ) if getattr(args, "to_directory", None): return ( source, profile, Path(args.to_directory).expanduser().absolute(), - args.to_profile, + _normalize_profile(args.to_profile), ) return source, profile, source, profile +def _normalize_profile(value: object) -> str: + return "default" if value in {None, ".", "default"} else str(value) + + def _target_details(args: Any, source_account: str, partition: str) -> dict[str, Any]: data = _state.load_config() target: dict[str, Any] = {} @@ -377,6 +523,13 @@ def _require_concrete_role(args: Any, role: str | None) -> None: ) +def _require_bounded_browser_role(role: str | None) -> str: + """Return a resolved browser boundary role or fail closed.""" + if not role: + raise _configs.OperationalError("Bounded browser login requires a role.") + return role + + def _configured_role_before_auth(args: Any) -> str | None: """Resolve only local target/boundary role configuration before browser auth.""" if getattr(args, "role", None): @@ -543,6 +696,7 @@ def _record( *, method: str, ecr: list[str] | None = None, + ecr_engine: str | None = None, ) -> None: sessions = _state.load_sessions() key = f"{destination.absolute()}::{profile}" @@ -557,6 +711,23 @@ def _record( metadata["login_cache_files"] = list( dict.fromkeys([*previous_cache, *current_cache]) ) + previous_cache_directories = ( + previous.get("login_cache_directories", []) if previous else [] + ) + current_cache_directories = metadata.get("login_cache_directories", []) + if previous_cache_directories or current_cache_directories: + metadata["login_cache_directories"] = list( + dict.fromkeys([*previous_cache_directories, *current_cache_directories]) + ) + previous_fingerprints = ( + previous.get("login_cache_fingerprints", {}) if previous else {} + ) + current_fingerprints = metadata.get("login_cache_fingerprints", {}) + if previous_fingerprints or current_fingerprints: + metadata["login_cache_fingerprints"] = { + **previous_fingerprints, + **current_fingerprints, + } sessions[key] = { **metadata, "destination": str(destination.absolute()), @@ -564,7 +735,11 @@ def _record( "auth_method": method, "started_at": _state.iso_now(), "backup": original_backup, + "section_backup": _section_backup(destination, profile, journal, previous), "ecr": list(dict.fromkeys([*previous_ecr, *(ecr or [])])), + "ecr_engine": ( + previous.get("ecr_engine") if previous and not ecr_engine else ecr_engine + ), } _state.save_sessions(sessions) @@ -743,6 +918,7 @@ def mfa_login(context: _configs.Context) -> _configs.Result: journal, method="mfa", ecr=ecr_registries, + ecr_engine=context.container_engine if ecr_registries else None, ) _commit() except Exception: @@ -777,8 +953,31 @@ def _aws_cli_version() -> tuple[int, int, int]: return int(match.group(1)), int(match.group(2)), int(match.group(3)) +def _require_browser_runtime() -> None: + """Fail before browser authentication when the Boto3 login provider is unusable.""" + try: + importlib.import_module("awscrt.auth") + importlib.import_module("awscrt.io") + except (ImportError, OSError) as error: + raise _configs.OperationalError( + "Browser login requires AWS Common Runtime (CRT) support. Reinstall " + "Hacksaws with its runtime dependencies (use `uv sync` in a source " + "checkout, or refresh the `uvx hacksaws` installation) and retry." + ) from error + + +def _native_login_cache() -> Path: + """Resolve the cache directory external AWS tools will use after native login.""" + configured = os.environ.get("AWS_LOGIN_CACHE_DIRECTORY") + if configured: + return Path(configured).expanduser().absolute() + return Path.home() / ".aws" / "login" / "cache" + + def _clean_env( - config: Path | None = None, credentials: Path | None = None + config: Path | None = None, + credentials: Path | None = None, + login_cache: Path | None = None, ) -> dict[str, str]: env = { key: value for key, value in os.environ.items() if key not in _CONFLICTING_ENV @@ -787,17 +986,22 @@ def _clean_env( env["AWS_CONFIG_FILE"] = str(config) if credentials: env["AWS_SHARED_CREDENTIALS_FILE"] = str(credentials) + if login_cache: + env["AWS_LOGIN_CACHE_DIRECTORY"] = str(login_cache) return env @contextmanager -def _aws_environment(config: Path, credentials: Path) -> Iterator[None]: +def _aws_environment( + config: Path, credentials: Path, login_cache: Path +) -> Iterator[None]: """Temporarily scrub inherited AWS identity/path variables for one staging area.""" previous = {key: os.environ.get(key) for key in _CONFLICTING_ENV} for key in _CONFLICTING_ENV: os.environ.pop(key, None) os.environ["AWS_CONFIG_FILE"] = str(config) os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(credentials) + os.environ["AWS_LOGIN_CACHE_DIRECTORY"] = str(login_cache) try: yield finally: @@ -809,20 +1013,32 @@ def _aws_environment(config: Path, credentials: Path) -> Iterator[None]: os.environ[key] = value -def _aws_login(config: Path, credentials: Path, profile: str, *, remote: bool) -> None: +def _aws_login( + config: Path, + credentials: Path, + profile: str, + *, + remote: bool, + login_cache: Path, +) -> None: _aws_cli_version() config.parent.mkdir(parents=True, exist_ok=True) command = ["aws", "login", "--profile", profile] if remote: command.append("--remote") try: - subprocess.run(command, check=True, env=_clean_env(config, credentials)) + subprocess.run( + command, + check=True, + env=_clean_env(config, credentials, login_cache), + ) except (FileNotFoundError, OSError, subprocess.CalledProcessError) as error: raise _configs.OperationalError(f"AWS browser login failed: {error}") from error def browser_login(context: _configs.Context) -> _configs.Result: """Run AWS-native browser login or isolate it before a role boundary.""" + _require_browser_runtime() args = context.args source_dir, source_profile, destination_dir, destination_profile = _paths(args) # Determine role from config without requiring caller identity first. @@ -830,7 +1046,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: _require_concrete_role(args, configured_role) has_boundary = configured_role is not None if not has_boundary: - native_cache = destination_dir / "login" / "cache" + native_cache = _native_login_cache() journal = _begin( [ destination_dir / "config", @@ -840,30 +1056,23 @@ def browser_login(context: _configs.Context) -> _configs.Result: cache_roots=[native_cache], ) ecr_registries: list[str] = [] + login_completed = False try: _aws_login( destination_dir / "config", destination_dir / "credentials", destination_profile, remote=args.remote, + login_cache=native_cache, ) + login_completed = True with _aws_environment( - destination_dir / "config", destination_dir / "credentials" + destination_dir / "config", + destination_dir / "credentials", + native_cache, ): native = boto3.Session(profile_name=destination_profile) account, partition, _ = _identity(native, label="browser login") - cache_before = { - item["path"] for item in journal["cache_snapshots"][0]["files"] - } - cache_after = ( - { - str(path.absolute()) - for path in native_cache.rglob("*") - if path.is_file() - } - if native_cache.exists() - else set() - ) target = _target_details(args, account, partition) if args.ecr: aws_account = _configs.AwsAccount( @@ -882,6 +1091,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: journal, context.container_engine, registry ), ) + changed_cache = _changed_cache_files(journal["cache_snapshots"][0]) _record( destination_dir, destination_profile, @@ -894,15 +1104,23 @@ def browser_login(context: _configs.Context) -> _configs.Result: "policy": None, "policy_provenance": "AWS-native login_session", "expires_at": None, - "login_cache_files": sorted(cache_after - cache_before), + "login_cache_files": changed_cache, + "login_cache_directories": [str(native_cache.absolute())], + "login_cache_fingerprints": _cache_fingerprints(changed_cache), }, journal, method="browser-native", ecr=ecr_registries, + ecr_engine=context.container_engine if ecr_registries else None, ) _commit() - except Exception: + except Exception as error: _rollback(journal) + if login_completed and isinstance(error, _configs.OperationalError): + raise _configs.OperationalError( + f"{error} Browser login changes for profile " + f"{destination_profile!r} were rolled back." + ) from error raise return _configs.Result( "BROWSER_LOGIN", @@ -912,6 +1130,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: staging = _state.root() / "staging" / uuid.uuid4().hex staging_config = staging / "config" staging_credentials = staging / "credentials" + staging_cache = staging / "login" / "cache" journal = _begin( [ destination_dir / "config", @@ -921,22 +1140,17 @@ def browser_login(context: _configs.Context) -> _configs.Result: cache_roots=[staging], ) ecr_registries = [] + login_completed = False try: _aws_login( - staging_config, staging_credentials, source_profile, remote=args.remote - ) - env = _clean_env(staging_config, staging_credentials) - old = { - key: os.environ.get(key) - for key in ("AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE") - } - os.environ.update( - { - key: env[key] - for key in ("AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE") - } + staging_config, + staging_credentials, + source_profile, + remote=args.remote, + login_cache=staging_cache, ) - try: + login_completed = True + with _aws_environment(staging_config, staging_credentials, staging_cache): intermediate = boto3.Session(profile_name=source_profile) source_account, partition, _ = _identity( intermediate, label="browser staging login" @@ -945,10 +1159,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: role, policy, external_id, boundary_name = _role_details( args, target, source_account, partition ) - if not role: - raise _configs.OperationalError( - "Bounded browser login requires a role." - ) + role = _require_bounded_browser_role(role) if args.ecr: aws_account = _configs.AwsAccount( { @@ -976,12 +1187,6 @@ def browser_login(context: _configs.Context) -> _configs.Result: external_id=external_id, boundary_name=boundary_name, ) - finally: - for key, value in old.items(): - if value is None: - os.environ.pop(key, None) - else: - os.environ[key] = value _save_credentials( destination_dir / "credentials", destination_profile, credentials ) @@ -1005,10 +1210,16 @@ def browser_login(context: _configs.Context) -> _configs.Result: journal, method="browser-boundary", ecr=ecr_registries, + ecr_engine=context.container_engine if ecr_registries else None, ) _commit() - except Exception: + except Exception as error: _rollback(journal) + if login_completed and isinstance(error, _configs.OperationalError): + raise _configs.OperationalError( + f"{error} Browser login changes for profile " + f"{destination_profile!r} were rolled back." + ) from error raise finally: shutil.rmtree(staging, ignore_errors=True) @@ -1018,61 +1229,586 @@ def browser_login(context: _configs.Context) -> _configs.Result: ) -def logout(context: _configs.Context) -> bool: - """Restore an expanded session locally, making no AWS logout call.""" - _source, _source_profile, destination, profile = _paths(context.args) - sessions = _state.load_sessions() - key = f"{destination.absolute()}::{profile}" - session = sessions.get(key) - if not session: - return False - for snapshot in reversed(session.get("backup", [])): - if Path(snapshot["path"]) == _state.sessions_path(): - continue - _restore(snapshot) - if session.get("auth_method") == "browser-native": - allowed_root = (destination / "login" / "cache").absolute() - for value in session.get("login_cache_files", []): - cache_file = Path(value).absolute() - if allowed_root in cache_file.parents: - cache_file.unlink(missing_ok=True) - if context.args.ecr: - for registry in session.get("ecr", []): - _ecr._run_container_engine( - context.container_engine, [context.container_engine, "logout", registry] +def _location_for_directory(directory: Path) -> str | None: + absolute = directory.expanduser().absolute() + default = (Path.home() / ".aws").absolute() + if absolute == default: + return "default" + if absolute.parent == Path.home().absolute() and absolute.name.startswith(".aws-"): + return absolute.name[5:] or None + return None + + +def _managed_section_state(session: dict[str, Any]) -> str | None: + sections = session.get("section_backup") + if not isinstance(sections, dict) or not sections: + return None + missing = False + for item in sections.values(): + if not isinstance(item, dict): + return "drifted" + path = Path(str(item.get("path", ""))).absolute() + section = str(item.get("section", "")) + installed = item.get("installed") + if not section or not isinstance(installed, dict): + return "drifted" + current = _section_state(path, section) + if current != installed: + if installed.get("exists") and not current["exists"]: + missing = True + else: + return "drifted" + return "missing" if missing else None + + +def _public_session(session: dict[str, Any], *, now: datetime) -> dict[str, Any]: + hidden = { + "backup", + "section_backup", + "login_cache_files", + "login_cache_directories", + "login_cache_fingerprints", + } + public = {key: value for key, value in session.items() if key not in hidden} + destination = Path(str(public.get("destination", Path.home() / ".aws"))).absolute() + public["destination"] = str(destination) + public["location"] = _location_for_directory(destination) + public["managed"] = True + drift = _managed_section_state(session) + expiry = public.get("expires_at") + remaining: int | None = None + if expiry: + try: + remaining = max( + 0, int((datetime.fromisoformat(str(expiry)) - now).total_seconds()) ) - del sessions[key] - elif session.get("ecr"): - sessions[key] = { - "destination": str(destination.absolute()), - "profile": profile, - "auth_method": "ecr-only", - "started_at": session.get("started_at"), - "backup": [], - "ecr": session["ecr"], - } + except ValueError: + remaining = None + public["remaining_seconds"] = remaining + if public.get("auth_method") in {"browser-cache-residue", "logout-residue"}: + state = "logout-residue" + elif public.get("auth_method") == "ecr-only": + state = "ecr-only" + elif drift: + state = drift + elif not session.get("section_backup"): + state = "legacy-unverified" + elif remaining == 0 and expiry: + state = "expired" + elif remaining is not None and remaining <= 900: + state = "expiring" else: - del sessions[key] - _state.save_sessions(sessions) - return True + state = "active" + public["state"] = state + return public def status() -> list[dict[str, Any]]: - """Return secret-free active session status.""" + """Return secret-free, local-only managed-session status.""" now = datetime.now(UTC) - result = [] + return sorted( + (_public_session(item, now=now) for item in _state.load_sessions().values()), + key=lambda item: (str(item.get("destination")), str(item.get("profile"))), + ) + + +def _verify_status(item: dict[str, Any]) -> dict[str, Any]: + if item.get("state") in {"ecr-only", "missing", "drifted"}: + return {"status": "skipped", "reason": f"local state is {item['state']}"} + directory = Path(str(item["destination"])) + profile = str(item.get("profile", "default")) + try: + with _aws_environment( + directory / "config", directory / "credentials", _native_login_cache() + ): + account, partition, arn = _identity( + boto3.Session(profile_name=profile), label=f"profile {profile!r}" + ) + except _configs.OperationalError as error: + return {"status": "error", "message": str(error)} + return { + "status": "verified", + "account": account, + "partition": partition, + "arn": arn, + } + + +def status_report( + *, + profile: str | None = None, + location: str | None = None, + directory: Path | None = None, + verify: bool = False, +) -> dict[str, Any]: + """Return filtered local lifecycle state with optional explicit AWS verification.""" + sessions = status() + if profile: + sessions = [item for item in sessions if item.get("profile") == profile] + if location: + normalized = _state.normalize_location(location) + sessions = [item for item in sessions if item.get("location") == normalized] + if directory: + wanted = str(directory.expanduser().absolute()) + sessions = [item for item in sessions if item.get("destination") == wanted] + if verify: + for item in sessions: + item["verification"] = _verify_status(item) + counts: dict[str, int] = {} + for item in sessions: + state = str(item["state"]) + counts[state] = counts.get(state, 0) + 1 + return {"sessions": sessions, "counts": counts, "warnings": []} + + +def _known_directories() -> dict[Path, str | None]: + directories: dict[Path, str | None] = {(Path.home() / ".aws").absolute(): "default"} + try: + for path in Path.home().glob(".aws-*"): + if path.is_dir(): + directories[path.absolute()] = path.name[5:] or None + except OSError: + pass + data = _state.load_config() + for target in data["targets"].values(): + for prefix in ("source", "destination"): + raw_directory = target.get(f"{prefix}_directory") + raw_location = target.get(f"{prefix}_location") + if raw_directory: + path = Path(str(raw_directory)).expanduser().absolute() + directories.setdefault(path, None) + elif raw_location: + path = _state.aws_directory(str(raw_location)).absolute() + directories.setdefault( + path, _state.normalize_location(str(raw_location)) + ) for session in _state.load_sessions().values(): - public = {key: value for key, value in session.items() if key != "backup"} - expiry = public.get("expires_at") - if expiry: + if session.get("destination"): + path = Path(str(session["destination"])).absolute() + directories.setdefault(path, _location_for_directory(path)) + return directories + + +def profile_inventory( + patterns: list[str] | None = None, *, verify: bool = False +) -> dict[str, Any]: + """Enumerate local profile names without reading or returning credential values.""" + warnings: list[dict[str, str]] = [] + found: dict[tuple[str, str], dict[str, Any]] = {} + managed = { + (str(item["destination"]), str(item.get("profile", "default"))): item + for item in status() + } + for directory, location in _known_directories().items(): + names: set[str] = set() + for filename, is_config in (("credentials", False), ("config", True)): + path = directory / filename + try: + parser = _read_ini(path) + except _configs.OperationalError as error: + warnings.append({"path": str(path), "message": str(error)}) + continue + for section in parser.sections(): + if is_config: + if section == "default": + names.add("default") + elif section.startswith("profile ") and section[8:]: + names.add(section[8:]) + else: + names.add(section) + if directory.exists(): try: - public["remaining_seconds"] = max( - 0, int((datetime.fromisoformat(expiry) - now).total_seconds()) + for path in directory.glob("*.store.credentials"): + names.add(path.name[: -len(".store.credentials")]) + except OSError as error: + warnings.append({"path": str(directory), "message": str(error)}) + for destination, profile_name in managed: + if destination == str(directory): + names.add(profile_name) + for profile_name in names: + key = (str(directory), profile_name) + lifecycle = managed.get(key) + legacy = (directory / f"{profile_name}.store.credentials").is_file() + found[key] = { + "location": location, + "directory": str(directory), + "profile": profile_name, + "managed": lifecycle is not None or legacy, + "state": ( + lifecycle["state"] + if lifecycle + else "legacy-unverified" + if legacy + else "unmanaged" + ), + "auth_method": ( + lifecycle.get("auth_method") + if lifecycle + else "legacy-mfa" + if legacy + else None + ), + } + profiles = sorted( + found.values(), + key=lambda item: ( + str(item.get("location") or "~"), + str(item["directory"]), + str(item["profile"]), + ), + ) + if patterns: + profiles = [ + item + for item in profiles + if any( + fnmatch.fnmatchcase(candidate.casefold(), pattern.casefold()) + for pattern in patterns + for candidate in ( + str(item["profile"]), + f"{item.get('location') or item['directory']}:{item['profile']}", ) - except ValueError: - public["remaining_seconds"] = None - result.append(public) - return result + ) + ] + if verify: + for item in profiles: + item["verification"] = _verify_status( + { + **item, + "destination": item["directory"], + } + ) + return {"profiles": profiles, "count": len(profiles), "warnings": warnings} + + +def _restore_profile_sections( + session: dict[str, Any], destination: Path, profile: str, *, force: bool +) -> None: + plans = _profile_section_plans(session, destination, profile, force=force) + _apply_profile_section_plans(plans) + + +def _profile_section_plans( + session: dict[str, Any], destination: Path, profile: str, *, force: bool +) -> list[tuple[Path, str, dict[str, Any]]]: + sections = session.get("section_backup") + if not isinstance(sections, dict) or not sections: + snapshots = { + Path(str(item.get("path", ""))).absolute(): item + for item in session.get("backup", []) + if isinstance(item, dict) + } + relevant = any( + (destination / filename).absolute() in snapshots + for filename in ("credentials", "config") + ) + if relevant and not force: + raise _configs.OperationalError( + "This legacy session has no installed-section fingerprint; retry " + "with --force to restore only its recorded profile sections." + ) + if not relevant: + return [] + sections = {} + for kind, filename, is_config in ( + ("credentials", "credentials", False), + ("config", "config", True), + ): + path = (destination / filename).absolute() + snapshot = snapshots.get(path) + if snapshot is None: + continue + raw = base64.b64decode(snapshot["data"]) if snapshot.get("exists") else None + parser = _parser_from_bytes(raw, path) + section = _section(profile, config=is_config) + values = _section_values(parser, section) + sections[kind] = { + "path": str(path), + "section": section, + "original": {"exists": values is not None, "values": values or {}}, + } + plans: list[tuple[Path, str, dict[str, Any]]] = [] + for kind, item in sections.items(): + if not isinstance(item, dict): + raise _configs.OperationalError("Managed session section state is invalid.") + expected_path = ( + destination / ("config" if kind == "config" else "credentials") + ).absolute() + path = Path(str(item.get("path", ""))).absolute() + expected_section = _section(profile, config=kind == "config") + if path != expected_path or item.get("section") != expected_section: + raise _configs.OperationalError( + "Managed session section state does not match its destination." + ) + installed = item.get("installed") + if not force and ( + isinstance(installed, dict) + and _section_state(path, expected_section) != installed + ): + raise _configs.OperationalError( + f"Profile {profile!r} changed after login in {path}; retry with " + "--force only after reviewing the local changes." + ) + original = item.get("original") + if not isinstance(original, dict): + raise _configs.OperationalError( + "Managed session original section is invalid." + ) + plans.append((path, expected_section, original)) + return plans + + +def _apply_profile_section_plans( + plans: list[tuple[Path, str, dict[str, Any]]], +) -> None: + for path, section, original in plans: + parser = _read_ini(path) + if original.get("exists"): + values = original.get("values") + if not isinstance(values, dict): + raise _configs.OperationalError( + "Managed session original section values are invalid." + ) + parser[section] = {str(key): str(value) for key, value in values.items()} + else: + parser.remove_section(section) + _write_ini(path, parser) + + +def _tracked_login_cache_plan( + session: dict[str, Any], destination: Path, *, force: bool +) -> tuple[list[Path], list[Path], list[dict[str, str]]]: + if session.get("auth_method") not in { + "browser-native", + "browser-cache-residue", + "logout-residue", + }: + return [], [], [] + configured_roots = session.get("login_cache_directories") or [ + str((destination / "login" / "cache").absolute()) + ] + allowed_roots = [Path(str(value)).absolute() for value in configured_roots] + fingerprints = session.get("login_cache_fingerprints", {}) + removals: list[Path] = [] + residue: list[dict[str, str]] = [] + for value in session.get("login_cache_files", []): + cache_file = Path(str(value)).absolute() + expected = fingerprints.get(str(cache_file)) + in_scope = any( + root == cache_file.parent or root in cache_file.parents + for root in allowed_roots + ) + if not in_scope: + residue.append( + {"path": str(cache_file), "reason": "outside tracked cache roots"} + ) + continue + if not cache_file.exists(): + continue + try: + current = _state.digest(cache_file.read_bytes()) + except OSError as error: + if force: + removals.append(cache_file) + continue + residue.append({"path": str(cache_file), "reason": f"unreadable: {error}"}) + continue + if not isinstance(expected, str) or current != expected: + if force: + removals.append(cache_file) + continue + residue.append( + {"path": str(cache_file), "reason": "fingerprint changed after login"} + ) + continue + removals.append(cache_file) + if residue and not force: + details = "; ".join(f"{item['path']}: {item['reason']}" for item in residue) + raise _configs.OperationalError( + "Tracked browser login cache changed after login; no logout changes were " + f"made. Review the cache or retry with --force: {details}" + ) + return allowed_roots, removals, residue + + +def _remove_tracked_login_cache( + removals: list[Path], residue: list[dict[str, str]], *, force: bool +) -> list[dict[str, str]]: + for cache_file in removals: + try: + cache_file.unlink() + except OSError as error: + if not force: + raise _configs.OperationalError( + f"Unable to remove tracked browser login cache {cache_file}: {error}" + ) from error + residue.append( + {"path": str(cache_file), "reason": f"remove failed: {error}"} + ) + return residue + + +def _matches_except(session: dict[str, Any], excluded: set[str]) -> bool: + if not excluded: + return False + profile = str(session.get("profile", "default")) + destination = Path(str(session.get("destination", Path.home() / ".aws"))) + location = session.get("location") or _location_for_directory(destination) + candidates = {profile, f"{location or destination}:{profile}"} + target_name = session.get("target") + if target_name: + candidates.add(f"+{str(target_name).lstrip('+')}") + try: + for name, target in _state.load_config()["targets"].items(): + source = ( + Path(str(target["source_directory"])).expanduser().absolute() + if target.get("source_directory") + else _state.aws_directory(target.get("source_location")) + ) + target_destination = ( + Path(str(target["destination_directory"])).expanduser().absolute() + if target.get("destination_directory") + else _state.aws_directory(target["destination_location"]) + if target.get("destination_location") + else source + ) + target_profile = _normalize_profile( + target.get("destination_profile", target.get("source_profile")) + ) + if ( + target_destination == destination.absolute() + and target_profile == profile + ): + candidates.add(f"+{name}") + except _configs.OperationalError: + pass + return any( + fnmatch.fnmatchcase(candidate.casefold(), pattern.casefold()) + for candidate in candidates + for pattern in excluded + ) + + +def matches_logout_exclusion( + *, destination: str, profile: str, excluded: set[str], location: str | None = None +) -> bool: + """Match bulk-logout selectors without exposing credential contents.""" + return _matches_except( + {"destination": destination, "profile": profile, "location": location}, excluded + ) + + +def _logout_key(key: str, args: Any) -> dict[str, Any]: + sessions = _state.load_sessions() + session = sessions.get(key) + if not session: + return {"key": key, "state": "not-managed", "changed": False} + destination = Path(str(session["destination"])).absolute() + profile = str(session.get("profile", "default")) + if _matches_except(session, set(getattr(args, "except_profiles", []) or [])): + return {"key": key, "state": "excluded", "changed": False} + keep_ecr = bool(getattr(args, "keep_ecr", False)) + force = bool(getattr(args, "force", False)) + registries = list(session.get("ecr", [])) + plans = _profile_section_plans(session, destination, profile, force=force) + cache_roots, cache_removals, cache_residue = _tracked_login_cache_plan( + session, destination, force=force + ) + journal = _begin( + [destination / "credentials", destination / "config", _state.sessions_path()], + cache_roots=cache_roots, + ) + residual: dict[str, Any] = { + "destination": str(destination), + "profile": profile, + "auth_method": "ecr-only", + "started_at": session.get("started_at"), + "backup": [], + "section_backup": {}, + "ecr": registries, + "ecr_engine": session.get("ecr_engine"), + } + try: + _apply_profile_section_plans(plans) + cache_residue = _remove_tracked_login_cache( + cache_removals, cache_residue, force=force + ) + if cache_residue: + residual.update( + auth_method="logout-residue" if registries else "browser-cache-residue", + login_cache_residue=cache_residue, + login_cache_directories=[str(path) for path in cache_roots], + login_cache_files=[item["path"] for item in cache_residue], + login_cache_fingerprints={}, + ) + if registries or cache_residue: + sessions[key] = residual + else: + del sessions[key] + _state.save_sessions(sessions) + _commit() + except Exception: + _rollback(journal) + raise + if registries and not keep_ecr: + engine = cast( + "_configs.ContainerEngine", + str( + session.get("ecr_engine") + or ("podman" if getattr(args, "podman", False) else "docker") + ), + ) + remaining = list(registries) + for registry in registries: + try: + _ecr._run_container_engine(engine, [engine, "logout", registry]) + except _configs.OperationalError: + sessions = _state.load_sessions() + sessions[key] = {**residual, "ecr": remaining} + _state.save_sessions(sessions) + raise + remaining.remove(registry) + sessions = _state.load_sessions() + if remaining: + sessions[key] = {**residual, "ecr": remaining} + elif cache_residue: + sessions[key] = {**residual, "ecr": []} + else: + sessions.pop(key, None) + _state.save_sessions(sessions) + return { + "key": key, + "destination": str(destination), + "profile": profile, + "state": ( + "logout-residue" + if cache_residue + else "ecr-only" + if registries and keep_ecr + else "logged-out" + ), + "residue": cache_residue, + "changed": True, + } + + +def logout(context: _configs.Context) -> bool: + """Restore one managed session locally using compare-and-swap sections.""" + _source, _source_profile, destination, profile = _paths(context.args) + key = f"{destination.absolute()}::{profile}" + return bool(_logout_key(key, context.args)["changed"]) + + +def logout_all(args: Any) -> dict[str, Any]: + """Log out every managed session except explicit profile selectors.""" + outcomes = [] + errors = [] + for key in sorted(_state.load_sessions()): + try: + outcomes.append(_logout_key(key, args)) + except _configs.OperationalError as error: + errors.append({"key": key, "message": str(error)}) + return {"outcomes": outcomes, "errors": errors} def explain_target(value: str) -> dict[str, Any]: @@ -1144,32 +1880,74 @@ def _check_config(args: Any) -> dict[str, Any]: data = _state.load_config() except _configs.OperationalError as error: return {"ok": False, "errors": [str(error)], "warnings": []} - for name in data["policies"]: + remote = bool(getattr(args, "remote", False) or getattr(args, "probe", False)) + scoped_accounts: set[str] = set() + if getattr(args, "account", None): + account_name, _ = _state.get_resource(data, "account", args.account) + scoped_accounts.add(account_name.casefold()) + check_session: Any | None = None + profile = "default" + account: str | None = None + partition: str | None = None + if remote: try: - _policies.parse_policy(_policies.stored_directory() / f"{name}.yaml") - except _configs.OperationalError as error: - errors.append(str(error)) - if args.remote or args.probe: - profile = args.profile or "default" - if args.target: - target_name, target = _state.get_resource( - data, "target", args.target.lstrip("+") - ) - profile = target.get("source_profile", "default") - source_directory = ( - Path(target["source_directory"]) - if target.get("source_directory") - else _state.aws_directory(target.get("source_location")) - ) + selector = _configs.resolve_credential_selector(args) + profile = selector.profile + if selector.target: + target_name, target = _state.get_resource( + data, "target", selector.target.lstrip("+") + ) + profile = target.get("source_profile", "default") + source_directory = ( + Path(target["source_directory"]) + if target.get("source_directory") + else _state.aws_directory(target.get("source_location")) + ) + warnings.append(f"Using target {target_name} credential source.") + else: + source_directory = selector.directory or _state.aws_directory( + selector.location + ) os.environ["AWS_CONFIG_FILE"] = str(source_directory / "config") os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str( source_directory / "credentials" ) - warnings.append(f"Using target {target_name} credential source.") - try: check_session = boto3.Session(profile_name=profile) account, partition, _ = _identity(check_session, label="config check") - if args.account: + if not scoped_accounts: + scoped_accounts = { + name.casefold() + for name, configured in data["accounts"].items() + if configured["id"] == account + and configured["partition"] == partition + } + except _configs.OperationalError as error: + errors.append(f"Remote resources unverifiable: {error}") + scoped_policies: set[str] | None = None + if getattr(args, "account", None) or remote: + scoped_policies = { + str(boundary["policy"]) + for boundary in data["boundaries"].values() + if str(boundary["account"]).casefold() in scoped_accounts + and boundary.get("policy") + } + for name in data["policies"]: + if scoped_policies is not None and not any( + name.casefold() == policy.casefold() for policy in scoped_policies + ): + continue + try: + _policies.parse_policy(_policies.stored_directory() / f"{name}.yaml") + except _configs.OperationalError as error: + errors.append(str(error)) + if ( + remote + and check_session is not None + and account is not None + and partition is not None + ): + try: + if getattr(args, "account", None): _, configured = _state.get_resource(data, "account", args.account) if configured["id"] != account or configured["partition"] != partition: errors.append( @@ -1193,7 +1971,18 @@ def _check_config(args: Any) -> dict[str, Any]: errors.append(f"Boundary {name}: {label} ({error}).") except BotoCoreError as error: errors.append(f"Boundary {name}: unverifiable ({error}).") - if args.probe: + if boundary.get("policy"): + try: + _policies.resolve( + str(boundary["policy"]), + account_id=account, + partition=partition, + profile=profile, + session=check_session, + ) + except _configs.OperationalError as error: + errors.append(f"Boundary {name} policy: {error}") + if getattr(args, "probe", False): deny_all = json.dumps( { "Version": "2012-10-17", @@ -1247,11 +2036,48 @@ def fix_config(args: Any) -> _configs.Result: _state.atomic_write(backup, path.read_bytes()) issues: list[tuple[str, str, str]] = [] scoped_policies: set[str] | None = None + scoped_account_names: set[str] = set() if args.account: + scoped_account_names.add(account_name.casefold()) + elif getattr(args, "remote", False) or getattr(args, "probe", False): + try: + selector = _configs.resolve_credential_selector(args) + profile = selector.profile + if selector.target: + _, target = _state.get_resource( + data, "target", selector.target.lstrip("+") + ) + profile = str(target.get("source_profile", "default")) + source_directory = ( + Path(str(target["source_directory"])).expanduser().absolute() + if target.get("source_directory") + else _state.aws_directory(target.get("source_location")) + ) + else: + source_directory = selector.directory or _state.aws_directory( + selector.location + ) + with _aws_environment( + source_directory / "config", + source_directory / "credentials", + _native_login_cache(), + ): + caller_account, caller_partition, _ = _identity( + boto3.Session(profile_name=profile), label="config fix" + ) + scoped_account_names = { + name.casefold() + for name, configured in data["accounts"].items() + if configured["id"] == caller_account + and configured["partition"] == caller_partition + } + except _configs.OperationalError: + scoped_account_names = set() + if args.account or getattr(args, "remote", False) or getattr(args, "probe", False): scoped_policies = { str(boundary["policy"]) for boundary in data["boundaries"].values() - if str(boundary["account"]).casefold() == account_name.casefold() + if str(boundary["account"]).casefold() in scoped_account_names and boundary.get("policy") } for name in data["policies"]: @@ -1299,6 +2125,114 @@ def fix_config(args: Any) -> _configs.Result: (_policies.stored_directory() / f"{name}.yaml").unlink(missing_ok=True) else: unresolved.append(message) + if getattr(args, "remote", False) or getattr(args, "probe", False): + report = _check_config(args) + local_messages = {message for _kind, _name, message in issues} + for message in report["errors"]: + if message in local_messages: + continue + boundary_match = re.match(r"Boundary ([^:]+):", message) + boundary_policy_match = re.match(r"Boundary ([^:]+) policy:", message) + kind = ( + "boundary-policy" + if boundary_policy_match + else "boundary" + if boundary_match + else "account" + if message.startswith("Selected account does not match caller ") + else "remote" + ) + name = ( + boundary_policy_match.group(1) + if boundary_policy_match + else boundary_match.group(1) + if boundary_match + else str(getattr(args, "account", "verification")) + ) + if args.yes or not sys.stdin.isatty(): + unresolved.append(message) + continue + answer = ( + input( + f"Issue {kind}:{name}: {message}\n" + "Choose update, leave, or remove [u/l/x]: " + ) + .strip() + .casefold() + ) + if answer in {"u", "update", "r", "repair"} and kind == "boundary": + role_arn = input("Replacement role ARN: ").strip() + if not re.fullmatch(r"arn:[^:]+:iam::\d{12}:role/.+", role_arn): + unresolved.append( + f"Update for boundary {name!r} failed: replacement must be " + "a complete IAM role ARN." + ) + continue + canonical, boundary = _state.get_resource(data, "boundary", name) + boundary["role_arn"] = role_arn + data["boundaries"][canonical] = boundary + elif answer in {"u", "update", "r", "repair"} and kind == "boundary-policy": + policy = input("Replacement policy name, ARN, or file: ").strip() + canonical, boundary = _state.get_resource(data, "boundary", name) + if policy: + boundary["policy"] = policy + else: + boundary.pop("policy", None) + data["boundaries"][canonical] = boundary + elif answer in {"u", "update", "r", "repair"} and kind == "account": + caller = re.search(r"caller ([^:]+):(\d{12})", message) + if not caller: + unresolved.append(message) + continue + canonical, account = _state.get_resource(data, "account", name) + account.update(partition=caller.group(1), id=caller.group(2)) + data["accounts"][canonical] = account + for boundary in data["boundaries"].values(): + if ( + str(boundary.get("account", "")).casefold() + != canonical.casefold() + ): + continue + role_path = str(boundary["role_arn"]).split(":role/", 1)[-1] + boundary["role_arn"] = ( + f"arn:{caller.group(1)}:iam::{caller.group(2)}:role/{role_path}" + ) + elif answer in {"x", "remove"} and kind in { + "boundary", + "boundary-policy", + "account", + }: + resource_kind = "boundary" if kind == "boundary-policy" else kind + canonical, _ = _state.get_resource(data, resource_kind, name) + removed_boundaries: set[str] = set() + if resource_kind == "boundary": + removed_boundaries.add(canonical.casefold()) + else: + removed_boundaries = { + key.casefold() + for key, value in data["boundaries"].items() + if str(value.get("account", "")).casefold() + == canonical.casefold() + } + data["boundaries"] = { + key: value + for key, value in data["boundaries"].items() + if key.casefold() not in removed_boundaries + } + data["targets"] = { + key: value + for key, value in data["targets"].items() + if str(value.get("boundary", "")).casefold() + not in removed_boundaries + and not ( + resource_kind == "account" + and str(value.get("source_account", "")).casefold() + == canonical.casefold() + ) + } + del data[_state.collection_name(resource_kind)][canonical] + else: + unresolved.append(message) _state.save_config(data) if unresolved: return _configs.Result( diff --git a/hacksaws/_state.py b/hacksaws/_state.py index 630e6c9..4e30add 100644 --- a/hacksaws/_state.py +++ b/hacksaws/_state.py @@ -22,7 +22,22 @@ r"((?:[A-Za-z0-9_+=,.@-]+/)*[A-Za-z0-9_+=,.@-]{1,64})$" ) PARTITIONS = {"aws", "aws-us-gov", "aws-cn"} -TOP_LEVEL = {"schema_version", "accounts", "boundaries", "targets", "policies", "cache"} +TOP_LEVEL = { + "schema_version", + "accounts", + "boundaries", + "targets", + "policies", + "cache", + "naming", + "iam", + "session", + "output", +} +NAMING_FIELDS = {"case", "prefix", "suffix", "enforcement"} +NAMING_CASES = {"Pascal", "camel", "snake", "kebab"} +ENFORCEMENT_LEVELS = {"off", "warn", "error"} +COLOR_MODES = {"auto", "always", "never"} def collection_name(kind: str) -> str: @@ -49,6 +64,20 @@ def default_config() -> dict[str, Any]: "targets": {}, "policies": {}, "cache": {"max_age": 3600}, + "naming": { + "global": { + "case": "Pascal", + "prefix": "", + "suffix": "", + "enforcement": "off", + }, + "resources": {}, + "accounts": {}, + "account_resources": {}, + }, + "iam": {"path": "/hacksaws/"}, + "session": {"packed_policy_warning": 80, "packed_policy_enforcement": "off"}, + "output": {"color": "auto"}, } @@ -116,6 +145,9 @@ def atomic_write(path: Path, data: bytes) -> None: def _validate_config(data: object) -> dict[str, Any]: if type(data) is not dict: raise OperationalError("Hacksaws config must be a JSON object.") + defaults = default_config() + for key in ("naming", "iam", "session", "output"): + data.setdefault(key, deepcopy(defaults[key])) unknown = set(data) - TOP_LEVEL if unknown: raise OperationalError( @@ -134,10 +166,93 @@ def _validate_config(data: object) -> dict[str, Any]: raise OperationalError("Config cache accepts only the max_age setting.") if type(cache.get("max_age")) is not int or cache["max_age"] < 0: raise OperationalError("Config cache.max_age must be non-negative seconds.") + _validate_foundation_settings(data) _validate_resources(data) return data +def _validate_naming_override(value: object, *, label: str, require_all: bool) -> None: + """Validate one naming layer while allowing sparse resource overrides.""" + if type(value) is not dict or set(value) - NAMING_FIELDS: + raise OperationalError(f"Config naming {label} contains unsupported settings.") + if require_all and set(value) != NAMING_FIELDS: + raise OperationalError( + f"Config naming {label} must define every naming setting." + ) + if "case" in value and value["case"] not in NAMING_CASES: + raise OperationalError(f"Config naming {label}.case is unsupported.") + if "enforcement" in value and value["enforcement"] not in ENFORCEMENT_LEVELS: + raise OperationalError(f"Config naming {label}.enforcement is unsupported.") + for field in ("prefix", "suffix"): + if field in value and type(value[field]) is not str: + raise OperationalError(f"Config naming {label}.{field} must be text.") + + +def _validate_foundation_settings(data: dict[str, Any]) -> None: + """Validate schema-one UX and IAM defaults without changing schema version.""" + naming = data["naming"] + if type(naming) is not dict or set(naming) != { + "global", + "resources", + "accounts", + "account_resources", + }: + raise OperationalError("Config naming has an unsupported shape.") + _validate_naming_override(naming["global"], label="global", require_all=True) + for layer in ("resources", "accounts"): + if type(naming[layer]) is not dict: + raise OperationalError(f"Config naming.{layer} must be an object.") + for name, override in naming[layer].items(): + validate_name(name, kind=f"naming {layer[:-1]}") + _validate_naming_override( + override, label=f"{layer}.{name}", require_all=False + ) + if type(naming["account_resources"]) is not dict: + raise OperationalError("Config naming.account_resources must be an object.") + for account, resources in naming["account_resources"].items(): + validate_name(account, kind="naming account") + if type(resources) is not dict: + raise OperationalError( + "Config naming.account_resources entries must be objects." + ) + for resource, override in resources.items(): + validate_name(resource, kind="naming resource") + _validate_naming_override( + override, + label=f"account_resources.{account}.{resource}", + require_all=False, + ) + iam = data["iam"] + if type(iam) is not dict or set(iam) != {"path"} or type(iam["path"]) is not str: + raise OperationalError("Config iam accepts only a text path setting.") + if not iam["path"].startswith("/") or not iam["path"].endswith("/"): + raise OperationalError("Config iam.path must start and end with '/'.") + session = data["session"] + if type(session) is not dict or set(session) != { + "packed_policy_warning", + "packed_policy_enforcement", + }: + raise OperationalError("Config session has an unsupported shape.") + if ( + type(session["packed_policy_warning"]) is not int + or not 0 <= session["packed_policy_warning"] <= 100 + ): + raise OperationalError( + "Config session.packed_policy_warning must be 0 through 100." + ) + if session["packed_policy_enforcement"] not in ENFORCEMENT_LEVELS: + raise OperationalError( + "Config session.packed_policy_enforcement is unsupported." + ) + output = data["output"] + if ( + type(output) is not dict + or set(output) != {"color"} + or output["color"] not in COLOR_MODES + ): + raise OperationalError("Config output.color must be auto, always, or never.") + + def _validate_resources(data: dict[str, Any]) -> None: for collection in ("accounts", "boundaries", "targets", "policies"): seen: set[str] = set() @@ -154,7 +269,13 @@ def _validate_resources(data: dict[str, Any]) -> None: f"{collection[:-1].title()} {name!r} must be an object." ) for name, account in data["accounts"].items(): - unknown = set(account) - {"id", "partition", "description", "unverified"} + unknown = set(account) - { + "id", + "partition", + "description", + "unverified", + "credential_target", + } if unknown: raise OperationalError( f"Unknown account field(s) for {name}: {', '.join(unknown)}." @@ -170,6 +291,11 @@ def _validate_resources(data: dict[str, Any]) -> None: raise OperationalError(f"Account {name!r} has an unsupported partition.") if "description" in account and type(account["description"]) is not str: raise OperationalError(f"Account {name!r} description must be text.") + if "credential_target" in account and ( + type(account["credential_target"]) is not str + or not account["credential_target"] + ): + raise OperationalError(f"Account {name!r} credential_target must be text.") if "unverified" in account and ( type(account["unverified"]) is not bool or account["unverified"] is not True ): @@ -313,6 +439,209 @@ def _validate_resources(data: dict[str, Any]) -> None: ) +CONFIG_OPTION_PATTERNS: dict[str, dict[str, object]] = { + "naming.global.{case|prefix|suffix|enforcement}": { + "description": "Default naming policy; later layers override earlier layers.", + "default": {"case": "Pascal", "prefix": "", "suffix": "", "enforcement": "off"}, + }, + "naming.resources..{case|prefix|suffix|enforcement}": { + "description": "Naming override for one resource kind.", + }, + "naming.accounts..{case|prefix|suffix|enforcement}": { + "description": "Naming override for one AWS account.", + }, + "naming.account_resources...{case|prefix|suffix|enforcement}": { + "description": "Most-specific account and resource naming override.", + }, + "iam.path": { + "description": "IAM resource path for managed artifacts.", + "default": "/hacksaws/", + }, + "session.packed_policy_warning": { + "description": "Packed-policy warning threshold, in percent.", + "default": 80, + }, + "session.packed_policy_enforcement": { + "description": "Packed-policy action: off, warn, or error.", + "default": "off", + }, + "output.color": { + "description": "Color mode: auto, always, or never.", + "default": "auto", + }, + "accounts..credential_target": { + "description": "Per-account credential target used only when explicitly selected.", + }, +} + + +def config_option_patterns() -> dict[str, dict[str, object]]: + """Return self-documenting, stable configuration option descriptions.""" + return deepcopy(CONFIG_OPTION_PATTERNS) + + +def resolve_naming( + data: dict[str, Any], + *, + resource: str, + account: str | None = None, + explicit: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Resolve naming in built-in/global/resource/account/account-resource/explicit order.""" + naming = data["naming"] + resolved = {"case": "Pascal", "prefix": "", "suffix": "", "enforcement": "off"} + resolved.update(naming["global"]) + resolved.update(naming["resources"].get(resource, {})) + if account: + resolved.update(naming["accounts"].get(account, {})) + resolved.update(naming["account_resources"].get(account, {}).get(resource, {})) + if explicit: + resolved.update(explicit) + return resolved + + +def _option_parts(key: str) -> list[str]: + if not key or any(not part for part in key.split(".")): + raise OperationalError("Config option key must use dotted names.") + return key.split(".") + + +def get_config_option(data: dict[str, Any], key: str) -> object: + """Read a declared configuration option by its dotted path.""" + current: object = data + for part in _option_parts(key): + if type(current) is not dict or part not in current: + raise OperationalError( + f"Unknown config option {key!r}; run 'config options'." + ) + current = current[part] + return deepcopy(current) + + +def set_config_option(data: dict[str, Any], key: str, value: object) -> None: + """Set a known leaf option and validate the complete schema-one document.""" + parts = _option_parts(key) + if parts[:2] == ["naming", "resources"] and len(parts) == 4: + resource, field = parts[2:] + validate_name(resource, kind="naming resource") + if field not in NAMING_FIELDS: + raise OperationalError( + f"Unknown config option {key!r}; run 'config options'." + ) + data["naming"]["resources"].setdefault(resource, {})[field] = value + _validate_config(data) + return + if parts[:2] == ["naming", "accounts"] and len(parts) == 4: + account, field = parts[2:] + validate_name(account, kind="naming account") + if field not in NAMING_FIELDS: + raise OperationalError( + f"Unknown config option {key!r}; run 'config options'." + ) + data["naming"]["accounts"].setdefault(account, {})[field] = value + _validate_config(data) + return + if parts[:2] == ["naming", "account_resources"] and len(parts) == 5: + account, resource, field = parts[2:] + validate_name(account, kind="naming account") + validate_name(resource, kind="naming resource") + if field not in NAMING_FIELDS: + raise OperationalError( + f"Unknown config option {key!r}; run 'config options'." + ) + data["naming"]["account_resources"].setdefault(account, {}).setdefault( + resource, {} + )[field] = value + _validate_config(data) + return + if ( + parts[:1] == ["accounts"] + and len(parts) == 3 + and parts[2] == "credential_target" + ): + account, value_map = get_resource(data, "account", parts[1]) + data["accounts"][account] = {**value_map, "credential_target": value} + _validate_config(data) + return + current: dict[str, Any] = data + for part in parts[:-1]: + child = current.get(part) + if type(child) is not dict: + raise OperationalError( + f"Unknown config option {key!r}; run 'config options'." + ) + current = child + if parts[-1] not in current or type(current[parts[-1]]) is dict: + raise OperationalError(f"Config option {key!r} is not a settable leaf.") + current[parts[-1]] = value + _validate_config(data) + + +def reset_config_option(data: dict[str, Any], key: str) -> None: + """Reset a known option to its schema-one default where one exists.""" + defaults = default_config() + parts = _option_parts(key) + if parts[:2] == ["naming", "resources"] and len(parts) == 4: + resource, field = parts[2:] + override = data["naming"]["resources"].get(resource) + if ( + field not in NAMING_FIELDS + or type(override) is not dict + or field not in override + ): + raise OperationalError(f"Config option {key!r} has no reset default.") + del override[field] + if not override: + del data["naming"]["resources"][resource] + _validate_config(data) + return + if parts[:2] == ["naming", "accounts"] and len(parts) == 4: + account, field = parts[2:] + override = data["naming"]["accounts"].get(account) + if ( + field not in NAMING_FIELDS + or type(override) is not dict + or field not in override + ): + raise OperationalError(f"Config option {key!r} has no reset default.") + del override[field] + if not override: + del data["naming"]["accounts"][account] + _validate_config(data) + return + if parts[:2] == ["naming", "account_resources"] and len(parts) == 5: + account, resource, field = parts[2:] + resources = data["naming"]["account_resources"].get(account) + override = resources.get(resource) if type(resources) is dict else None + if ( + field not in NAMING_FIELDS + or type(override) is not dict + or field not in override + ): + raise OperationalError(f"Config option {key!r} has no reset default.") + del override[field] + if not override: + del resources[resource] + if not resources: + del data["naming"]["account_resources"][account] + _validate_config(data) + return + current: dict[str, Any] = data + default_current: dict[str, Any] = defaults + for part in parts[:-1]: + if ( + type(current.get(part)) is not dict + or type(default_current.get(part)) is not dict + ): + raise OperationalError(f"Config option {key!r} has no reset default.") + current = current[part] + default_current = default_current[part] + if parts[-1] not in default_current: + raise OperationalError(f"Config option {key!r} has no reset default.") + current[parts[-1]] = deepcopy(default_current[parts[-1]]) + _validate_config(data) + + def load_config(*, create: bool = False) -> dict[str, Any]: """Load and strictly validate config.json.""" path = root() / "config.json" diff --git a/hacksaws/_test_runner.py b/hacksaws/_test_runner.py index 9b434ec..969c604 100644 --- a/hacksaws/_test_runner.py +++ b/hacksaws/_test_runner.py @@ -4,6 +4,11 @@ import subprocess import sys +from importlib.util import find_spec +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence PYTEST_ARGUMENTS = [ "--cov=hacksaws", @@ -13,10 +18,17 @@ ] -def main() -> int: - """Run the full test suite and preserve pytest's process result.""" +def main(arguments: Sequence[str] | None = None) -> int: + """Run the full test suite, forwarding arguments and preserving its result.""" + if find_spec("pytest") is None: + sys.stderr.write( + "The 'test' command requires development dependencies. " + "Run `uv sync --group dev`, then `uv run test`.\n" + ) + return 2 + forwarded = sys.argv[1:] if arguments is None else arguments completed = subprocess.run( - [sys.executable, "-m", "pytest", *PYTEST_ARGUMENTS], check=False + [sys.executable, "-m", "pytest", *PYTEST_ARGUMENTS, *forwarded], check=False ) return completed.returncode diff --git a/hacksaws/tests/scripts/__init__.py b/hacksaws/tests/scripts/__init__.py new file mode 100644 index 0000000..8c20871 --- /dev/null +++ b/hacksaws/tests/scripts/__init__.py @@ -0,0 +1 @@ +"""Explicitly opt-in test-support scripts; never ordinary pytest collection.""" diff --git a/hacksaws/tests/scripts/live_iam_smoke.py b/hacksaws/tests/scripts/live_iam_smoke.py new file mode 100644 index 0000000..8895114 --- /dev/null +++ b/hacksaws/tests/scripts/live_iam_smoke.py @@ -0,0 +1,243 @@ +"""Explicitly opt-in lifecycle smoke test for a disposable AWS IAM account. + +This module is not collected by pytest and must never be added to ordinary CI. +It refuses to call AWS unless the destructive opt-in, exact account guard, and +saved credential target are all supplied. +""" + +# ruff: noqa: TRY003 + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tempfile +import uuid +from collections.abc import Callable +from pathlib import Path + +from botocore.exceptions import ClientError + +from hacksaws import _cli +from hacksaws import _iam_cli + +IAM_PATH = "/hacksaws-test/" +OPT_IN = "HACKSAWS_LIVE_AWS" +ACCOUNT = "HACKSAWS_LIVE_AWS_ACCOUNT_ID" +CLEANUP = "HACKSAWS_LIVE_AWS_CLEANUP" +TARGET = "HACKSAWS_LIVE_AWS_TARGET" + + +def _run(arguments: list[str]) -> None: + result = _cli.console_main(arguments) + if result.exit_code: + raise RuntimeError( + f"Live IAM smoke command failed ({result.code}): " + f"hacksaws {' '.join(arguments)}" + ) + + +def _selectors(expected: str, target: str) -> list[str]: + return ["--target", target, "--account", expected] + + +def _absent(call: Callable[..., object], **kwargs: str) -> bool: + try: + call(**kwargs) + except ClientError as error: + return error.response.get("Error", {}).get("Code") == "NoSuchEntity" + return False + + +def main() -> int: + """Create, exercise, clean, and verify one uniquely tagged IAM fixture set.""" + expected = os.environ.get(ACCOUNT, "") + target = os.environ.get(TARGET, "") + if ( + os.environ.get(OPT_IN) != "1" + or os.environ.get(CLEANUP) != "1" + or not expected + or not target + ): + raise SystemExit( + "Refusing live AWS smoke test; set " + f"{OPT_IN}=1, {CLEANUP}=1, {ACCOUNT}=12-digit-account-id, and " + f"{TARGET}=saved-target." + ) + if not expected.isdigit() or len(expected) != 12: + raise SystemExit(f"{ACCOUNT} must be a 12-digit AWS account ID.") + + selector_args = _selectors(expected, target) + context = _iam_cli.IamCommandContext.create( + argparse.Namespace( + profile="default", + location="default", + directory=None, + target=target, + account=expected, + region=None, + ) + ) + if context.account_id != expected: + raise SystemExit( + f"Refusing account {context.account_id}; expected guarded account " + f"{expected}." + ) + + run_id = uuid.uuid4().hex + role_name = f"HacksawsSmokeRole{run_id[:12]}" + policy_name = f"HacksawsSmokePolicy{run_id[:12]}" + policy_arn = ( + f"arn:{context.partition}:iam::{expected}:policy{IAM_PATH}{policy_name}" + ) + smoke_tags = [ + {"Key": "hacksaws:smoke", "Value": "true"}, + {"Key": "hacksaws:run-id", "Value": run_id}, + ] + cleaned = False + + with tempfile.TemporaryDirectory(prefix="hacksaws-live-smoke-") as temporary: + directory = Path(temporary) + policy_file = directory / "managed-policy.json" + updated_policy_file = directory / "managed-policy-updated.json" + inline_file = directory / "inline-policy.json" + base_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:GetCallerIdentity", + "Resource": "*", + } + ], + } + updated_document = { + **base_document, + "Statement": [ + *base_document["Statement"], + { + "Effect": "Allow", + "Action": "iam:GetRole", + "Resource": ( + f"arn:{context.partition}:iam::{expected}:" + f"role{IAM_PATH}{role_name}" + ), + }, + ], + } + policy_file.write_text(json.dumps(base_document), encoding="utf-8") + updated_policy_file.write_text(json.dumps(updated_document), encoding="utf-8") + inline_file.write_text(json.dumps(base_document), encoding="utf-8") + + try: + _run( + [ + "iam", + "role", + "create", + role_name, + "--path", + IAM_PATH, + "--trust-caller", + *selector_args, + "--yes", + ] + ) + context.iam.tag_role(RoleName=role_name, Tags=smoke_tags) + _run( + [ + "iam", + "policy", + "create", + str(policy_file), + policy_name, + "--path", + IAM_PATH, + "--local-validation-only", + *selector_args, + "--yes", + ] + ) + context.iam.tag_policy(PolicyArn=policy_arn, Tags=smoke_tags) + _run( + [ + "iam", + "role", + "attach", + role_name, + policy_arn, + *selector_args, + "--yes", + ] + ) + _run( + [ + "iam", + "role", + "inline-policy", + "put", + role_name, + "SmokeInline", + str(inline_file), + *selector_args, + "--yes", + ] + ) + _run( + [ + "iam", + "policy", + "update", + policy_name, + str(updated_policy_file), + "--local-validation-only", + *selector_args, + "--yes", + ] + ) + cleanup_args = [ + "cleanup", + "--smoke-run", + run_id, + "--cascade", + *selector_args, + ] + _run([*cleanup_args, "--dry-run"]) + _run([*cleanup_args, "--yes"]) + cleaned = True + finally: + if not cleaned: + try: + _run( + [ + "cleanup", + role_name, + policy_name, + "--cascade", + *selector_args, + "--yes", + ] + ) + except Exception as error: # noqa: BLE001 + sys.stderr.write( + "Emergency cleanup failed; run the printed recovery command: " + f"{error}\n" + ) + + if not _absent(context.iam.get_role, RoleName=role_name) or not _absent( + context.iam.get_policy, PolicyArn=policy_arn + ): + raise RuntimeError( + "Live IAM smoke cleanup did not leave both resources absent." + ) + sys.stdout.write( + f"Verified lifecycle and absence for account {expected}, target {target}, " + f"and smoke run {run_id}.\n" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hacksaws/tests/test_cli_state_coverage.py b/hacksaws/tests/test_cli_state_coverage.py index 93da7fa..5f92bbb 100644 --- a/hacksaws/tests/test_cli_state_coverage.py +++ b/hacksaws/tests/test_cli_state_coverage.py @@ -179,6 +179,95 @@ def test_resource_cli_add_get_list_update_and_clear(state_home: Path) -> None: } +def test_resource_updates_cover_explicit_source_and_destination_switches( + state_home: Path, +) -> None: + _seed_connected(state_home) + data = _state.load_config() + data["accounts"]["Other"] = {"id": "999999999999", "partition": "aws"} + data["boundaries"]["OtherGuard"] = { + "role_arn": "arn:aws:iam::999999999999:role/Other", + "account": "Other", + "verified": False, + } + _state.save_config(data) + + assert ( + _run( + [ + "boundary", + "update", + "Guard", + "--account", + "Other", + "--role", + "Other", + "--no-verify", + ] + ).exit_code + == 0 + ) + new_source = state_home / "new-source" + assert ( + _run( + [ + "target", + "update", + "Deploy", + "--source-account", + "Other", + "--source-profile", + "operator", + "--source-directory", + str(new_source), + "--boundary", + "OtherGuard", + "--to", + "west:agent", + ] + ).exit_code + == 0 + ) + target = _state.load_config()["targets"]["Deploy"] + assert target["source_account"] == "Other" + assert target["source_profile"] == "operator" + assert target["source_directory"] == str(new_source.absolute()) + assert target["destination_location"] == "west" + assert target["destination_profile"] == "agent" + + assert ( + _run( + [ + "target", + "update", + "Deploy", + "--to", + "west:agent", + "--to-directory", + str(state_home / "destination"), + "--to-profile", + "agent", + ] + ).code + == "OPERATIONAL_ERROR" + ) + assert _run(["target", "update", "Deploy", "--to", "invalid"]).code == ( + "OPERATIONAL_ERROR" + ) + assert ( + _run( + [ + "target", + "update", + "Deploy", + "--to-directory", + str(state_home / "destination"), + ] + ).code + == "OPERATIONAL_ERROR" + ) + + def test_verified_account_and_boundary_adds_use_authoritative_identity( state_home: Path, ) -> None: @@ -370,7 +459,6 @@ def test_policy_cache_config_status_and_logout_dispatch(state_home: Path) -> Non (cache_root / "one.json").write_text("{}", encoding="utf-8") assert json.loads(_run(["cache", "get", "--json"]).message) == { "max_age": 0, - "entries": 1, } assert _run(["cache", "clear", "--yes"]).code == "CACHE_CLEAR" @@ -382,10 +470,17 @@ def test_policy_cache_config_status_and_logout_dispatch(state_home: Path) -> Non == "Prod" ) with ( - patch("hacksaws._sessions.status", return_value={"sessions": []}), + patch( + "hacksaws._sessions.status_report", + return_value={"sessions": [], "counts": {}, "warnings": []}, + ), patch("hacksaws._cli._run_logout", return_value=_configs.Result("OUT", "ok")), ): - assert json.loads(_run(["status", "--json"]).message) == {"sessions": []} + assert _run(["status", "--json"]).data == { + "sessions": [], + "counts": {}, + "warnings": [], + } assert _run(["logout"]).code == "OUT" @@ -488,3 +583,114 @@ def test_state_rename_rejects_collisions_without_rewriting(state_home: Path) -> with pytest.raises(_configs.OperationalError, match="already exists"): _state.rename_resource(data, "account", "Prod", "Other") assert data == before + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + (lambda data: data.update(accounts=[]), "accounts.*object"), + (lambda data: data.update(cache={}), "cache accepts"), + (lambda data: data.update(cache={"max_age": -1}), "non-negative"), + (lambda data: data["naming"].update(global_={}), "unsupported shape"), + ( + lambda data: data["naming"].update({"global": {"case": "Pascal"}}), + "must define every", + ), + ( + lambda data: data["naming"]["global"].update(case="title"), + "case is unsupported", + ), + ( + lambda data: data["naming"]["global"].update(enforcement="force"), + "enforcement is unsupported", + ), + ( + lambda data: data["naming"]["global"].update(prefix=3), + "prefix must be text", + ), + (lambda data: data["naming"].update(resources=[]), "resources must be"), + ( + lambda data: data["naming"].update(account_resources=[]), + "account_resources must be", + ), + ( + lambda data: data["naming"]["account_resources"].update(Prod=[]), + "entries must be objects", + ), + (lambda data: data.update(iam={"path": 3}), "iam accepts"), + (lambda data: data.update(iam={"path": "hacksaws"}), "start and end"), + (lambda data: data.update(session={}), "session has an unsupported"), + ( + lambda data: data["session"].update(packed_policy_warning=101), + "must be 0 through 100", + ), + ( + lambda data: data["session"].update(packed_policy_enforcement="force"), + "enforcement is unsupported", + ), + (lambda data: data.update(output={"color": "sometimes"}), "output.color"), + ( + lambda data: data["accounts"].update( + Prod={ + "id": ACCOUNT_ID, + "partition": "aws", + "credential_target": "", + } + ), + "credential_target must be text", + ), + ( + lambda data: data["policies"].update( + Guard={ + "file": "stored_session_policies/Guard.yaml", + "description": 3, + } + ), + "description must be text", + ), + ], +) +def test_foundation_schema_rejects_each_unsafe_shape( + mutate: object, message: str +) -> None: + data = _state.default_config() + mutate(data) # type: ignore[operator] + with pytest.raises(_configs.OperationalError, match=message): + _state._validate_config(data) + + +def test_config_option_layers_round_trip_and_resolve_precedence() -> None: + data = _state.default_config() + data["accounts"]["Prod"] = {"id": ACCOUNT_ID, "partition": "aws"} + _state.set_config_option(data, "naming.resources.role.prefix", "resource-") + _state.set_config_option(data, "naming.accounts.Prod.suffix", "-account") + _state.set_config_option(data, "naming.account_resources.Prod.role.case", "snake") + _state.set_config_option(data, "accounts.Prod.credential_target", "+Deploy") + resolved = _state.resolve_naming(data, resource="role", account="Prod") + assert resolved == { + "case": "snake", + "prefix": "resource-", + "suffix": "-account", + "enforcement": "off", + } + assert ( + _state.get_config_option(data, "accounts.Prod.credential_target") == "+Deploy" + ) + + _state.reset_config_option(data, "naming.resources.role.prefix") + _state.reset_config_option(data, "naming.accounts.Prod.suffix") + _state.reset_config_option(data, "naming.account_resources.Prod.role.case") + assert data["naming"]["resources"] == {} + assert data["naming"]["accounts"] == {} + assert data["naming"]["account_resources"] == {} + + with pytest.raises(_configs.OperationalError, match="dotted names"): + _state.get_config_option(data, "bad..key") + with pytest.raises(_configs.OperationalError, match="Unknown config option"): + _state.set_config_option(data, "naming.accounts.Prod.unknown", "x") + with pytest.raises(_configs.OperationalError, match="no reset default"): + _state.reset_config_option(data, "naming.resources.role.prefix") + with pytest.raises(_configs.OperationalError, match="not a settable leaf"): + _state.set_config_option(data, "output", {}) + with pytest.raises(_configs.OperationalError, match="IAM role ARN must be text"): + _state.parse_role_arn(None) diff --git a/hacksaws/tests/test_coverage_closure.py b/hacksaws/tests/test_coverage_closure.py index 0054ab6..6ea7497 100644 --- a/hacksaws/tests/test_coverage_closure.py +++ b/hacksaws/tests/test_coverage_closure.py @@ -5,6 +5,7 @@ import argparse import configparser import importlib +import subprocess import tomllib from copy import deepcopy from importlib import metadata @@ -25,6 +26,7 @@ from hacksaws import _policies from hacksaws import _state from hacksaws import _test_runner +from scripts import prettier ACCOUNT = "123456789012" ROLE = f"arn:aws:iam::{ACCOUNT}:role/Guard" @@ -92,6 +94,124 @@ def test_coverage_gate_uses_two_decimal_precision() -> None: assert "--cov-fail-under=95" in _test_runner.PYTEST_ARGUMENTS +def test_task_leaves_follow_toolbelt_calling_convention() -> None: + project = Path(__file__).parents[2] / "pyproject.toml" + with project.open("rb") as stream: + configuration = tomllib.load(stream) + tasks = configuration["tool"]["taskipy"]["tasks"] + + assert tasks["format_ruff"] == "uvx ruff format" + assert tasks["format_prettier"] == "python scripts/prettier.py write" + assert tasks["lint_ruff_format"] == "uvx ruff format --check" + assert tasks["lint_ruff"] == "uvx ruff check" + assert tasks["lint_mypy"] == ( + "mypy --install-types --non-interactive --ignore-missing-imports" + ) + assert tasks["lint_prettier"] == "python scripts/prettier.py check" + for name, command in tasks.items(): + if name.startswith(("format_", "lint_")): + assert not command.endswith(" .") + assert tasks["format"] == "task format_ruff . && task format_prettier ." + assert tasks["check"] == "task format && task lint && task test" + + +def test_typed_marker_and_build_metadata() -> None: + project = Path(__file__).parents[2] / "pyproject.toml" + with project.open("rb") as stream: + configuration = tomllib.load(stream) + + assert configuration["project"]["name"] == "hacksaws" + assert configuration["project"]["scripts"]["hacksaws"] == "hacksaws:main" + assert "Typing :: Typed" in configuration["project"]["classifiers"] + assert configuration["tool"]["hatch"]["build"]["artifacts"] == ["hacksaws/py.typed"] + assert project.with_name("hacksaws").joinpath("py.typed").is_file() + + +def test_prettier_wrapper_forwards_paths_without_scanning_ignored_cache() -> None: + git_result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( + ["git"], 0, b"README.md\0CHEATSHEET.md\0" + ) + prettier_result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( + ["npx"], 0 + ) + with ( + patch( + "scripts.prettier.subprocess.run", + side_effect=[git_result, prettier_result], + ) as run, + patch("scripts.prettier.shutil.which", side_effect=["git", "npx"]), + ): + assert prettier.main(["check", "docs", "."]) == 0 + + assert run.call_args_list[0].args[0][-3:] == ["--", "docs", "."] + assert run.call_args_list[1].args[0] == [ + "npx", + "prettier", + "--check", + "--ignore-unknown", + "--", + "README.md", + "CHEATSHEET.md", + ] + assert not any(".cache" in argument for argument in run.call_args_list[1].args[0]) + + +def test_prettier_wrapper_terminates_options_before_git_filenames() -> None: + git_result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( + ["git"], 0, b"--foo.md\0" + ) + prettier_result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( + ["npx"], 0 + ) + with ( + patch( + "scripts.prettier.subprocess.run", + side_effect=[git_result, prettier_result], + ) as run, + patch("scripts.prettier.shutil.which", side_effect=["git", "npx"]), + ): + assert prettier.main(["write", "."]) == 0 + + prettier_command = run.call_args_list[1].args[0] + assert prettier_command[-2:] == ["--", "--foo.md"] + + +def test_prettier_wrapper_handles_no_candidates_and_propagates_errors() -> None: + no_files: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( + ["git"], 0, b"" + ) + with ( + patch("scripts.prettier.subprocess.run", return_value=no_files) as run, + patch("scripts.prettier.shutil.which", return_value="git"), + ): + assert prettier.main(["write", "."]) == 0 + run.assert_called_once() + + git_error: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( + ["git"], 3, b"" + ) + with ( + patch("scripts.prettier.subprocess.run", return_value=git_error), + patch("scripts.prettier.shutil.which", return_value="git"), + ): + assert prettier.main(["check", "."]) == 3 + + files: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( + ["git"], 0, b"README.md\0" + ) + prettier_error: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( + ["npx"], 7 + ) + with ( + patch( + "scripts.prettier.subprocess.run", + side_effect=[files, prettier_error], + ), + patch("scripts.prettier.shutil.which", side_effect=["git", "npx"]), + ): + assert prettier.main(["check", "."]) == 7 + + def test_aws_ini_helpers_translate_parser_write_and_profile_errors( tmp_path: Path, ) -> None: diff --git a/hacksaws/tests/test_hacksaws.py b/hacksaws/tests/test_hacksaws.py index 44f86b5..a17caec 100644 --- a/hacksaws/tests/test_hacksaws.py +++ b/hacksaws/tests/test_hacksaws.py @@ -623,7 +623,7 @@ def test_aws_failure_is_concise( ) captured = capsys.readouterr() - assert result.code == "MFA_LOGOUT" + assert result.code == "LOGOUT_NO_STATE" assert result.exit_code == 0 assert captured.err == "" assert "Traceback" not in captured.err @@ -791,7 +791,7 @@ def test_container_engine_launch_os_error_is_concise_through_cli( result = hacksaws.console_main(arguments) captured = capsys.readouterr() - assert result.code == "MFA_LOGOUT" + assert result.code == "LOGOUT_NO_STATE" assert result.exit_code == 0 assert captured.err == "" assert "Traceback" not in captured.err diff --git a/hacksaws/tests/test_iam_cleanup.py b/hacksaws/tests/test_iam_cleanup.py new file mode 100644 index 0000000..1c83db9 --- /dev/null +++ b/hacksaws/tests/test_iam_cleanup.py @@ -0,0 +1,861 @@ +"""Focused contracts for account-scoped IAM inventory and cleanup.""" + +# Test doubles intentionally use dynamic boto-shaped interfaces and positional records. +# ruff: noqa: ANN401, D101, D102, D105, D107, FBT003, PT018 + +from __future__ import annotations + +import json +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from botocore.exceptions import ClientError + +from hacksaws import _iam_cleanup as cleanup +from hacksaws import _iam_managed_policies as managed +from hacksaws import _iam_recovery as recovery +from hacksaws import _iam_roles as roles +from hacksaws._configs import OperationalError + +ACCOUNT = "123456789012" +CALLER = f"arn:aws:iam::{ACCOUNT}:user/tester" +TRUST = {"Version": "2012-10-17", "Statement": []} + + +def policy( + name: str = "AgentRead", + *, + resource_id: str = "policy-1", + origin: str | None = "created", + document: dict[str, Any] | None = None, +) -> managed.ManagedPolicyRecord: + tags = [ + managed.Tag("hacksaws:managed-by", "hacksaws"), + managed.Tag("hacksaws:resource-kind", "managed-policy"), + managed.Tag("hacksaws:resource-id", resource_id), + ] + if origin is not None: + tags.append(managed.Tag(cleanup.ORIGIN_TAG, origin)) + return managed.ManagedPolicyRecord( + managed.ManagedPolicyArn.parse( + f"arn:aws:iam::{ACCOUNT}:policy/hacksaws/{name}" + ), + f"ANPA{name}", + name, + "/hacksaws/", + "v2", + 0, + 0, + tuple(tags), + document or {"Version": "2012-10-17", "Statement": []}, + ( + managed.PolicyVersionRecord("v1", False, None, {"Statement": []}), + managed.PolicyVersionRecord("v2", True, None, {"Statement": []}), + ), + ) + + +def role( + name: str = "AgentRole", + *, + origin: str | None = "adopted", + attached: tuple[str, ...] = (), + boundary: str | None = None, + profiles: tuple[str, ...] = (), + trust: dict[str, Any] | None = None, +) -> roles.RoleSnapshot: + tags = {roles.MANAGED_TAG: "true", roles.OWNER_TAG: CALLER} + if origin is not None: + tags[cleanup.ORIGIN_TAG] = origin + return roles.RoleSnapshot( + name, + f"arn:aws:iam::{ACCOUNT}:role/hacksaws/{name}", + "/hacksaws/", + trust or TRUST, + tags=tags, + attached_policies=attached, + permissions_boundary=boundary, + instance_profiles=profiles, + role_id=f"AROA{name}", + ) + + +class RoleService: + def __init__(self, values: tuple[roles.RoleSnapshot, ...]) -> None: + self.values = {item.name: item for item in values} + + def list_roles(self, *, path_prefix: str) -> tuple[roles.RoleSnapshot, ...]: + assert path_prefix == "/" + return tuple(self.values.values()) + + def get_role(self, name: str) -> roles.RoleSnapshot: + return self.values[name] + + +class PolicyService: + def __init__( + self, + values: tuple[managed.ManagedPolicyRecord, ...], + dependencies: managed.PolicyDependencies | None = None, + ) -> None: + self.values = {item.arn.value: item for item in values} + self.dependencies = dependencies or managed.PolicyDependencies() + + def list_policies( + self, *, scope: managed.PolicyScope, include_tags: bool + ) -> tuple[managed.ManagedPolicyRecord, ...]: + assert scope is managed.PolicyScope.LOCAL and include_tags + return tuple(self.values.values()) + + def get_policy( + self, reference: str, **_kwargs: object + ) -> managed.ManagedPolicyRecord: + return self.values[reference] + + def policy_dependencies(self, _reference: str) -> managed.PolicyDependencies: + return self.dependencies + + +class Iam: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, object]]] = [] + self.failures = 0 + self.role_exists = True + self.policy_exists = True + + def get_role(self, **_kwargs: object) -> dict[str, object]: + if not self.role_exists: + raise ClientError( + {"Error": {"Code": "NoSuchEntity", "Message": "absent"}}, + "GetRole", + ) + return {"Role": {"RoleId": "AROAAgentRole"}} + + def delete_role(self, **kwargs: object) -> None: + self.calls.append(("delete_role", dict(kwargs))) + if self.failures: + self.failures -= 1 + raise ClientError( + {"Error": {"Code": "ConcurrentModification", "Message": "retry"}}, + "DeleteRole", + ) + self.role_exists = False + + def get_policy(self, **_kwargs: object) -> dict[str, object]: + if not self.policy_exists: + raise ClientError( + {"Error": {"Code": "NoSuchEntity", "Message": "absent"}}, + "GetPolicy", + ) + return {"Policy": {"PolicyId": "ANPAAgentRead"}} + + def delete_policy(self, **kwargs: object) -> None: + self.calls.append(("delete_policy", dict(kwargs))) + self.policy_exists = False + + def __getattr__(self, action: str) -> Any: + def call(**kwargs: object) -> None: + self.calls.append((action, dict(kwargs))) + + return call + + +def context(iam: Iam | None = None) -> SimpleNamespace: + return SimpleNamespace( + account_id=ACCOUNT, + partition="aws", + arn=CALLER, + iam=iam or Iam(), + sts=SimpleNamespace(), + access_analyzer=None, + ) + + +@pytest.fixture(autouse=True) +def isolated_recovery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + recovery.clear_handlers() + cleanup.ensure_recovery_handler() + + +def service( + role_values: tuple[roles.RoleSnapshot, ...] = (), + policy_values: tuple[managed.ManagedPolicyRecord, ...] = (), + *, + dependencies: managed.PolicyDependencies | None = None, + iam: Iam | None = None, +) -> cleanup.CleanupService: + return cleanup.CleanupService( + context(iam), + role_service=RoleService(role_values), # type: ignore[arg-type] + policy_service=PolicyService( # type: ignore[arg-type] + policy_values, dependencies + ), + sleeper=lambda _delay: None, + jitter=lambda _lower, upper: upper, + ) + + +def test_inventory_classifies_origins_groups_smoke_and_filters() -> None: + created = policy() + group = policy( + "hacksaws-Agents-assume-roles", + resource_id="group-Agents", + origin=None, + ) + adopted_role = role() + adopted_role = replace( + adopted_role, + tags={ + **adopted_role.tags, + cleanup.SMOKE_TAG: "true", + cleanup.SMOKE_RUN_TAG: "run-1", + }, + ) + inventory = service((adopted_role,), (created, group)).inventory() + assert [item.resource_type for item in inventory.items] == [ + cleanup.ResourceType.GROUP_GRANT, + cleanup.ResourceType.POLICY, + cleanup.ResourceType.ROLE, + ] + assert inventory.items[0].origin is cleanup.OwnershipOrigin.LEGACY + selected = inventory.filter( + patterns=("agent*",), + origins=(cleanup.OwnershipOrigin.ADOPTED,), + owned_only=True, + smoke_only=True, + smoke_run_id="run-1", + ) + assert selected == (inventory.items[2],) + assert inventory.as_dict()["count"] == 3 + + +def test_plan_requires_explicit_scope_and_reports_dependency_opt_ins() -> None: + value = role( + attached=("arn:aws:iam::aws:policy/ReadOnlyAccess",), + boundary="arn:aws:iam::123456789012:policy/Boundary", + profiles=("AgentProfile",), + ) + selected = service((value,)) + with pytest.raises(OperationalError, match="PATTERN"): + selected.plan(cleanup.CleanupOptions()) + plan = selected.plan(cleanup.CleanupOptions(patterns=("agent*",), dry_run=True)) + assert plan.classification is cleanup.PlanClassification.BLOCKED + assert {item.code for item in plan.blockers} == { + "CASCADE_REQUIRED", + "BOUNDARY_OPT_IN_REQUIRED", + "INSTANCE_PROFILE_OPT_IN_REQUIRED", + } + + +def test_smoke_selectors_are_explicit_scope_and_dry_run_is_read_only() -> None: + smoke_role = replace( + role(), + tags={ + **role().tags, + cleanup.SMOKE_TAG: "true", + cleanup.SMOKE_RUN_TAG: "run-1", + }, + ) + selected = service((smoke_role,)) + + smoke_plan = selected.plan(cleanup.CleanupOptions(smoke_only=True)) + run_plan = selected.plan(cleanup.CleanupOptions(smoke_run_id="run-1")) + + assert smoke_plan.resources == run_plan.resources + assert [item.name for item in smoke_plan.resources] == ["AgentRole"] + assert recovery.list_journals() == [] + assert selected.context.iam.calls == [] + + +def test_plan_orders_roles_before_policies_and_marks_identity_commits() -> None: + policy_value = policy() + role_value = role(attached=(policy_value.arn.value,)) + plan = service((role_value,), (policy_value,)).plan( + cleanup.CleanupOptions( + all_resources=True, + cascade=True, + origins=frozenset( + {cleanup.OwnershipOrigin.CREATED, cleanup.OwnershipOrigin.ADOPTED} + ), + ) + ) + assert plan.classification is cleanup.PlanClassification.PLANNED + role_delete = next(step for step in plan.steps if step.action == "delete_role") + policy_delete = next(step for step in plan.steps if step.action == "delete_policy") + assert role_delete.irreversible and policy_delete.irreversible + first_policy_step = next( + step for step in plan.steps if step.resource_key.startswith("policy:") + ) + assert role_delete.id in first_policy_step.prerequisites + assert policy_delete.params["ExpectedPolicyId"] == policy_value.policy_id + assert plan.as_dict()["leaveNoTrace"]["localRecoveryJournalRetained"] is True # type: ignore[index] + + +def test_group_grant_cleanup_removes_owned_trust_before_aggregate_policy() -> None: + role_arn = f"arn:aws:iam::{ACCOUNT}:role/hacksaws/AgentRole" + principal = roles.DurablePrincipal( + "account", f"arn:aws:iam::{ACCOUNT}:root", ACCOUNT, "aws" + ) + role_value = role( + trust={ + "Version": "2012-10-17", + "Statement": [roles.trust_statement(principal, "HacksawsGroupAccount")], + } + ) + group = policy( + "hacksaws-Agents-assume-roles", + resource_id="group-Agents", + document={ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "HacksawsGroupAssumeRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": [role_arn], + } + ], + }, + ) + dependencies = managed.PolicyDependencies( + permission_groups=(managed.EntityReference("group", "Agents", "AGPA1"),) + ) + plan = service((role_value,), (group,), dependencies=dependencies).plan( + cleanup.CleanupOptions( + all_resources=True, + resource_types=frozenset({cleanup.ResourceType.GROUP_GRANT}), + ) + ) + actions = [step.action for step in plan.steps] + assert actions[0] == "update_assume_role_policy" + assert actions[-1] == "delete_policy" + assert plan.steps[0].id in plan.steps[1].prerequisites + + +def test_execute_retries_transient_failure_and_finishes_lnt_journal() -> None: + iam = Iam() + iam.failures = 1 + selected = service(iam=iam) + item = cleanup.InventoryItem( + cleanup.ResourceType.ROLE, + "AgentRole", + f"arn:aws:iam::{ACCOUNT}:role/AgentRole", + "AROAAgentRole", + cleanup.OwnershipOrigin.CREATED, + True, + "/hacksaws/", + ) + step = cleanup.CleanupStep( + "delete-role", + item.key, + "delete_role", + {"RoleName": "AgentRole", "ExpectedRoleId": "AROAAgentRole"}, + irreversible=True, + ) + independent = cleanup.CleanupStep( + "detach-group", + item.key, + "detach_group_policy", + {"GroupName": "Agents", "PolicyArn": "arn:policy"}, + ) + plan = cleanup.CleanupPlan( + ACCOUNT, + "aws", + CALLER, + cleanup.CleanupOptions(all_resources=True, dry_run=False), + (item,), + (step, independent), + ) + result = selected.execute(plan) + assert result.classification is cleanup.ResultClassification.CLEANED + assert result.lnt + assert [action for action, _params in iam.calls] == [ + "delete_role", + "detach_group_policy", + "delete_role", + ] + journal = recovery.get_journal(str(result.journal_id)) + assert journal["status"] == "completed" + assert journal["steps"][1]["attempts"] == 1 + assert journal["steps"][1]["forward"]["planStepId"] == "delete-role" + assert journal["partition"] == "aws" + assert journal["payloadsScrubbed"] is True + assert all(step["compensation"] == {} for step in journal["steps"]) + assert "credential" not in str(journal).casefold() + + +def test_successful_cleanup_scrubs_policy_and_trust_recovery_material() -> None: + iam = Iam() + selected = service(iam=iam) + item = cleanup.InventoryItem( + cleanup.ResourceType.ROLE, + "AgentRole", + f"arn:aws:iam::{ACCOUNT}:role/AgentRole", + "AROAAgentRole", + cleanup.OwnershipOrigin.CREATED, + True, + "/hacksaws/", + ) + secret_document = { + "Version": "2012-10-17", + "Statement": [{"Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}], + } + plan = cleanup.CleanupPlan( + ACCOUNT, + "aws", + CALLER, + cleanup.CleanupOptions(all_resources=True, dry_run=False), + (item,), + ( + cleanup.CleanupStep( + "trust", + item.key, + "update_assume_role_policy", + {"RoleName": "AgentRole", "PolicyDocument": "{}"}, + "update_assume_role_policy", + {"RoleName": "AgentRole", "PolicyDocument": secret_document}, + ), + cleanup.CleanupStep( + "delete-role", + item.key, + "delete_role", + {"RoleName": "AgentRole", "ExpectedRoleId": "AROAAgentRole"}, + prerequisites=("trust",), + irreversible=True, + ), + ), + ) + + result = selected.execute(plan) + receipt = recovery.get_journal(str(result.journal_id)) + + assert result.lnt + assert receipt["payloadsScrubbed"] is True + assert "PolicyDocument" not in str(receipt) + assert CALLER not in str(receipt) + assert selected.continue_journal(str(result.journal_id)).lnt + with pytest.raises(OperationalError, match="cannot be rolled back"): + selected.rollback_journal(str(result.journal_id)) + + +def test_execute_preserves_nonretryable_failure_for_recovery() -> None: + iam = Iam() + + def denied(**_kwargs: object) -> None: + raise ClientError( + {"Error": {"Code": "AccessDenied", "Message": "no"}}, "DeleteRole" + ) + + iam.delete_role = denied # type: ignore[method-assign] + selected = service(iam=iam) + item = cleanup.InventoryItem( + cleanup.ResourceType.ROLE, + "AgentRole", + f"arn:aws:iam::{ACCOUNT}:role/AgentRole", + "AROAAgentRole", + cleanup.OwnershipOrigin.CREATED, + True, + "/hacksaws/", + ) + plan = cleanup.CleanupPlan( + ACCOUNT, + "aws", + CALLER, + cleanup.CleanupOptions(all_resources=True, dry_run=False), + (item,), + ( + cleanup.CleanupStep( + "delete-role", + item.key, + "delete_role", + {"RoleName": "AgentRole", "ExpectedRoleId": "AROAAgentRole"}, + irreversible=True, + ), + ), + ) + result = selected.execute(plan) + assert result.classification is cleanup.ResultClassification.RECOVERY_REQUIRED + assert result.failed == (item.key,) + assert result.remaining == (item.key,) + assert recovery.get_journal(str(result.journal_id))["status"] == "failed" + iam.delete_role = Iam.delete_role.__get__(iam, Iam) # type: ignore[method-assign] + resumed = selected.continue_journal(str(result.journal_id)) + assert resumed.classification is cleanup.ResultClassification.CLEANED + assert resumed.lnt + assert recovery.get_journal(str(result.journal_id))["status"] == "completed" + + +def test_origin_tags_are_emitted_for_create_and_adopt() -> None: + spec = roles.RoleSpec("AgentRole", TRUST) + assert roles.ownership_tags(spec)[roles.ORIGIN_TAG] == "created" + adopted = roles.plan_adopt_role(role(origin=None), CALLER) + tags = { + item["Key"]: item["Value"] + for operation in adopted.operations + for item in operation.params["Tags"] + } + assert tags[roles.ORIGIN_TAG] == "adopted" + + +def test_policy_dependency_steps_cover_every_relationship_and_drift() -> None: + value = policy() + dependencies = managed.PolicyDependencies( + permission_users=(managed.EntityReference("user", "Alice", "AIDA1"),), + permission_groups=(managed.EntityReference("group", "Agents", "AGPA1"),), + permission_roles=(managed.EntityReference("role", "Reader", "AROA1"),), + boundary_users=(managed.EntityReference("user", "BoundaryUser", "AIDA2"),), + boundary_roles=(managed.EntityReference("role", "BoundaryRole", "AROA2"),), + ) + selected = service((role("Unselected"),), (value,), dependencies=dependencies) + blocked = selected.plan(cleanup.CleanupOptions(all_resources=True)) + assert blocked.classification is cleanup.PlanClassification.BLOCKED + assert {item.code for item in blocked.blockers} == { + "CASCADE_REQUIRED", + "BOUNDARY_OPT_IN_REQUIRED", + } + plan = selected.plan( + cleanup.CleanupOptions( + all_resources=True, + resource_types=frozenset({cleanup.ResourceType.POLICY}), + cascade=True, + remove_boundaries=True, + ) + ) + assert [step.action for step in plan.steps] == [ + "detach_user_policy", + "detach_group_policy", + "detach_role_policy", + "delete_user_permissions_boundary", + "delete_role_permissions_boundary", + "delete_policy_version", + "delete_policy", + ] + selected.policy_service.values[value.arn.value] = policy(origin="adopted") # type: ignore[attr-defined] + with pytest.raises(OperationalError, match="changed after cleanup planning"): + selected.execute(plan) + + +def test_no_matches_blocked_and_account_mismatch_execution_results() -> None: + selected = service((role(),)) + no_matches = selected.plan(cleanup.CleanupOptions(patterns=("missing*",))) + assert no_matches.classification is cleanup.PlanClassification.NO_MATCHES + assert selected.execute(no_matches).as_dict() == { + "classification": "cleaned", + "journalId": None, + "completed": [], + "failed": [], + "remaining": [], + "leaveNoTrace": True, + } + blocked = cleanup.CleanupPlan( + ACCOUNT, + "aws", + CALLER, + cleanup.CleanupOptions(all_resources=True), + (), + (), + (cleanup.CleanupBlocker("role:x", "BLOCKED", "reason"),), + ) + # A resource makes blocker classification take precedence over no-matches. + blocked = cleanup.CleanupPlan( + blocked.account_id, + blocked.partition, + blocked.caller_arn, + blocked.options, + ( + cleanup.InventoryItem( + cleanup.ResourceType.ROLE, + "x", + "arn:x", + "id", + cleanup.OwnershipOrigin.CREATED, + True, + "/", + ), + ), + (), + blocked.blockers, + ) + assert ( + selected.execute(blocked).classification is cleanup.ResultClassification.BLOCKED + ) + wrong = cleanup.CleanupPlan( + "999999999999", + "aws", + CALLER, + cleanup.CleanupOptions(all_resources=True), + blocked.resources, + (), + ) + with pytest.raises(OperationalError, match="does not match"): + selected.execute(wrong) + + +def test_low_level_identity_and_recovery_payload_guards() -> None: + iam = Iam() + ctx = context(iam) + assert cleanup.ownership_origin({cleanup.ORIGIN_TAG: "other"}) is ( + cleanup.OwnershipOrigin.UNKNOWN + ) + assert cleanup._error_code(RuntimeError("x")) == "RuntimeError" + with pytest.raises(OperationalError, match="not allowlisted"): + cleanup._call(ctx, "delete_user", {}) + with pytest.raises(OperationalError, match="payload is invalid"): + cleanup._forward({"action": 1}, ctx) + with pytest.raises(OperationalError, match="payload is invalid"): + cleanup._compensate({"action": 1, "params": {}}, ctx) + cleanup._compensate({"action": None, "params": {}}, ctx) + cleanup._compensate( + { + "irreversible": True, + "forwardAction": "delete_role", + "forwardParams": { + "RoleName": "AgentRole", + "ExpectedRoleId": "AROAAgentRole", + }, + }, + ctx, + ) + cleanup._compensate( + { + "irreversible": True, + "forwardAction": "delete_policy", + "forwardParams": { + "PolicyArn": "arn:policy", + "ExpectedPolicyId": "ANPAAgentRead", + }, + }, + ctx, + ) + iam.role_exists = False + with pytest.raises(OperationalError, match="identity commit point"): + cleanup._compensate( + { + "irreversible": True, + "forwardAction": "delete_role", + "forwardParams": { + "RoleName": "AgentRole", + "ExpectedRoleId": "AROAAgentRole", + }, + }, + ctx, + ) + + +def test_cleanup_recovery_rejects_wrong_service_account_and_partition() -> None: + selected = service() + recovery.register_handler( + "other", + "noop", + forward=lambda _payload, _context: None, + compensate=lambda _payload, _context: None, + ) + other = recovery.begin_journal("other", ACCOUNT, "test") + with pytest.raises(OperationalError, match="not an IAM cleanup"): + selected.continue_journal(other.id) + handle = recovery.begin_journal("iam-cleanup", "999999999999", "cleanup") + with pytest.raises(OperationalError, match="selected account"): + selected.continue_journal(handle.id) + legacy = recovery.begin_journal("iam-cleanup", ACCOUNT, "cleanup") + legacy_path = recovery._journal_path(legacy.id) + legacy_data = json.loads(legacy_path.read_text(encoding="utf-8")) + legacy_data.pop("partition") + legacy_path.write_text(json.dumps(legacy_data), encoding="utf-8") + with pytest.raises(OperationalError, match="no recorded AWS partition"): + selected.continue_journal(legacy.id) + wrong_partition = recovery.begin_journal( + "iam-cleanup", ACCOUNT, "cleanup", partition="aws-cn" + ) + with pytest.raises(OperationalError, match="selected partition"): + selected.continue_journal(wrong_partition.id) + with pytest.raises(OperationalError, match="selected partition"): + selected.rollback_journal(wrong_partition.id) + + +def test_invalid_snapshots_inventory_warning_and_role_drift() -> None: + class FailingRoleService(RoleService): + def get_role(self, name: str) -> roles.RoleSnapshot: + raise ClientError( + {"Error": {"Code": "ServiceFailure", "Message": name}}, "GetRole" + ) + + warned = cleanup.CleanupService( + context(), + role_service=FailingRoleService((role(),)), # type: ignore[arg-type] + policy_service=PolicyService(()), # type: ignore[arg-type] + ).inventory() + assert warned.items == () + assert "Unable to hydrate role" in warned.warnings[0] + + selected = service((role(),)) + plan = selected.plan(cleanup.CleanupOptions(all_resources=True)) + selected.role_service.values["AgentRole"] = replace( # type: ignore[attr-defined] + role(), description="drift" + ) + with pytest.raises(OperationalError, match="changed after cleanup planning"): + selected.execute(plan) + + invalid = cleanup.InventoryItem( + cleanup.ResourceType.ROLE, + "invalid", + "arn:invalid", + "id", + cleanup.OwnershipOrigin.CREATED, + True, + "/", + snapshot=object(), + ) + with pytest.raises(OperationalError, match="Role inventory snapshot"): + selected._role_steps(invalid, cleanup.CleanupOptions(), ()) + invalid_policy = replace(invalid, resource_type=cleanup.ResourceType.POLICY) + with pytest.raises(OperationalError, match="Policy inventory snapshot"): + selected._policy_steps(invalid_policy, cleanup.CleanupOptions()) + + +def test_group_grant_retention_parsing_and_ambiguous_trust_blocker() -> None: + role_arn = f"arn:aws:iam::{ACCOUNT}:role/hacksaws/AgentRole" + selected_policy = policy( + "hacksaws-Agents-assume-roles", + resource_id="group-Agents", + document={ + "Statement": { + "Sid": "HacksawsGroupAssumeRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": role_arn, + } + }, + ) + retained_policy = policy( + "hacksaws-Other-assume-roles", + resource_id="group-Other", + document={ + "Statement": [ + {"Sid": "Unrelated"}, + {"Sid": "HacksawsGroupAssumeRoles", "Resource": [role_arn, 1]}, + ] + }, + ) + dependencies = managed.PolicyDependencies( + permission_groups=(managed.EntityReference("group", "Agents", "AGPA1"),) + ) + selected = service( + (role(),), (selected_policy, retained_policy), dependencies=dependencies + ) + plan = selected.plan( + cleanup.CleanupOptions( + patterns=("*Agents*",), + resource_types=frozenset({cleanup.ResourceType.GROUP_GRANT}), + ) + ) + assert "update_assume_role_policy" not in [step.action for step in plan.steps] + assert ( + cleanup.CleanupService._grant_role_arns( # type: ignore[arg-type] + replace(plan.resources[0], snapshot=object()) + ) + == () + ) + + ambiguous = replace( + role(), + trust={ + "Statement": [ + {"Sid": "HacksawsGroupAccount"}, + {"Sid": "HacksawsGroupAccount"}, + ] + }, + ) + only_selected = service((ambiguous,), (selected_policy,), dependencies=dependencies) + blocked = only_selected.plan( + cleanup.CleanupOptions( + all_resources=True, + resource_types=frozenset({cleanup.ResourceType.GROUP_GRANT}), + ) + ) + assert blocked.blockers[0].code == "GROUP_TRUST_BLOCKED" + + +def test_lnt_residue_identity_mismatch_absence_and_stalled_queue() -> None: + iam = Iam() + + def retained(**kwargs: object) -> None: + iam.calls.append(("delete_role", dict(kwargs))) + + iam.delete_role = retained # type: ignore[method-assign] + selected = service(iam=iam) + item = cleanup.InventoryItem( + cleanup.ResourceType.ROLE, + "AgentRole", + f"arn:aws:iam::{ACCOUNT}:role/AgentRole", + "AROAAgentRole", + cleanup.OwnershipOrigin.CREATED, + True, + "/hacksaws/", + ) + plan = cleanup.CleanupPlan( + ACCOUNT, + "aws", + CALLER, + cleanup.CleanupOptions(all_resources=True, dry_run=False), + (item,), + ( + cleanup.CleanupStep( + "delete-role", + item.key, + "delete_role", + {"RoleName": "AgentRole", "ExpectedRoleId": "AROAAgentRole"}, + irreversible=True, + ), + ), + ) + result = selected.execute(plan) + assert result.classification is cleanup.ResultClassification.PARTIAL + assert result.remaining == (item.key,) + + with pytest.raises(OperationalError, match="Role identity changed"): + cleanup._call( + context(), + "delete_role", + {"RoleName": "AgentRole", "ExpectedRoleId": "different"}, + ) + with pytest.raises(OperationalError, match="Policy identity changed"): + cleanup._call( + context(), + "delete_policy", + {"PolicyArn": "arn:policy", "ExpectedPolicyId": "different"}, + ) + + iam.role_exists = False + assert cleanup._forward( + { + "action": "delete_role", + "params": {"RoleName": "AgentRole", "ExpectedRoleId": "AROAAgentRole"}, + }, + context(iam), + ) == {"absenceObserved": True} + + cleanup.ensure_recovery_handler() + stalled = recovery.begin_journal("iam-cleanup", ACCOUNT, "cleanup", partition="aws") + stalled.record_before_mutation( + "aws-operation", + forward={ + "planStepId": "blocked", + "resourceKey": item.key, + "prerequisites": ["missing"], + "action": "delete_role", + "params": {"RoleName": "AgentRole"}, + "irreversible": True, + }, + compensation={"action": None, "params": {}, "irreversible": False}, + ) + state = cleanup._continue_queue( + stalled.id, + context(iam), + sleeper=lambda _delay: None, + jitter=lambda _lower, upper: upper, + ) + assert state["remaining"] == [item.key] diff --git a/hacksaws/tests/test_iam_cli_scaffold.py b/hacksaws/tests/test_iam_cli_scaffold.py new file mode 100644 index 0000000..42ccdf4 --- /dev/null +++ b/hacksaws/tests/test_iam_cli_scaffold.py @@ -0,0 +1,818 @@ +"""Focused contract tests for the central IAM CLI scaffold.""" + +from __future__ import annotations + +import argparse +import json +import os +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import boto3 +import pytest + +from hacksaws import _configs +from hacksaws import _iam_cleanup +from hacksaws import _iam_cli +from hacksaws import _iam_recovery +from hacksaws import _state + + +class _Client: + def __init__(self, account: str = "123456789012") -> None: + self.account = account + + def get_caller_identity(self) -> dict[str, str]: + return { + "Account": self.account, + "Arn": f"arn:aws:iam::{self.account}:user/test", + } + + +class _Frozen: + access_key = "SELECTEDACCESS" + secret_key = "selected-secret" # noqa: S105 + token = "selected-token" # noqa: S105 + + +class _Credentials: + def get_frozen_credentials(self) -> _Frozen: + return _Frozen() + + +class _Session: + region_name = "us-west-2" + + def __init__(self, account: str = "123456789012") -> None: + self.account = account + + def get_credentials(self) -> _Credentials: + return _Credentials() + + def client(self, name: str) -> _Client: + assert name in {"iam", "sts", "accessanalyzer"} + return _Client(self.account) + + +def _account_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + _state.save_config(data) + + +def test_context_binds_selected_files_and_verifies_expected_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _account_config(tmp_path, monkeypatch) + monkeypatch.setenv("AWS_CONFIG_FILE", "before-config") + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", "before-credentials") + monkeypatch.setenv("AWS_PROFILE", "before-profile") + selected = tmp_path / "aws-west" + selected.mkdir() + observed: dict[str, str | None] = {} + calls: list[dict[str, object]] = [] + + def factory(**kwargs: object) -> _Session: + calls.append(kwargs) + observed["config"] = os.environ.get("AWS_CONFIG_FILE") + observed["credentials"] = os.environ.get("AWS_SHARED_CREDENTIALS_FILE") + observed["profile"] = os.environ.get("AWS_PROFILE") + return _Session() + + context = _iam_cli.IamCommandContext.create( + argparse.Namespace( + profile="deploy", + location="west", + directory=str(selected), + target=None, + account="Prod", + region="us-west-2", + ), + session_factory=factory, + ) + assert context.account_id == "123456789012" + assert context.config_path == selected / "config" + assert observed == { + "config": str(selected / "config"), + "credentials": str(selected / "credentials"), + "profile": None, + } + assert calls == [ + {"profile_name": "deploy", "region_name": "us-west-2"}, + { + "aws_access_key_id": "SELECTEDACCESS", + "aws_secret_access_key": "selected-secret", + "aws_session_token": "selected-token", + "region_name": "us-west-2", + }, + ] + assert os.environ["AWS_CONFIG_FILE"] == "before-config" + assert os.environ["AWS_SHARED_CREDENTIALS_FILE"] == "before-credentials" + assert os.environ["AWS_PROFILE"] == "before-profile" + + +def test_context_rejects_selected_account_mismatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _account_config(tmp_path, monkeypatch) + + class WrongSession(_Session): + def __init__(self) -> None: + super().__init__("999999999999") + + with pytest.raises(_configs.OperationalError, match="selected account requires"): + _iam_cli.IamCommandContext.create( + argparse.Namespace( + profile="default", + location="default", + directory=str(tmp_path / "aws"), + target=None, + account="Prod", + region=None, + ), + session_factory=lambda **_kwargs: WrongSession(), + ) + + +def test_iam_remote_alias_help_json_error_and_recovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _account_config(tmp_path, monkeypatch) + from hacksaws import _cli + + _iam_cli.clear_adapters() + assert _cli.console_main(["iam", "policy"]).code == "IAM_LEAF_HELP" + assert _cli.console_main(["remote", "policy"]).code == "IAM_LEAF_HELP" + result = _cli.console_main(["iam", "recovery", "list", "--json"]) + rendered = json.loads(capsys.readouterr().out) + assert result.code == "IAM_RECOVERY_LIST" + assert rendered["schemaVersion"] == 1 + assert rendered["data"] == {"journals": [], "count": 0} + bad = _cli.console_main(["iam", "recovery", "nope", "--json"]) + assert bad.exit_code == _configs.EXIT_USAGE + assert json.loads(capsys.readouterr().err)["code"] == "ARGUMENT_ERROR" + + +def test_root_selectors_survive_a_leaf_parser( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _account_config(tmp_path, monkeypatch) + from hacksaws import _cli + + namespace = _cli._create_parser().parse_args( + ["iam", "--profile", "source", "--location", "west", "policy"] + ) + assert namespace.profile == "source" + assert namespace.location == "west" + + +def test_terminal_iam_selectors_cleanup_aliases_and_no_abbreviations() -> None: + from hacksaws import _cli + + parser = _cli._create_parser() + policy = parser.parse_args( + [ + "iam", + "policy", + "create", + "policy.yaml", + "--profile", + "admin", + "--location", + "horizon", + "--dry-run", + ] + ) + assert policy.profile == "admin" + assert policy.location == "horizon" + assert policy.dry_run is True + + for prefix in (["cleanup"], ["iam", "cleanup"], ["remote", "cleanup"]): + cleanup = parser.parse_args([*prefix, "*Agent*", "--policies", "--dry-run"]) + assert cleanup.iam_action == "cleanup" + assert cleanup.patterns == ["*Agent*"] + assert cleanup.policies is True + + with pytest.raises(SystemExit): + parser.parse_args(["iam", "policy", "get", "Agent", "--prof", "admin"]) + + +@pytest.mark.parametrize( + ("arguments", "message"), + [ + ( + ["iam", "--profile", "one", "policy", "get", "Agent", "--profile", "two"], + "profile only once", + ), + ( + ["iam", "policy", "get", "Agent", "--location", "x", "-d", "aws"], + "select the same AWS folder", + ), + ( + ["iam", "policy", "get", "Agent", "--target", "prod", "--profile", "admin"], + "cannot be combined", + ), + ], +) +def test_selector_preflight_rejects_duplicates_and_conflicts( + arguments: list[str], message: str +) -> None: + with pytest.raises(_configs.OperationalError, match=message): + _iam_cli.validate_selector_arguments(arguments) + + +def _inventory_item() -> _iam_cleanup.InventoryItem: + return _iam_cleanup.InventoryItem( + resource_type=_iam_cleanup.ResourceType.POLICY, + name="AgentRead", + arn="arn:aws:iam::123456789012:policy/hacksaws/AgentRead", + resource_id="ANPA1", + origin=_iam_cleanup.OwnershipOrigin.CREATED, + owned=True, + path="/hacksaws/", + dependencies={"roles": ("Agent",)}, + ) + + +def test_iam_inventory_and_cleanup_cli_results(monkeypatch: pytest.MonkeyPatch) -> None: + item = _inventory_item() + inventory = _iam_cleanup.IamInventory( + "123456789012", "aws", "arn:aws:iam::123456789012:user/test", (item,) + ) + planned = _iam_cleanup.CleanupPlan( + inventory.account_id, + inventory.partition, + inventory.caller_arn, + _iam_cleanup.CleanupOptions(patterns=("*Agent*",), dry_run=True), + (item,), + (), + ) + cleaned = _iam_cleanup.CleanupResult( + classification=_iam_cleanup.ResultClassification.CLEANED, + journal_id="journal", + completed=(item.key,), + failed=(), + remaining=(), + lnt=True, + ) + + class Service: + def __init__(self, _context: object) -> None: + pass + + def inventory(self) -> _iam_cleanup.IamInventory: + return inventory + + def plan( + self, _options: _iam_cleanup.CleanupOptions + ) -> _iam_cleanup.CleanupPlan: + return planned + + def execute( + self, _plan: _iam_cleanup.CleanupPlan + ) -> _iam_cleanup.CleanupResult: + return cleaned + + monkeypatch.setattr(_iam_cli._iam_cleanup, "CleanupService", Service) + context = SimpleNamespace() + listed = _iam_cli.inventory_result( + argparse.Namespace( + patterns=["*Agent*"], + roles=False, + policies=True, + group_grants=False, + created=False, + adopted=False, + smoke=False, + smoke_run=None, + wide=True, + ), + context, + ) + assert listed.code == "IAM_INVENTORY" + assert "ANPA" not in listed.message + assert "AgentRead" in listed.message + + dry_run = _iam_cli.cleanup_result( + argparse.Namespace( + patterns=["*Agent*"], + all=False, + roles=False, + policies=True, + group_grants=False, + created=False, + adopted=False, + smoke=False, + smoke_run=None, + cascade=False, + remove_boundaries=False, + remove_from_instance_profiles=False, + dry_run=True, + yes=False, + ), + context, + ) + assert dry_run.code == "IAM_CLEANUP_PLAN" + assert dry_run.data["classification"] == "planned" + + execute_args = argparse.Namespace( + patterns=["*Agent*"], + all=False, + roles=False, + policies=True, + group_grants=False, + created=False, + adopted=False, + smoke=False, + smoke_run=None, + cascade=False, + remove_boundaries=False, + remove_from_instance_profiles=False, + dry_run=False, + yes=True, + ) + applied = _iam_cli.cleanup_result(execute_args, context) + assert applied.code == "IAM_CLEANUP_COMPLETE" + assert applied.data["result"]["leaveNoTrace"] is True + + +def test_cleanup_requires_explicit_selection_and_rejects_all_with_patterns() -> None: + base = { + "patterns": [], + "all": False, + "roles": False, + "policies": False, + "group_grants": False, + "created": False, + "adopted": False, + "smoke": False, + "smoke_run": None, + "cascade": False, + "remove_boundaries": False, + "remove_from_instance_profiles": False, + "dry_run": True, + "yes": False, + } + with pytest.raises(_configs.OperationalError, match="requires PATTERN"): + _iam_cli.cleanup_result(argparse.Namespace(**base), SimpleNamespace()) + with pytest.raises(_configs.OperationalError, match="conflicts"): + _iam_cli.cleanup_result( + argparse.Namespace(**{**base, "patterns": ["*"], "all": True}), + SimpleNamespace(), + ) + + +def test_cleanup_blocked_confirmation_and_partial_results( + monkeypatch: pytest.MonkeyPatch, +) -> None: + item = _inventory_item() + options = _iam_cleanup.CleanupOptions(patterns=("*",), dry_run=False) + blocker = _iam_cleanup.CleanupBlocker(item.key, "dependency", "retained role") + blocked = _iam_cleanup.CleanupPlan( + "123456789012", + "aws", + "arn:aws:iam::123456789012:user/test", + options, + (item,), + (), + (blocker,), + ) + planned = replace(blocked, blockers=()) + partial = _iam_cleanup.CleanupResult( + classification=_iam_cleanup.ResultClassification.PARTIAL, + journal_id="journal", + completed=(), + failed=(item.key,), + remaining=(item.key,), + lnt=False, + ) + selected_plan = blocked + + class Service: + def __init__(self, _context: object) -> None: + pass + + def plan( + self, _options: _iam_cleanup.CleanupOptions + ) -> _iam_cleanup.CleanupPlan: + return selected_plan + + def execute( + self, _plan: _iam_cleanup.CleanupPlan + ) -> _iam_cleanup.CleanupResult: + return partial + + monkeypatch.setattr(_iam_cli._iam_cleanup, "CleanupService", Service) + args = argparse.Namespace( + patterns=["*"], + all=False, + roles=True, + policies=False, + group_grants=True, + created=True, + adopted=True, + smoke=False, + smoke_run=None, + cascade=False, + remove_boundaries=False, + remove_from_instance_profiles=False, + dry_run=False, + yes=False, + ) + result = _iam_cli.cleanup_result(args, SimpleNamespace()) + assert result.code == "IAM_CLEANUP_PLAN" + assert result.exit_code == 2 + + selected_plan = planned + monkeypatch.setattr(_iam_cli.os, "isatty", lambda _fd: False) + result = _iam_cli.cleanup_result(args, SimpleNamespace()) + assert result.code == "IAM_CLEANUP_CONFIRMATION_REQUIRED" + + args.yes = True + result = _iam_cli.cleanup_result(args, SimpleNamespace()) + assert result.code == "IAM_CLEANUP_PARTIAL" + assert result.exit_code == 2 + + +def test_inventory_rendering_and_central_dispatch_branches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + item = _inventory_item().as_dict() + assert "🧪" not in _iam_cli._inventory_text([item], wide=False) + assert _iam_cli._inventory_text([], wide=False).startswith("No matching") + context = SimpleNamespace() + monkeypatch.setattr(_iam_cli.IamCommandContext, "create", lambda _args: context) + monkeypatch.setattr( + _iam_cli, + "inventory_result", + lambda _args, _context: _configs.Result("LISTED", "listed"), + ) + monkeypatch.setattr( + _iam_cli, + "cleanup_result", + lambda _args, _context: _configs.Result("CLEANED", "cleaned"), + ) + assert _iam_cli.dispatch(argparse.Namespace(iam_action="list")).code == "LISTED" + cleanup_args = argparse.Namespace(iam_action="cleanup") + assert _iam_cli.dispatch(cleanup_args).code == "CLEANED" + assert _iam_cli.dispatch_root_cleanup(cleanup_args).code == "CLEANED" + + +def test_recovery_get_and_empty_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _account_config(tmp_path, monkeypatch) + monkeypatch.delenv("AWS_CONFIG_FILE", raising=False) + monkeypatch.delenv("AWS_SHARED_CREDENTIALS_FILE", raising=False) + monkeypatch.delenv("AWS_PROFILE", raising=False) + with _iam_cli.credential_environment(tmp_path / "config", tmp_path / "credentials"): + assert os.environ["AWS_CONFIG_FILE"].endswith("config") + assert "AWS_CONFIG_FILE" not in os.environ + _iam_recovery.clear_handlers() + _iam_recovery.register_handler( + "policy", + "create", + forward=lambda _payload, _context: None, + compensate=lambda _payload, _context: None, + ) + journal = _iam_recovery.begin_journal( + "policy", "123456789012", "create-policy", journal_id="recoverable" + ) + get = _iam_cli.recovery_result( + argparse.Namespace(recovery_action="get", journal_id=journal.id) + ) + assert json.loads(get.message)["status"] == "active" + + +def test_credential_environment_restores_every_provider_variable_exactly( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + before = { + key: f"before-{index}" + for index, key in enumerate(_iam_cli._CREDENTIAL_ENVIRONMENT_KEYS) + } + for key, value in before.items(): + monkeypatch.setenv(key, value) + config = tmp_path / "selected-config" + credentials = tmp_path / "selected-credentials" + with _iam_cli.credential_environment(config, credentials): + assert os.environ["AWS_CONFIG_FILE"] == str(config) + assert os.environ["AWS_SHARED_CREDENTIALS_FILE"] == str(credentials) + assert os.environ["AWS_EC2_METADATA_DISABLED"] == "true" + for key in _iam_cli._CREDENTIAL_ENVIRONMENT_KEYS[2:]: + if key != "AWS_EC2_METADATA_DISABLED": + assert key not in os.environ + assert { + key: os.environ.get(key) for key in _iam_cli._CREDENTIAL_ENVIRONMENT_KEYS + } == before + + +def test_real_botocore_clients_ignore_all_ambient_endpoint_overrides( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + selected = tmp_path / "selected-endpoints" + selected.mkdir() + selected.joinpath("config").write_text( + "[profile deploy]\nregion = us-west-2\n", encoding="utf-8" + ) + selected.joinpath("credentials").write_text( + "[deploy]\naws_access_key_id = SELECTEDACCESS\n" + "aws_secret_access_key = selected-secret\n", + encoding="utf-8", + ) + endpoint_keys = [ + key + for key in _iam_cli._CREDENTIAL_ENVIRONMENT_KEYS + if key.startswith("AWS_ENDPOINT_URL") + ] + for key in endpoint_keys: + monkeypatch.setenv(key, "https://attacker.invalid") + + with _iam_cli.credential_environment(selected / "config", selected / "credentials"): + session = boto3.Session(profile_name="deploy") + endpoints = { + name: session.client(name).meta.endpoint_url + for name in ("sts", "iam", "accessanalyzer") + } + + assert all("attacker.invalid" not in endpoint for endpoint in endpoints.values()) + assert all(os.environ[key] == "https://attacker.invalid" for key in endpoint_keys) + + +@pytest.mark.parametrize("ambient_has_profile", [True, False]) +def test_real_boto3_provider_freezes_only_selected_profile( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ambient_has_profile: bool, +) -> None: + """Ambient same-profile credentials cannot replace the explicitly selected file.""" + _account_config(tmp_path, monkeypatch) + selected = tmp_path / "selected" + ambient = tmp_path / "ambient" + selected.mkdir() + ambient.mkdir() + selected.joinpath("config").write_text( + "[profile deploy]\nregion = us-west-2\n", encoding="utf-8" + ) + selected.joinpath("credentials").write_text( + "[deploy]\naws_access_key_id = SELECTEDACCESS\n" + "aws_secret_access_key = selected-secret\n", + encoding="utf-8", + ) + ambient.joinpath("config").write_text( + "[profile deploy]\nregion = us-east-1\n" if ambient_has_profile else "", + encoding="utf-8", + ) + ambient.joinpath("credentials").write_text( + "[deploy]\naws_access_key_id = AMBIENTACCESS\n" + "aws_secret_access_key = ambient-secret\n" + if ambient_has_profile + else "", + encoding="utf-8", + ) + monkeypatch.setenv("AWS_CONFIG_FILE", str(ambient / "config")) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(ambient / "credentials")) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "ENVACCESS") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "env-secret") + endpoint_keys = [ + key + for key in _iam_cli._CREDENTIAL_ENVIRONMENT_KEYS + if key.startswith("AWS_ENDPOINT_URL") + ] + for key in endpoint_keys: + monkeypatch.setenv(key, "https://attacker.invalid") + explicit_calls: list[dict[str, object]] = [] + + class ObservingClient(_Client): + def __init__(self) -> None: + super().__init__() + self.endpoint_override = next( + (os.environ.get(key) for key in endpoint_keys if os.environ.get(key)), + None, + ) + assert self.endpoint_override is None + + def get_caller_identity(self) -> dict[str, str]: + assert os.environ["AWS_CONFIG_FILE"] == str(selected / "config") + assert "AWS_ACCESS_KEY_ID" not in os.environ + return super().get_caller_identity() + + def mutation(self) -> None: + assert self.endpoint_override is None + + class ObservingSession(_Session): + def client(self, name: str) -> _Client: + assert os.environ["AWS_CONFIG_FILE"] == str(selected / "config") + return ObservingClient() + + def factory(**kwargs: object) -> object: + if "profile_name" in kwargs: + return boto3.Session( + profile_name=str(kwargs["profile_name"]), + region_name=( + str(kwargs["region_name"]) + if kwargs.get("region_name") is not None + else None + ), + ) + explicit_calls.append(kwargs) + assert os.environ["AWS_CONFIG_FILE"] == str(selected / "config") + assert "AWS_ACCESS_KEY_ID" not in os.environ + return ObservingSession() + + context = _iam_cli.IamCommandContext.create( + argparse.Namespace( + profile="deploy", + location="default", + directory=str(selected), + target=None, + account="Prod", + region=None, + ), + session_factory=factory, + ) + assert context.account_id == "123456789012" + assert explicit_calls[0]["aws_access_key_id"] == "SELECTEDACCESS" + assert explicit_calls[0]["aws_secret_access_key"] == "selected-secret" # noqa: S105 + assert explicit_calls[0]["region_name"] == "us-west-2" + assert os.environ["AWS_CONFIG_FILE"] == str(ambient / "config") + assert os.environ["AWS_ACCESS_KEY_ID"] == "ENVACCESS" + context.iam.mutation() + assert all(os.environ[key] == "https://attacker.invalid" for key in endpoint_keys) + + +def test_context_reports_missing_credentials_and_invalid_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _account_config(tmp_path, monkeypatch) + + class MissingSession: + region_name = None + + def get_credentials(self) -> None: + return None + + args = argparse.Namespace( + profile="missing", + location="default", + directory=str(tmp_path / "selected"), + target=None, + account=None, + region=None, + ) + with pytest.raises(_configs.OperationalError, match="has no credentials"): + _iam_cli.IamCommandContext.create( + args, session_factory=lambda **_kwargs: MissingSession() + ) + + class InvalidClient(_Client): + def get_caller_identity(self) -> dict[str, str]: + return {"Account": "invalid", "Arn": "also-invalid"} + + class InvalidSession(_Session): + def client(self, name: str) -> _Client: + return InvalidClient() if name == "sts" else _Client() + + with pytest.raises(_configs.OperationalError, match="GetCallerIdentity"): + _iam_cli.IamCommandContext.create( + args, session_factory=lambda **_kwargs: InvalidSession() + ) + + +def test_target_source_and_recovery_executor_dispatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _account_config(tmp_path, monkeypatch) + data = _state.load_config() + data["targets"]["Ops"] = { + "source_account": "Prod", + "source_profile": "operator", + "source_directory": str(tmp_path / "target-aws"), + "destination_location": "default", + "destination_profile": "default", + "boundary": None, + } + _state.save_config(data) + selector = _configs.CredentialSelector(target="+Ops") + directory, profile, account = _iam_cli._selected_source( + selector, argparse.Namespace(account=None) + ) + assert directory == (tmp_path / "target-aws").absolute() + assert profile == "operator" + assert account is not None + assert account["id"] == "123456789012" + + context = SimpleNamespace(account_id="123456789012") + monkeypatch.setattr(_iam_cli.IamCommandContext, "create", lambda _args: context) + monkeypatch.setattr( + _iam_recovery, + "get_journal", + lambda journal_id: {"id": journal_id, "serviceType": "role"}, + ) + monkeypatch.setattr( + _iam_recovery, + "continue_journal", + lambda journal_id, received: { + "id": journal_id, + "contextMatches": received is context, + }, + ) + monkeypatch.setattr( + _iam_recovery, + "rollback_journal", + lambda journal_id, received: { + "id": journal_id, + "contextMatches": received is context, + }, + ) + common = { + "journal_id": "journal", + "profile": "default", + "location": "default", + "directory": None, + "target": None, + "account": None, + "region": None, + } + continued = _iam_cli.recovery_result( + argparse.Namespace(recovery_action="continue", **common) + ) + rolled_back = _iam_cli.recovery_result( + argparse.Namespace(recovery_action="rollback", **common) + ) + assert continued.data == {"id": "journal", "contextMatches": True} + assert rolled_back.data == {"id": "journal", "contextMatches": True} + + +def test_dispatch_handles_missing_and_declining_adapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + args = argparse.Namespace(iam_action="policy", policy_action="list") + _iam_cli.clear_adapters() + assert _iam_cli.dispatch(args).code == "IAM_LEAF_HELP" + + class DecliningAdapter: + name = "policy" + + def register(self, _parser: argparse.ArgumentParser) -> None: + pass + + def dispatch( + self, _args: argparse.Namespace, _context: object + ) -> _configs.Result | None: + return None + + _iam_cli.register_adapter(DecliningAdapter()) + monkeypatch.setattr( + _iam_cli.IamCommandContext, + "create", + lambda _args: SimpleNamespace(account_id="123456789012"), + ) + assert _iam_cli.dispatch(args).code == "IAM_LEAF_HELP" + + +def test_selector_validation_covers_local_account_and_config_commands() -> None: + with pytest.raises(_configs.OperationalError, match="cannot be combined"): + _iam_cli.validate_selector_arguments( + [ + "account", + "add", + "Prod", + "123456789012", + "--target", + "prod", + "--profile", + "admin", + ] + ) + with pytest.raises(_configs.OperationalError, match="same AWS folder"): + _iam_cli.validate_selector_arguments( + [ + "config", + "check", + "--location", + "horizon", + "--directory", + "C:/aws", + ] + ) + + +def test_cleanup_account_mismatch_is_safety_refusal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail(_args: argparse.Namespace) -> None: + message = ( + "Selected IAM credentials identify aws:111111111111, but the selected " + "account requires aws:222222222222." + ) + raise _configs.OperationalError(message) + + monkeypatch.setattr(_iam_cli.IamCommandContext, "create", fail) + + result = _iam_cli.dispatch_root_cleanup(argparse.Namespace()) + + assert result.code == "IAM_CLEANUP_SAFETY_REFUSAL" + assert result.exit_code == 3 diff --git a/hacksaws/tests/test_iam_managed_policies.py b/hacksaws/tests/test_iam_managed_policies.py new file mode 100644 index 0000000..f4792b4 --- /dev/null +++ b/hacksaws/tests/test_iam_managed_policies.py @@ -0,0 +1,2064 @@ +"""Focused offline tests for the pure IAM managed-policy service layer.""" + +# ruff: noqa: D102, D107 + +from __future__ import annotations + +import json +from dataclasses import replace +from datetime import UTC +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from typing import cast +from unittest.mock import patch +from urllib.parse import quote + +import boto3 +import pytest +from botocore.exceptions import ClientError +from botocore.stub import Stubber + +from hacksaws import _configs +from hacksaws import _iam_managed_policies as managed +from hacksaws import _iam_policy_cli as policy_cli +from hacksaws import _iam_policy_documents as documents +from hacksaws import _iam_recovery + +ACCOUNT = "123456789012" +PARTITION = "aws" +ARN = f"arn:{PARTITION}:iam::{ACCOUNT}:policy/hacksaws/AgentRead" +ROLE_ARN = f"arn:{PARTITION}:iam::{ACCOUNT}:role/AgentSession" +NOW = datetime(2026, 8, 1, tzinfo=UTC) +POLICY: dict[str, documents.JsonValue] = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "logs:GetLogEvents", + "Resource": "*", + } + ], +} +CHANGED: dict[str, documents.JsonValue] = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:GetLogEvents", "logs:FilterLogEvents"], + "Resource": "*", + } + ], +} + + +class StatefulIam: + """Small stateful IAM fake exercising service concurrency semantics.""" + + def __init__(self, *, existing: bool = True) -> None: + self.exists = existing + self.default = "v1" + self.versions: dict[str, tuple[dict[str, documents.JsonValue], datetime]] = { + "v1": (POLICY, NOW) + } + self.tags: dict[str, str] = { + "hacksaws:managed-by": "hacksaws", + "hacksaws:resource-id": "resource-1", + "hacksaws:resource-kind": "managed-policy", + } + self.permission_users: dict[str, str] = {} + self.permission_groups: dict[str, str] = {} + self.permission_roles: dict[str, str] = {} + self.boundary_users: dict[str, str] = {} + self.boundary_roles: dict[str, str] = {} + self.user_identities: dict[str, str] = {} + self.group_identities: dict[str, str] = {} + self.role_identities: dict[str, str] = {} + self.calls: list[str] = [] + + def _metadata(self) -> dict[str, object]: + if not self.exists: + raise self._not_found() + return { + "Arn": ARN, + "PolicyId": "ANPA12345678901234567", + "PolicyName": "AgentRead", + "Path": "/hacksaws/", + "DefaultVersionId": self.default, + "AttachmentCount": len(self.permission_users) + + len(self.permission_groups) + + len(self.permission_roles), + "PermissionsBoundaryUsageCount": len(self.boundary_users) + + len(self.boundary_roles), + } + + @staticmethod + def _not_found() -> ClientError: + return ClientError( + {"Error": {"Code": "NoSuchEntity", "Message": "not found"}}, + "GetPolicy", + ) + + def list_policies(self, **kwargs: object) -> dict[str, object]: + scope = kwargs.get("Scope") + policies = [self._metadata()] if self.exists and scope != "AWS" else [] + return {"Policies": policies, "IsTruncated": False} + + def get_policy(self, **kwargs: object) -> dict[str, object]: + return {"Policy": self._metadata()} + + def get_policy_version(self, **kwargs: object) -> dict[str, object]: + version_id = cast("str", kwargs["VersionId"]) + document, created = self.versions[version_id] + return { + "PolicyVersion": { + "Document": document, + "VersionId": version_id, + "IsDefaultVersion": version_id == self.default, + "CreateDate": created, + } + } + + def list_policy_versions(self, **kwargs: object) -> dict[str, object]: + return { + "Versions": [ + { + "VersionId": version_id, + "IsDefaultVersion": version_id == self.default, + "CreateDate": created, + } + for version_id, (_, created) in self.versions.items() + ], + "IsTruncated": False, + } + + def create_policy(self, **kwargs: object) -> dict[str, object]: + self.calls.append("CreatePolicy") + self.exists = True + self.default = "v1" + self.versions = { + "v1": ( + json.loads(cast("str", kwargs["PolicyDocument"])), + NOW, + ) + } + self.tags = { + cast("str", item["Key"]): cast("str", item["Value"]) + for item in cast("list[dict[str, object]]", kwargs["Tags"]) + } + return {"Policy": self._metadata()} + + def create_policy_version(self, **kwargs: object) -> dict[str, object]: + self.calls.append("CreatePolicyVersion") + next_number = max(int(key[1:]) for key in self.versions) + 1 + version_id = f"v{next_number}" + self.versions[version_id] = ( + json.loads(cast("str", kwargs["PolicyDocument"])), + NOW, + ) + if kwargs.get("SetAsDefault") is True: + self.default = version_id + return { + "PolicyVersion": { + "VersionId": version_id, + "IsDefaultVersion": True, + "CreateDate": NOW, + } + } + + def set_default_policy_version(self, **kwargs: object) -> None: + self.calls.append("SetDefaultPolicyVersion") + self.default = cast("str", kwargs["VersionId"]) + + def delete_policy_version(self, **kwargs: object) -> None: + self.calls.append("DeletePolicyVersion") + del self.versions[cast("str", kwargs["VersionId"])] + + def list_policy_tags(self, **kwargs: object) -> dict[str, object]: + return { + "Tags": [{"Key": key, "Value": value} for key, value in self.tags.items()], + "IsTruncated": False, + } + + def tag_policy(self, **kwargs: object) -> None: + self.calls.append("TagPolicy") + for item in cast("list[dict[str, object]]", kwargs["Tags"]): + self.tags[cast("str", item["Key"])] = cast("str", item["Value"]) + + def untag_policy(self, **kwargs: object) -> None: + self.calls.append("UntagPolicy") + for key in cast("list[str]", kwargs["TagKeys"]): + self.tags.pop(key, None) + + def list_entities_for_policy(self, **kwargs: object) -> dict[str, object]: + boundary = kwargs.get("PolicyUsageFilter") == "PermissionsBoundary" + users = self.boundary_users if boundary else self.permission_users + roles = self.boundary_roles if boundary else self.permission_roles + groups = {} if boundary else self.permission_groups + return { + "PolicyUsers": [ + {"UserName": name, "UserId": entity_id} + for entity_id, name in users.items() + ], + "PolicyGroups": [ + {"GroupName": name, "GroupId": entity_id} + for entity_id, name in groups.items() + ], + "PolicyRoles": [ + {"RoleName": name, "RoleId": entity_id} + for entity_id, name in roles.items() + ], + "IsTruncated": False, + } + + def detach_user_policy(self, **kwargs: object) -> None: + self.calls.append("DetachUserPolicy") + name = cast("str", kwargs["UserName"]) + self.user_identities.update( + {value: key for key, value in self.permission_users.items()} + ) + self.permission_users = { + key: value for key, value in self.permission_users.items() if value != name + } + + def attach_user_policy(self, **kwargs: object) -> None: + self.calls.append("AttachUserPolicy") + name = cast("str", kwargs["UserName"]) + self.permission_users[self.user_identities[name]] = name + + def detach_group_policy(self, **kwargs: object) -> None: + self.calls.append("DetachGroupPolicy") + name = cast("str", kwargs["GroupName"]) + self.group_identities.update( + {value: key for key, value in self.permission_groups.items()} + ) + self.permission_groups = { + key: value for key, value in self.permission_groups.items() if value != name + } + + def attach_group_policy(self, **kwargs: object) -> None: + self.calls.append("AttachGroupPolicy") + name = cast("str", kwargs["GroupName"]) + self.permission_groups[self.group_identities[name]] = name + + def detach_role_policy(self, **kwargs: object) -> None: + self.calls.append("DetachRolePolicy") + name = cast("str", kwargs["RoleName"]) + self.role_identities.update( + {value: key for key, value in self.permission_roles.items()} + ) + self.permission_roles = { + key: value for key, value in self.permission_roles.items() if value != name + } + + def attach_role_policy(self, **kwargs: object) -> None: + self.calls.append("AttachRolePolicy") + name = cast("str", kwargs["RoleName"]) + self.permission_roles[self.role_identities[name]] = name + + def delete_user_permissions_boundary(self, **kwargs: object) -> None: + self.calls.append("DeleteUserPermissionsBoundary") + name = cast("str", kwargs["UserName"]) + self.user_identities.update( + {value: key for key, value in self.boundary_users.items()} + ) + self.boundary_users = { + key: value for key, value in self.boundary_users.items() if value != name + } + + def put_user_permissions_boundary(self, **kwargs: object) -> None: + self.calls.append("PutUserPermissionsBoundary") + name = cast("str", kwargs["UserName"]) + self.boundary_users[self.user_identities[name]] = name + + def delete_role_permissions_boundary(self, **kwargs: object) -> None: + self.calls.append("DeleteRolePermissionsBoundary") + name = cast("str", kwargs["RoleName"]) + self.role_identities.update( + {value: key for key, value in self.boundary_roles.items()} + ) + self.boundary_roles = { + key: value for key, value in self.boundary_roles.items() if value != name + } + + def put_role_permissions_boundary(self, **kwargs: object) -> None: + self.calls.append("PutRolePermissionsBoundary") + name = cast("str", kwargs["RoleName"]) + self.boundary_roles[self.role_identities[name]] = name + + def get_user(self, **kwargs: object) -> dict[str, object]: + name = cast("str", kwargs["UserName"]) + return {"User": {"UserName": name, "UserId": self.user_identities[name]}} + + def get_group(self, **kwargs: object) -> dict[str, object]: + name = cast("str", kwargs["GroupName"]) + return { + "Group": {"GroupName": name, "GroupId": self.group_identities[name]}, + "Users": [], + "IsTruncated": False, + } + + def get_role(self, **kwargs: object) -> dict[str, object]: + name = cast("str", kwargs["RoleName"]) + return {"Role": {"RoleName": name, "RoleId": self.role_identities[name]}} + + def delete_policy(self, **kwargs: object) -> None: + self.calls.append("DeletePolicy") + self.exists = False + + +class FakeSts: + """STS fake that records probes but never retains credentials.""" + + def __init__(self) -> None: + self.assume_request: dict[str, object] | None = None + self.assume_error: ClientError | None = None + + def get_caller_identity(self, **kwargs: object) -> dict[str, object]: + return { + "Account": ACCOUNT, + "Arn": f"arn:aws:iam::{ACCOUNT}:user/tester", + "UserId": "AIDA12345678901234567", + } + + def assume_role(self, **kwargs: object) -> dict[str, object]: + self.assume_request = dict(kwargs) + if self.assume_error is not None: + raise self.assume_error + return { + "AssumedRoleUser": { + "Arn": f"arn:aws:sts::{ACCOUNT}:assumed-role/AgentSession/probe", + "AssumedRoleId": "AROA12345678901234567:probe", + }, + "Credentials": { + "AccessKeyId": "ASIAEXAMPLE", + "SecretAccessKey": "secret", + "SessionToken": "token", + "Expiration": NOW, + }, + "PackedPolicySize": 85, + } + + +def make_service( + iam: StatefulIam, + sts: FakeSts | None = None, + *, + retry: managed.RetryPolicy | None = None, +) -> managed.IamManagedPolicyService: + return managed.IamManagedPolicyService( + iam, + sts or FakeSts(), + None, + managed.PolicyServiceOptions( + ACCOUNT, + PARTITION, + retry=retry or managed.RetryPolicy((0.0,)), + ), + sleeper=lambda _delay: None, + ) + + +def test_strict_policy_input_formats_metadata_and_canonicalization( + tmp_path: Path, +) -> None: + nested = tmp_path / "policy.yaml" + nested.write_text( + "metadata:\n name: Read\n tags:\n Team: Agents\n" + "policy:\n Version: '2012-10-17'\n Statement: []\n", + encoding="utf-8", + ) + loaded = documents.load_policy_input( + nested, + metadata_mode=documents.MetadataMode.NESTED, + ) + assert loaded.metadata.name == "Read" + assert loaded.metadata.tags == (("Team", "Agents"),) + assert loaded.canonical_json == '{"Statement":[],"Version":"2012-10-17"}' + + policy = tmp_path / "plain.toml" + policy.write_text('Version="2012-10-17"\nStatement=[]\n', encoding="utf-8") + sidecar = tmp_path / "meta.json" + sidecar.write_text('{"description":"read only"}', encoding="utf-8") + loaded = documents.load_policy_input( + policy, + metadata_mode=documents.MetadataMode.SIDECAR, + sidecar=sidecar, + ) + assert loaded.metadata.description == "read only" + assert loaded.sidecar == sidecar + + +@pytest.mark.parametrize( + ("suffix", "contents", "match"), + [ + (".json", '{"Version":"x","Version":"y"}', "Duplicate JSON"), + (".yaml", "Version: x\nVersion: y\n", "Duplicate YAML"), + (".toml", "Version=nan\nStatement=[]\n", "Non-finite"), + ], +) +def test_strict_policy_loader_rejects_lossy_inputs( + tmp_path: Path, + suffix: str, + contents: str, + match: str, +) -> None: + path = tmp_path / f"bad{suffix}" + path.write_text(contents, encoding="utf-8") + with pytest.raises(documents.PolicyInputError, match=match): + documents.load_policy_input(path) + + +def test_iam_document_decode_and_semantic_compare() -> None: + encoded = quote(json.dumps(POLICY)) + assert documents.decode_iam_document(encoded) == POLICY + reordered = {"Statement": POLICY["Statement"], "Version": "2012-10-17"} + assert documents.policy_digest(reordered) == documents.policy_digest(POLICY) + + +def test_resolution_namespaces_ambiguity_and_exact_account_checks() -> None: + iam = StatefulIam() + service = make_service(iam) + assert service.resolve("custom:AgentRead").selected is not None + assert service.resolve("owned:AgentRead").selected is not None + + original_list = iam.list_policies + + def list_with_aws(**kwargs: object) -> dict[str, object]: + result = original_list(**kwargs) + if kwargs.get("Scope") == "AWS": + policy = dict(iam._metadata()) + policy["Arn"] = "arn:aws:iam::aws:policy/AgentRead" + policy["Path"] = "/" + result["Policies"] = [policy] + return result + + iam.list_policies = list_with_aws # type: ignore[method-assign] + ambiguous = service.resolve("AgentRead") + assert ambiguous.ambiguous + assert ambiguous.selected is None + with pytest.raises(managed.PolicyServiceError, match="account"): + service.resolve("arn:aws:iam::999999999999:policy/AgentRead") + + +def test_list_policies_uses_marker_pagination_with_stubber() -> None: + client = boto3.client( + "iam", + region_name="us-east-1", + aws_access_key_id="test", + aws_secret_access_key="test", # noqa: S106 + ) + first = { + "Policies": [], + "IsTruncated": True, + "Marker": "next", + } + second = { + "Policies": [ + { + "PolicyName": "AgentRead", + "PolicyId": "ANPA12345678901234567", + "Arn": ARN, + "Path": "/hacksaws/", + "DefaultVersionId": "v1", + "AttachmentCount": 0, + "PermissionsBoundaryUsageCount": 0, + "IsAttachable": True, + "CreateDate": NOW, + "UpdateDate": NOW, + } + ], + "IsTruncated": False, + } + with Stubber(client) as stubber: + stubber.add_response("list_policies", first, {"Scope": "Local"}) + stubber.add_response( + "list_policies", + second, + {"Scope": "Local", "Marker": "next"}, + ) + service = managed.IamManagedPolicyService( + client, + FakeSts(), + None, + managed.PolicyServiceOptions(ACCOUNT, PARTITION), + ) + records = service.list_policies(scope=managed.PolicyScope.LOCAL) + assert [item.arn.value for item in records] == [ARN] + + +def test_validation_aggregates_local_and_paginated_aws_findings() -> None: + class Analyzer: + def validate_policy(self, **kwargs: object) -> dict[str, object]: + if "nextToken" not in kwargs: + return { + "findings": [ + { + "findingType": "ERROR", + "issueCode": "AWS_ERROR", + "findingDetails": "bad action", + } + ], + "nextToken": "next", + } + return { + "findings": [ + { + "findingType": "SUGGESTION", + "issueCode": "AWS_HINT", + "findingDetails": "consider scope", + } + ] + } + + iam = StatefulIam() + service = managed.IamManagedPolicyService( + iam, + FakeSts(), + Analyzer(), + managed.PolicyServiceOptions(ACCOUNT, PARTITION), + ) + bad: dict[str, documents.JsonValue] = {"Statement": "bad"} + report = service.validate_policy( + bad, + name="not valid!", + path="bad", + tags=(managed.Tag("aws:bad", "x"),), + ) + codes = {item.code for item in report.diagnostics} + assert { + "INVALID_POLICY_NAME", + "INVALID_POLICY_PATH", + "INVALID_STATEMENT", + "RESERVED_AWS_TAG_PREFIX", + "AWS_ERROR", + "AWS_HINT", + } <= codes + assert not report.valid + assert report.repairs + + +def test_caller_attribution_and_repeat_user_tags() -> None: + iam = StatefulIam() + service = make_service(iam) + tags = service.ownership_tags( + "abc", + (managed.Tag("Team", "Agents"), managed.Tag("Purpose", "Debug")), + created_at=NOW, + ) + values = {tag.key: tag.value for tag in tags} + assert values["Team"] == "Agents" + assert values["hacksaws:created-by"].endswith(":user/tester") + assert values["hacksaws:ownership-origin"] == "created" + with pytest.raises(managed.PolicyServiceError, match="reserved"): + service.ownership_tags( + "abc", + (managed.Tag("HACKSAWS:MANAGED-BY", "other"),), + ) + + +def test_create_and_noop_publish() -> None: + iam = StatefulIam(existing=False) + service = make_service(iam) + plan = service.plan_create( + "AgentRead", + POLICY, + options=managed.CreatePolicyOptions( + resource_id="fixed", + include_aws_validation=False, + ), + ) + result = service.execute_change(plan) + assert result.action is managed.ChangeAction.CREATE + assert result.policy.document == POLICY + assert "CreatePolicy" in iam.calls + + noop = service.plan_publish(ARN, POLICY, include_aws_validation=False) + assert noop.operation.action is managed.ChangeAction.NOOP + assert service.execute_change(noop).action is managed.ChangeAction.NOOP + + +def test_five_version_update_prunes_oldest_nondefault_and_retains_rollback() -> None: + iam = StatefulIam() + iam.versions = { + f"v{number}": (POLICY, NOW.replace(day=number)) for number in range(1, 6) + } + iam.default = "v5" + service = make_service(iam) + plan = service.plan_publish(ARN, CHANGED, include_aws_validation=False) + assert plan.prune_version_id == "v1" + assert plan.expected_default_version_id == "v5" + result = service.execute_change(plan) + assert result.action is managed.ChangeAction.UPDATE + assert iam.default == "v6" + assert "v1" not in iam.versions + assert "v5" in iam.versions + assert iam.calls[:2] == ["DeletePolicyVersion", "CreatePolicyVersion"] + + +def test_unowned_five_version_policy_refuses_automatic_pruning() -> None: + iam = StatefulIam() + iam.tags = {} + iam.versions = { + f"v{number}": (POLICY, NOW.replace(day=number)) for number in range(1, 6) + } + iam.default = "v5" + service = make_service(iam) + plan = service.plan_publish(ARN, CHANGED, include_aws_validation=False) + assert not plan.validation.valid + with pytest.raises(managed.PolicyValidationError): + service.execute_change(plan) + + +def test_publish_detects_default_document_drift() -> None: + iam = StatefulIam() + service = make_service(iam) + plan = service.plan_publish(ARN, CHANGED, include_aws_validation=False) + iam.versions["v1"] = (CHANGED, NOW) + with pytest.raises(managed.PolicyDriftError): + service.execute_change(plan) + assert "CreatePolicyVersion" not in iam.calls + + +def test_rollback_switches_default_and_keeps_versions() -> None: + iam = StatefulIam() + iam.versions["v2"] = (CHANGED, NOW.replace(day=2)) + iam.default = "v2" + service = make_service(iam) + plan = service.plan_rollback(ARN, "v1") + result = service.execute_change(plan) + assert result.action is managed.ChangeAction.ROLLBACK + assert iam.default == "v1" + assert set(iam.versions) == {"v1", "v2"} + + +def test_adopt_release_and_tag_drift() -> None: + iam = StatefulIam() + iam.tags = {"Existing": "yes"} + service = make_service(iam) + adopt = service.plan_adopt( + ARN, + "adopted", + user_tags=(managed.Tag("Team", "Agents"),), + ) + result = service.execute_tag_change(adopt) + assert result.policy is not None + assert result.policy.owned + release = service.plan_release(ARN) + iam.tags["drift"] = "true" + with pytest.raises(managed.PolicyDriftError): + service.execute_tag_change(release) + del iam.tags["drift"] + released = service.execute_tag_change(release) + assert released.policy is not None + assert not released.policy.owned + assert iam.tags["Existing"] == "yes" + + +def test_dependency_complete_delete_requires_cascade_then_executes() -> None: + iam = StatefulIam() + iam.permission_users = {"U1": "alice"} + iam.permission_groups = {"G1": "agents"} + iam.permission_roles = {"R1": "reader"} + iam.boundary_users = {"U2": "bob"} + iam.boundary_roles = {"R2": "bounded"} + iam.versions["v2"] = (CHANGED, NOW.replace(day=2)) + service = make_service(iam) + blocked = service.plan_delete(ARN) + assert not blocked.executable + with pytest.raises(managed.PolicyValidationError): + service.execute_delete(blocked) + + plan = service.plan_delete(ARN, cascade=True) + assert plan.executable + assert { + "DetachUserPolicy", + "DetachGroupPolicy", + "DetachRolePolicy", + "DeleteUserPermissionsBoundary", + "DeleteRolePermissionsBoundary", + "DeletePolicyVersion", + "DeletePolicy", + } <= {step.operation for step in plan.operation.steps} + result = service.execute_delete(plan) + assert result.policy is None + assert not iam.exists + + +def test_aws_managed_policy_is_immutable() -> None: + iam = StatefulIam() + service = make_service(iam) + customer = service.get_policy(ARN) + aws_policy = managed.ManagedPolicyRecord( + arn=managed.ManagedPolicyArn.parse("arn:aws:iam::aws:policy/ReadOnlyAccess"), + policy_id=customer.policy_id, + name="ReadOnlyAccess", + path="/", + default_version_id="v1", + attachment_count=0, + permissions_boundary_usage_count=0, + ) + with pytest.raises(managed.ImmutablePolicyError): + service._require_mutable(aws_policy) + + +def test_assume_role_probe_returns_no_credentials_and_warns() -> None: + iam = StatefulIam() + sts = FakeSts() + service = make_service(iam, sts) + result = service.probe_assume_role(ROLE_ARN, POLICY) + assert result.packed_policy_size == 85 + assert result.warning is not None + assert not hasattr(result, "credentials") + assert sts.assume_request is not None + assert json.loads(cast("str", sts.assume_request["Policy"])) == POLICY + + +def test_packed_policy_failure_is_actionable() -> None: + iam = StatefulIam() + sts = FakeSts() + sts.assume_error = ClientError( + { + "Error": { + "Code": "PackedPolicyTooLarge", + "Message": "PackedPolicySize exceeded 104% of the allowed space", + } + }, + "AssumeRole", + ) + service = make_service(iam, sts) + with pytest.raises(managed.PackedPolicyProbeError) as captured: + service.probe_assume_role(ROLE_ARN, POLICY) + assert captured.value.diagnostic.packed_policy_size == 104 + assert len(captured.value.diagnostic.repairs) == 3 + + +def test_bounded_eventual_consistency_retries_no_such_entity() -> None: + class EventuallyVisibleIam(StatefulIam): + def __init__(self) -> None: + super().__init__(existing=False) + self.remaining_failures = 0 + + def create_policy(self, **kwargs: object) -> dict[str, object]: + response = super().create_policy(**kwargs) + self.remaining_failures = 1 + return response + + def get_policy(self, **kwargs: object) -> dict[str, object]: + if self.remaining_failures: + self.remaining_failures -= 1 + raise self._not_found() + return super().get_policy(**kwargs) + + iam = EventuallyVisibleIam() + delays: list[float] = [] + service = managed.IamManagedPolicyService( + iam, + FakeSts(), + None, + managed.PolicyServiceOptions( + ACCOUNT, + PARTITION, + retry=managed.RetryPolicy((0.0, 0.5)), + ), + sleeper=delays.append, + ) + plan = service.plan_create( + "AgentRead", + POLICY, + options=managed.CreatePolicyOptions(include_aws_validation=False), + ) + assert service.execute_change(plan).policy.document == POLICY + assert delays == [0.5] + + +def test_policy_document_validation_edge_cases(tmp_path: Path) -> None: + with pytest.raises(documents.PolicyInputError, match="Unsupported"): + documents.PolicyFormat.from_path(tmp_path / "policy.txt") + with pytest.raises(documents.PolicyInputError, match="must be an object"): + documents.decode_iam_document("[]") + with pytest.raises(documents.PolicyInputError, match="invalid policy"): + documents.decode_iam_document("%7Bbad") + assert documents.decode_iam_document({"Version": "x"}) == {"Version": "x"} + + source = tmp_path / "source.json" + source.write_text(json.dumps(POLICY), encoding="utf-8") + assert documents.load_policy_input(source).digest == documents.policy_digest(POLICY) + with pytest.raises(documents.PolicyInputError, match="Unable to read"): + documents.load_policy_input(tmp_path / "missing.json") + + +@pytest.mark.parametrize( + ("metadata", "match"), + [ + ({"unknown": "x"}, "Unknown policy metadata"), + ({"name": 1}, "must be a string"), + ({"tags": {"Team": 1}}, "string value"), + ({"tags": "bad"}, "key/value object list"), + ({"tags": ["bad"]}, "must be an object"), + ({"tags": [{"key": 1}]}, "requires string"), + ], +) +def test_nested_metadata_validation_failures( + tmp_path: Path, + metadata: object, + match: str, +) -> None: + path = tmp_path / "nested.json" + path.write_text( + json.dumps({"metadata": metadata, "policy": POLICY}), + encoding="utf-8", + ) + with pytest.raises(documents.PolicyInputError, match=match): + documents.load_policy_input(path, metadata_mode=documents.MetadataMode.NESTED) + + +def test_nested_and_sidecar_structural_failures(tmp_path: Path) -> None: + nested = tmp_path / "nested.json" + nested.write_text('{"extra":true,"policy":{}}', encoding="utf-8") + with pytest.raises(documents.PolicyInputError, match="Unknown nested"): + documents.load_policy_input(nested, metadata_mode=documents.MetadataMode.NESTED) + nested.write_text('{"metadata":{}}', encoding="utf-8") + with pytest.raises(documents.PolicyInputError, match="requires a 'policy'"): + documents.load_policy_input(nested, metadata_mode=documents.MetadataMode.NESTED) + + plain = tmp_path / "plain.yaml" + plain.write_text("Version: x\nStatement: []\n", encoding="utf-8") + with pytest.raises(documents.PolicyInputError, match="metadata sidecar"): + documents.load_policy_input( + plain, + metadata_mode=documents.MetadataMode.SIDECAR, + ) + + +def test_yaml_and_json_value_strict_failures(tmp_path: Path) -> None: + malformed = tmp_path / "malformed.json" + malformed.write_text("{", encoding="utf-8") + with pytest.raises(documents.PolicyInputError, match="Invalid JSON"): + documents.load_policy_input(malformed) + + non_string_key = tmp_path / "key.yaml" + non_string_key.write_text("1: value\n", encoding="utf-8") + with pytest.raises(documents.PolicyInputError, match="must be a string"): + documents.load_policy_input(non_string_key) + + unhashable_key = tmp_path / "unhashable.yaml" + unhashable_key.write_text("? [one, two]\n: value\n", encoding="utf-8") + with pytest.raises(documents.PolicyInputError, match="hashable"): + documents.load_policy_input(unhashable_key) + + with pytest.raises(documents.PolicyInputError, match="Unsupported value"): + documents._json_value(object()) + + +def aws_error(code: str = "AccessDenied", operation: str = "Operation") -> ClientError: + return ClientError( + {"Error": {"Code": code, "Message": f"{code} message"}}, + operation, + ) + + +def test_models_helpers_and_packed_policy_non_errors() -> None: + root = managed.ManagedPolicyArn.parse(f"arn:aws:iam::{ACCOUNT}:policy/RootPolicy") + assert root.name == "RootPolicy" + assert root.path == "/" + assert root.kind is managed.PolicyKind.CUSTOMER_MANAGED + aws = managed.ManagedPolicyArn.parse("arn:aws:iam::aws:policy/ReadOnlyAccess") + assert aws.kind is managed.PolicyKind.AWS_MANAGED + with pytest.raises(managed.PolicyServiceError, match="Invalid IAM"): + managed.ManagedPolicyArn.parse("bad") + + tag = managed.Tag("Team", "Agents") + assert tag.as_request() == {"Key": "Team", "Value": "Agents"} + empty = managed.ResolutionResult("missing", ()) + assert not empty.ambiguous + assert empty.selected is None + dependencies = managed.PolicyDependencies() + assert dependencies.empty + assert managed.packed_policy_warning(None) is None + assert managed.packed_policy_warning(10) is None + assert managed.parse_packed_policy_diagnostic(aws_error()) is None + + report = managed.ValidationReport( + ( + managed.ValidationDiagnostic( + managed.DiagnosticSeverity.WARNING, + "WARN", + "warning", + ), + ) + ) + assert report.valid + assert not report.repairs + assert len(report.merge(report).diagnostics) == 2 + journal = managed.OperationJournal("plan", []) + journal.record("step", managed.StepState.COMPENSATED, "done") + assert journal.entries[0].detail == "done" + + +@pytest.mark.parametrize( + ("value", "helper", "match"), + [ + ([], managed._mapping, "not an object"), + ("bad", managed._items, "not a list"), + (1, managed._string, "not a string"), + ], +) +def test_response_shape_helpers_reject_invalid_fields( + value: object, + helper: object, + match: str, +) -> None: + callable_helper = cast("object", helper) + with pytest.raises(managed.PolicyServiceError, match=match): + callable_helper(value, label="field") # type: ignore[operator] + + +def test_service_options_and_caller_identity_validation() -> None: + iam = StatefulIam() + with pytest.raises(managed.PolicyServiceError, match="account ID"): + managed.IamManagedPolicyService( + iam, + FakeSts(), + None, + managed.PolicyServiceOptions("bad", PARTITION), + ) + with pytest.raises(managed.PolicyServiceError, match="partition"): + managed.IamManagedPolicyService( + iam, + FakeSts(), + None, + managed.PolicyServiceOptions(ACCOUNT, "bad"), + ) + with pytest.raises(managed.PolicyServiceError, match="begin and end"): + managed.IamManagedPolicyService( + iam, + FakeSts(), + None, + managed.PolicyServiceOptions(ACCOUNT, PARTITION, "bad"), + ) + + sts = FakeSts() + service = make_service(iam, sts) + sts.get_caller_identity = lambda **_kwargs: { # type: ignore[method-assign] + "Account": ACCOUNT, + "Arn": "malformed", + "UserId": "id", + } + with pytest.raises(managed.PolicyServiceError, match="malformed"): + service.caller_identity() + sts.get_caller_identity = lambda **_kwargs: { # type: ignore[method-assign] + "Account": "999999999999", + "Arn": "arn:aws:iam::999999999999:user/test", + "UserId": "id", + } + with pytest.raises(managed.PolicyServiceError, match="expected"): + service.caller_identity() + + +def test_validation_all_tag_and_size_diagnostics() -> None: + service = make_service(StatefulIam()) + oversized: dict[str, documents.JsonValue] = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "x" * 6_200}], + } + tags = [managed.Tag(f"k{index}", "v") for index in range(51)] + tags.extend( + ( + managed.Tag("", "v"), + managed.Tag("long", "v" * 257), + managed.Tag("duplicate", "one"), + managed.Tag("duplicate", "two"), + ) + ) + report = service.validate_policy( + oversized, + tags=tags, + include_aws=False, + ) + codes = {item.code for item in report.diagnostics} + assert { + "POLICY_SIZE_EXCEEDED", + "TAG_LIMIT_EXCEEDED", + "INVALID_TAG_KEY", + "INVALID_TAG_VALUE", + "DUPLICATE_TAG_KEY", + } <= codes + empty_report = service.validate_policy( + {"Version": "2012-10-17", "Statement": []}, + include_aws=False, + ) + assert "EMPTY_STATEMENT" in {item.code for item in empty_report.diagnostics} + + +def test_long_caller_attribution_hash_and_tag_validation_failure() -> None: + service = make_service(StatefulIam()) + caller = managed.CallerIdentity( + ACCOUNT, + PARTITION, + "arn:aws:iam::" + "x" * 300, + "principal", + ) + tags = service.ownership_tags("id", caller=caller, created_at=NOW) + assert {tag.key: tag.value for tag in tags}["hacksaws:created-by"].startswith( + "sha256:" + ) + with pytest.raises(managed.PolicyValidationError): + service.ownership_tags("x" * 300, caller=caller) + + +def test_access_analyzer_resource_type_and_missing_finding_fields() -> None: + class Analyzer: + def __init__(self) -> None: + self.request: dict[str, object] = {} + + def validate_policy(self, **kwargs: object) -> dict[str, object]: + self.request = dict(kwargs) + return {"findings": [{}]} + + analyzer = Analyzer() + validator = managed.AccessAnalyzerPolicyValidator(analyzer) + report = validator.validate( + POLICY, + policy_type="RESOURCE_POLICY", + resource_type="AWS::IAM::AssumeRolePolicyDocument", + ) + assert analyzer.request["validatePolicyResourceType"] == ( + "AWS::IAM::AssumeRolePolicyDocument" + ) + assert report.diagnostics[0].severity is managed.DiagnosticSeverity.WARNING + + +def test_resolution_not_found_invalid_namespace_and_partition() -> None: + iam = StatefulIam(existing=False) + service = make_service(iam) + assert service.resolve("custom:missing").candidates == () + assert service.resolve("weird:name").candidates == () + with pytest.raises(managed.PolicyServiceError, match="cannot be empty"): + service.resolve("owned:") + with pytest.raises(managed.PolicyServiceError, match="partition"): + service.resolve(f"arn:aws-cn:iam::{ACCOUNT}:policy/Test") + with pytest.raises(managed.PolicyServiceError, match="not found"): + service.get_policy("missing") + + +def test_get_policy_reports_ambiguity() -> None: + iam = StatefulIam() + service = make_service(iam) + original = iam.list_policies + + def list_both(**kwargs: object) -> dict[str, object]: + result = original(**kwargs) + if kwargs.get("Scope") == "AWS": + item = dict(iam._metadata()) + item["Arn"] = "arn:aws:iam::aws:policy/AgentRead" + item["Path"] = "/" + result["Policies"] = [item] + return result + + iam.list_policies = list_both # type: ignore[method-assign] + with pytest.raises(managed.PolicyServiceError, match="ambiguous"): + service.get_policy("AgentRead") + + +def test_tag_version_and_entity_marker_pagination() -> None: + iam = StatefulIam() + service = make_service(iam) + tag_calls = 0 + original_tags = iam.list_policy_tags + + def paged_tags(**kwargs: object) -> dict[str, object]: + nonlocal tag_calls + tag_calls += 1 + if tag_calls == 1: + return { + "Tags": [{"Key": "first", "Value": "1"}], + "IsTruncated": True, + "Marker": "next", + } + return original_tags(**kwargs) + + iam.list_policy_tags = paged_tags # type: ignore[method-assign] + record = service.get_policy(ARN, include_document=False) + assert any(tag.key == "first" for tag in record.tags) + + version_calls = 0 + original_versions = iam.list_policy_versions + + def paged_versions(**kwargs: object) -> dict[str, object]: + nonlocal version_calls + version_calls += 1 + if version_calls == 1: + return {"Versions": [], "IsTruncated": True, "Marker": "next"} + return original_versions(**kwargs) + + iam.list_policy_versions = paged_versions # type: ignore[method-assign] + assert service.export_policy(ARN, include_all_versions=True).versions + + entity_calls = 0 + original_entities = iam.list_entities_for_policy + + def paged_entities(**kwargs: object) -> dict[str, object]: + nonlocal entity_calls + entity_calls += 1 + if entity_calls == 1: + return { + "PolicyUsers": [], + "PolicyGroups": [], + "PolicyRoles": [], + "IsTruncated": True, + "Marker": "next", + } + return original_entities(**kwargs) + + iam.list_entities_for_policy = paged_entities # type: ignore[method-assign] + assert service.policy_dependencies(ARN).empty + + +def test_arn_resolution_not_found_error_and_owned_filter() -> None: + missing = make_service(StatefulIam(existing=False)) + assert missing.resolve(ARN).candidates == () + + iam = StatefulIam() + service = make_service(iam) + iam.tags = {} + assert service.resolve("owned:AgentRead").candidates == () + iam.get_policy = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[method-assign] + aws_error() + ) + with pytest.raises(ClientError): + service.resolve(ARN) + + +def test_read_policy_rejects_mismatched_returned_arn() -> None: + iam = StatefulIam() + service = make_service(iam) + original = iam.get_policy + + def mismatched(**kwargs: object) -> dict[str, object]: + response = original(**kwargs) + policy = cast("dict[str, object]", response["Policy"]) + policy["Arn"] = f"arn:aws:iam::{ACCOUNT}:policy/hacksaws/Other" + return response + + iam.get_policy = mismatched # type: ignore[method-assign] + with pytest.raises(managed.PolicyServiceError, match="different policy ARN"): + service.resolve(ARN) + + +def test_create_description_and_create_failure() -> None: + iam = StatefulIam(existing=False) + service = make_service(iam) + plan = service.plan_create( + "AgentRead", + POLICY, + options=managed.CreatePolicyOptions( + description="read logs", + include_aws_validation=False, + ), + ) + assert plan.description == "read logs" + compensation = plan.operation.steps[0].compensation + assert compensation is not None + assert compensation.parameters["PolicyArn"] == ARN + iam.create_policy = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[method-assign] + aws_error(operation="CreatePolicy") + ) + with pytest.raises(ClientError): + service.execute_change(plan) + + +def test_aws_managed_get_skips_tags_and_publish_is_immutable() -> None: + class AwsManagedIam(StatefulIam): + def _metadata(self) -> dict[str, object]: + metadata = super()._metadata() + metadata["Arn"] = "arn:aws:iam::aws:policy/ReadOnlyAccess" + metadata["PolicyName"] = "ReadOnlyAccess" + metadata["Path"] = "/" + return metadata + + def list_policy_tags(self, **kwargs: object) -> dict[str, object]: + pytest.fail("AWS-managed policy tags must not be requested") + + iam = AwsManagedIam() + service = make_service(iam) + arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" + policy = service.get_policy(arn) + assert policy.tags == () + with pytest.raises(managed.ImmutablePolicyError): + service.plan_publish(arn, CHANGED, include_aws_validation=False) + + +def test_plan_guards_missing_documents_versions_and_arns() -> None: + iam = StatefulIam() + service = make_service(iam) + record = service.get_policy(ARN) + missing_document = replace(record, document=None) + with ( + patch.object(service, "get_policy", return_value=missing_document), + pytest.raises(managed.PolicyServiceError, match="document was not loaded"), + ): + service.plan_publish(ARN, CHANGED, include_aws_validation=False) + with pytest.raises(managed.PolicyServiceError, match="does not exist"): + service.plan_rollback(ARN, "v99") + + create = service.plan_create( + "Other", + POLICY, + options=managed.CreatePolicyOptions(include_aws_validation=False), + ) + invalid = replace( + create, + operation=replace(create.operation, action=managed.ChangeAction.UPDATE), + ) + with pytest.raises(managed.PolicyServiceError, match="requires an ARN"): + service.execute_change(invalid) + + +def test_update_and_rollback_client_failures() -> None: + iam = StatefulIam() + service = make_service(iam) + update = service.plan_publish(ARN, CHANGED, include_aws_validation=False) + iam.create_policy_version = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[method-assign] + aws_error(operation="CreatePolicyVersion") + ) + with pytest.raises(ClientError): + service.execute_change(update) + + iam = StatefulIam() + iam.versions["v2"] = (CHANGED, NOW) + iam.default = "v2" + service = make_service(iam) + rollback = service.plan_rollback(ARN, "v1") + iam.set_default_policy_version = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[method-assign] + aws_error(operation="SetDefaultPolicyVersion") + ) + with pytest.raises(ClientError): + service.execute_change(rollback) + + +def test_prune_client_failure_prevents_publish() -> None: + iam = StatefulIam() + iam.versions = { + f"v{number}": (POLICY, NOW.replace(day=number)) for number in range(1, 6) + } + iam.default = "v5" + service = make_service(iam) + plan = service.plan_publish(ARN, CHANGED, include_aws_validation=False) + iam.delete_policy_version = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[method-assign] + aws_error(operation="DeletePolicyVersion") + ) + with pytest.raises(ClientError): + service.execute_change(plan) + assert "CreatePolicyVersion" not in iam.calls + + +def test_bounded_verification_reports_stale_and_non_retryable_errors() -> None: + iam = StatefulIam() + service = make_service(iam) + with pytest.raises(managed.PolicyServiceError, match="bounded propagation"): + service._verify_policy( + managed.ManagedPolicyArn.parse(ARN), + expected_version="v99", + expected_digest="bad", + ) + iam.get_policy = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[method-assign] + aws_error() + ) + with pytest.raises(ClientError): + service._verify_policy( + managed.ManagedPolicyArn.parse(ARN), + expected_version="v1", + expected_digest=documents.policy_digest(POLICY), + ) + + +def test_export_missing_document_and_adoption_conflict() -> None: + iam = StatefulIam() + service = make_service(iam) + record = service.get_policy(ARN) + with ( + patch.object( + service, + "get_policy", + return_value=replace(record, document=None), + ), + pytest.raises(managed.PolicyServiceError, match="no active document"), + ): + service.export_policy(ARN) + iam.tags["hacksaws:managed-by"] = "another-tool" + with pytest.raises(managed.PolicyServiceError, match="already managed"): + service.plan_adopt(ARN, "id") + + +def test_release_noop_and_tag_client_failure() -> None: + iam = StatefulIam() + iam.tags = {"Existing": "yes"} + service = make_service(iam) + release = service.plan_release(ARN) + assert service.execute_tag_change(release).journal.entries == [] + + adopt = service.plan_adopt(ARN, "id") + assert {tag.key: tag.value for tag in adopt.add}[ + "hacksaws:ownership-origin" + ] == "adopted" + iam.tag_policy = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[method-assign] + aws_error(operation="TagPolicy") + ) + with pytest.raises(ClientError): + service.execute_tag_change(adopt) + + +def test_delete_warns_for_unowned_detects_drift_and_client_failure() -> None: + iam = StatefulIam() + iam.tags = {} + service = make_service(iam) + plan = service.plan_delete(ARN, cascade=True) + assert any("ownership" in warning for warning in plan.operation.warnings) + iam.versions["v2"] = (CHANGED, NOW) + with pytest.raises(managed.PolicyDriftError): + service.execute_delete(plan) + + iam = StatefulIam() + service = make_service(iam) + plan = service.plan_delete(ARN, cascade=True) + iam.delete_policy = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[method-assign] + aws_error(operation="DeletePolicy") + ) + with pytest.raises(ClientError): + service.execute_delete(plan) + unknown = managed.OperationStep("x", "Unknown", {}) + with pytest.raises(managed.PolicyServiceError, match="Unsupported deletion"): + service._execute_delete_step(unknown) + + +def test_probe_options_validation_and_nonpacked_failure() -> None: + iam = StatefulIam() + sts = FakeSts() + service = make_service(iam, sts) + with pytest.raises(managed.PolicyServiceError, match="Invalid IAM role"): + service.probe_assume_role("bad", POLICY) + with pytest.raises(managed.PolicyServiceError, match="does not match"): + service.probe_assume_role("arn:aws:iam::999999999999:role/AgentSession", POLICY) + + result = service.probe_assume_role( + ROLE_ARN, + POLICY, + options=managed.AssumeRoleProbeOptions( + external_id="external", + source_identity="tester", + session_tags=(managed.Tag("Team", "Agents"),), + ), + ) + assert result.warning is not None + assert sts.assume_request is not None + assert sts.assume_request["ExternalId"] == "external" + assert sts.assume_request["SourceIdentity"] == "tester" + assert sts.assume_request["Tags"] == [{"Key": "Team", "Value": "Agents"}] + + sts.assume_error = aws_error(operation="AssumeRole") + with pytest.raises(ClientError): + service.probe_assume_role(ROLE_ARN, POLICY) + + +def test_probe_without_packed_size_or_expiration() -> None: + class MinimalSts(FakeSts): + def assume_role(self, **kwargs: object) -> dict[str, object]: + return { + "AssumedRoleUser": {"Arn": "arn:aws:sts::x:assumed-role/x/y"}, + "Credentials": {}, + } + + service = make_service(StatefulIam(), MinimalSts()) + result = service.probe_assume_role(ROLE_ARN, POLICY) + assert result.packed_policy_size is None + assert result.expires_at is None + assert result.warning is None + + +def test_policy_recovery_delete_commit_point_is_irreversible_and_idempotent( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + iam = StatefulIam() + iam.versions["v2"] = (CHANGED, NOW) + iam.default = "v2" + iam.tags["environment"] = "test" + iam.permission_users["U1"] = "Human" + iam.permission_groups["G1"] = "Operators" + iam.permission_roles["R1"] = "Reader" + iam.boundary_users["BU1"] = "RestrictedUser" + iam.boundary_roles["BR1"] = "RestrictedRole" + sts = FakeSts() + service = make_service(iam, sts) + context = SimpleNamespace( + iam=iam, + sts=sts, + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + policy = service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ) + dependencies = service.policy_dependencies(ARN) + compensation = policy_cli._policy_state(policy, dependencies=dependencies) + forward = policy_cli._absent_state(ARN, policy.name, policy.path) + + _iam_recovery.clear_handlers() + journal_id = policy_cli._durable_reconcile( + cast("Any", context), "delete", forward, compensation + ) + assert not iam.exists + assert not iam.permission_users + assert not iam.permission_groups + assert not iam.permission_roles + assert not iam.boundary_users + assert not iam.boundary_roles + + mutation_calls = len(iam.calls) + for _attempt in range(2): + with pytest.raises( + _configs.OperationalError, match="irreversible AWS PolicyId" + ): + _iam_recovery.rollback_journal(journal_id, context) + assert not iam.exists + assert len(iam.calls) == mutation_calls + policy_cli._reconcile_policy( + policy_cli._recovery_payload(compensation, forward), context + ) + assert len(iam.calls) == mutation_calls + assert "CreatePolicy" not in iam.calls + assert "AttachUserPolicy" not in iam.calls + assert "AttachGroupPolicy" not in iam.calls + assert "AttachRolePolicy" not in iam.calls + assert "PutUserPermissionsBoundary" not in iam.calls + assert "PutRolePermissionsBoundary" not in iam.calls + + +def test_policy_recovery_rejects_wrong_account_and_create_collision( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + sts = FakeSts() + context = SimpleNamespace( + iam=iam, + sts=sts, + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + with pytest.raises(_configs.OperationalError, match="selected credentials"): + policy_cli._reconcile_policy( + policy_cli._recovery_payload( + policy_cli._absent_state( + "arn:aws:iam::999999999999:policy/Test", "Test", "/" + ), + policy_cli._absent_state( + "arn:aws:iam::999999999999:policy/Test", "Test", "/" + ), + ), + context, + ) + collision = policy_cli._policy_state( + make_service(iam, sts).get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + create_only=True, + ) + collision["tags"] = [{"Key": "hacksaws:resource-id", "Value": "different"}] + with pytest.raises(managed.PolicyDriftError, match="exact expected predecessor"): + policy_cli._reconcile_policy( + policy_cli._recovery_payload( + policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/"), + collision, + ), + context, + ) + + +def test_policy_recovery_selects_retained_default_and_reconciles_tags( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + iam.versions["v2"] = (CHANGED, NOW) + sts = FakeSts() + context = SimpleNamespace( + iam=iam, + sts=sts, + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + service = make_service(iam, sts) + original = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=service.policy_dependencies(ARN), + ) + desired = dict(original) + desired["versions"] = [{"id": "v2", "default": True, "document": CHANGED}] + desired["tags"] = [{"Key": "environment", "Value": "production"}] + + policy_cli._reconcile_policy( + policy_cli._recovery_payload(original, desired), context + ) + + assert iam.default == "v2" + assert list(iam.versions) == ["v2"] + assert iam.tags == {"environment": "production"} + assert "SetDefaultPolicyVersion" in iam.calls + assert "DeletePolicyVersion" in iam.calls + assert "TagPolicy" in iam.calls + assert "UntagPolicy" in iam.calls + + +def test_policy_recovery_rejects_concurrent_document_and_tag_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + original = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=service.policy_dependencies(ARN), + ) + desired = dict(original) + desired["versions"] = [ + {"id": "v1", "default": False, "document": POLICY}, + {"id": "pending", "default": True, "document": CHANGED}, + ] + + deny_all: dict[str, documents.JsonValue] = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Deny", "Action": "*", "Resource": "*"}], + } + iam.versions["v2"] = (deny_all, NOW) + iam.default = "v2" + iam.tags["concurrent"] = "preserve-me" + before_calls = list(iam.calls) + + with pytest.raises(managed.PolicyDriftError, match="exact expected predecessor"): + policy_cli._reconcile_policy( + policy_cli._recovery_payload(original, desired), context + ) + + assert iam.calls == before_calls + assert iam.default == "v2" + assert iam.tags["concurrent"] == "preserve-me" + + +def test_policy_delete_recovery_rejects_new_attachment_and_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + expected = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=service.policy_dependencies(ARN), + ) + iam.permission_roles["R2"] = "NewAttachment" + iam.boundary_roles["BR2"] = "NewBoundary" + + with pytest.raises(managed.PolicyDriftError, match="exact expected predecessor"): + policy_cli._reconcile_policy( + policy_cli._recovery_payload( + expected, + policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/"), + ), + context, + ) + + assert "DetachRolePolicy" not in iam.calls + assert "DeleteRolePermissionsBoundary" not in iam.calls + assert "DeletePolicy" not in iam.calls + assert iam.permission_roles == {"R2": "NewAttachment"} + assert iam.boundary_roles == {"BR2": "NewBoundary"} + + +def test_create_rollback_preserves_unrelated_policy_at_same_arn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + intended = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=managed.PolicyDependencies(), + create_only=True, + ) + intended["policyId"] = "pending" + intended["defaultVersionId"] = "pending" + intended["versions"] = [{"id": "pending", "default": True, "document": POLICY}] + intended["tags"] = [ + {"Key": "hacksaws:managed-by", "Value": "hacksaws"}, + {"Key": "hacksaws:resource-id", "Value": "this-journal-only"}, + {"Key": "hacksaws:resource-kind", "Value": "managed-policy"}, + ] + + with pytest.raises(managed.PolicyDriftError, match="exact expected predecessor"): + policy_cli._reconcile_policy( + policy_cli._recovery_payload( + intended, + policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/"), + ), + context, + ) + + assert iam.exists + assert "DeletePolicy" not in iam.calls + assert iam.tags["hacksaws:resource-id"] == "resource-1" + + +def test_create_rollback_requires_exact_durable_policy_id_receipt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + created = policy_cli._created_base_state( + policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=managed.PolicyDependencies(), + create_only=True, + ) + ) + payload = { + "expected": created, + "target": policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/"), + "effect": {"policyId": "ANPA-DIFFERENT-POLICY"}, + } + + with pytest.raises(managed.PolicyDriftError, match="before deletion"): + policy_cli._delete_created_policy_with_receipt(payload, context) + + assert iam.exists + assert "DeletePolicy" not in iam.calls + + +def test_unreceipted_create_crash_preserves_present_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + template_iam = StatefulIam() + template_service = make_service(template_iam) + target = policy_cli._created_base_state( + policy_cli._policy_state( + template_service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=managed.PolicyDependencies(), + create_only=True, + ) + ) + iam = StatefulIam(existing=False) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + _iam_recovery.clear_handlers() + policy_cli.ensure_recovery_handlers() + journal = _iam_recovery.begin_journal("policy", ACCOUNT, "create-crash") + journal.record_before_mutation( + "create-policy", + forward={"target": target}, + compensation={ + "expected": target, + "target": policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/"), + "effectSourceStep": "self", + }, + ) + + receipt = policy_cli._create_policy_with_receipt({"target": target}, context) + assert receipt == {"policyId": "ANPA12345678901234567"} + with pytest.raises(_configs.OperationalError, match="cannot prove"): + _iam_recovery.rollback_journal(journal.id, context) + + assert iam.exists + assert "DeletePolicy" not in iam.calls + + +def test_receipted_create_rollback_deletes_only_bound_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + template_iam = StatefulIam() + template_service = make_service(template_iam) + target = policy_cli._replacement_target( + policy_cli._policy_state( + template_service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=managed.PolicyDependencies(), + create_only=True, + ) + ) + iam = StatefulIam(existing=False) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + _iam_recovery.clear_handlers() + + journal_id = policy_cli._durable_reconcile( + cast("Any", context), + "create", + target, + policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/"), + ) + + journal = _iam_recovery.get_journal(journal_id) + assert journal["steps"][0]["effect"] == {"policyId": "ANPA12345678901234567"} + assert iam.exists + _iam_recovery.rollback_journal(journal_id, context) + assert not iam.exists + assert "DeletePolicy" in iam.calls + + +def test_existing_policy_checkpoint_rejects_partial_atomic_tag_batch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + iam.tags.update({"first": "old", "second": "old"}) + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + expected = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=service.policy_dependencies(ARN), + ) + target = policy_cli._clone_state(expected) + target_tags = { + item["Key"]: item["Value"] for item in policy_cli._state_tags(target) + } + target_tags.update({"first": "new", "second": "new"}) + target["tags"] = [ + {"Key": key, "Value": value} for key, value in sorted(target_tags.items()) + ] + iam.tags["first"] = "new" + before_calls = list(iam.calls) + + with pytest.raises(managed.PolicyDriftError, match="exact recovery checkpoint"): + policy_cli._reconcile_policy( + policy_cli._recovery_payload(expected, target), context + ) + + assert iam.calls == before_calls + assert iam.tags["first"] == "new" + assert iam.tags["second"] == "old" + + +def test_delete_checkpoint_rejects_out_of_order_missing_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + iam.permission_roles["R1"] = "Reader" + iam.boundary_roles["BR1"] = "Restricted" + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + expected = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=service.policy_dependencies(ARN), + ) + iam.boundary_roles.clear() + payload = policy_cli._recovery_payload( + expected, policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/") + ) + + with pytest.raises(managed.PolicyDriftError, match="exact recovery checkpoint"): + policy_cli._reconcile_policy(payload, context) + + assert iam.permission_roles == {"R1": "Reader"} + assert "DetachRolePolicy" not in iam.calls + assert "DeletePolicy" not in iam.calls + + +def test_dependency_restore_rejects_same_name_recreated_principal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + iam = StatefulIam() + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + target = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=managed.PolicyDependencies( + permission_users=(managed.EntityReference("User", "Human", "U1"),) + ), + ) + iam.user_identities["Human"] = "U2" + + with pytest.raises(managed.PolicyDriftError, match="different IAM principal"): + policy_cli._reconcile_policy( + policy_cli._recovery_payload( + policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/"), + target, + include_reverse_checkpoints=True, + ), + context, + ) + + assert "AttachUserPolicy" not in iam.calls + assert not iam.permission_users + + +@pytest.mark.parametrize("recover", ["continue", "rollback"]) +def test_delete_recovery_resumes_exact_partial_checkpoint( + monkeypatch: pytest.MonkeyPatch, recover: str +) -> None: + iam = StatefulIam() + iam.permission_roles["R1"] = "Reader" + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + original = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=service.policy_dependencies(ARN), + ) + absent = policy_cli._absent_state(ARN, "AgentRead", "/hacksaws/") + forward = policy_cli._recovery_payload(original, absent) + compensation = policy_cli._recovery_payload( + absent, original, include_reverse_checkpoints=True + ) + detach = iam.detach_role_policy + + def detach_then_crash(**kwargs: object) -> None: + detach(**kwargs) + raise RuntimeError("crash after DetachRolePolicy") # noqa: TRY003 + + monkeypatch.setattr(iam, "detach_role_policy", detach_then_crash) + with pytest.raises(RuntimeError, match="crash after DetachRolePolicy"): + policy_cli._reconcile_policy(forward, context) + monkeypatch.setattr(iam, "detach_role_policy", detach) + + policy_cli._reconcile_policy( + forward if recover == "continue" else compensation, context + ) + + if recover == "continue": + assert not iam.exists + else: + assert iam.exists + assert service.policy_dependencies(ARN).permission_roles == ( + managed.EntityReference("Role", "Reader", "R1"), + ) + + +@pytest.mark.parametrize("recover", ["continue", "rollback"]) +def test_update_recovery_resumes_after_prune_checkpoint( + monkeypatch: pytest.MonkeyPatch, recover: str +) -> None: + iam = StatefulIam() + extra_documents: dict[str, dict[str, documents.JsonValue]] = {} + for number in range(2, 6): + document: dict[str, documents.JsonValue] = { + "Version": "2012-10-17", + "Statement": [{"Sid": f"Original{number}"}], + } + extra_documents[f"v{number}"] = document + iam.versions[f"v{number}"] = (document, NOW) + service = make_service(iam) + context = SimpleNamespace( + iam=iam, + sts=FakeSts(), + access_analyzer=None, + account_id=ACCOUNT, + partition=PARTITION, + ) + monkeypatch.setattr( + policy_cli._state, "load_config", policy_cli._state.default_config + ) + original = policy_cli._policy_state( + service.get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ), + dependencies=service.policy_dependencies(ARN), + ) + target = dict(original) + target["versions"] = [ + {"id": "v1", "default": False, "document": POLICY}, + *[ + { + "id": version_id, + "default": False, + "document": document, + } + for version_id, document in extra_documents.items() + if version_id != "v2" + ], + {"id": "pending", "default": True, "document": CHANGED}, + ] + forward = policy_cli._recovery_payload(original, target) + compensation = policy_cli._recovery_payload( + target, original, include_reverse_checkpoints=True + ) + prune = iam.delete_policy_version + + def prune_then_crash(**kwargs: object) -> None: + prune(**kwargs) + raise RuntimeError("crash after DeletePolicyVersion") # noqa: TRY003 + + monkeypatch.setattr(iam, "delete_policy_version", prune_then_crash) + with pytest.raises(RuntimeError, match="crash after DeletePolicyVersion"): + policy_cli._reconcile_policy(forward, context) + assert "v2" not in iam.versions + monkeypatch.setattr(iam, "delete_policy_version", prune) + + policy_cli._reconcile_policy( + forward if recover == "continue" else compensation, context + ) + + documents_after = { + managed.policy_digest(document) for document, _created in iam.versions.values() + } + if recover == "continue": + assert iam.default != "v1" + assert managed.policy_digest(CHANGED) in documents_after + assert managed.policy_digest(extra_documents["v2"]) not in documents_after + else: + assert iam.default == "v1" + assert managed.policy_digest(extra_documents["v2"]) in documents_after + assert managed.policy_digest(CHANGED) not in documents_after + + +def test_policy_recovery_prunes_capacity_and_validates_snapshot_shapes() -> None: + iam = StatefulIam() + for number in range(2, 6): + iam.versions[f"v{number}"] = ( + {"Version": "2012-10-17", "Statement": [{"Sid": str(number)}]}, + NOW, + ) + current = make_service(iam).get_policy( + ARN, include_document=True, include_versions=True, include_tags=True + ) + policy_cli._ensure_version_capacity( + cast("Any", SimpleNamespace(iam=iam)), + current, + {managed.policy_digest(POLICY)}, + ) + assert len(iam.versions) == 4 + assert "v2" not in iam.versions + + with pytest.raises(managed.PolicyServiceError, match="every version document"): + policy_cli._version_payload( + managed.PolicyVersionRecord( + version_id="v1", + is_default=True, + created_at=NOW, + document=None, + ) + ) + with pytest.raises(_configs.OperationalError, match="versions are invalid"): + policy_cli._state_versions({"versions": "not-a-list"}) + with pytest.raises(_configs.OperationalError, match="tags are invalid"): + policy_cli._state_tags({"tags": [{"Value": "missing-key"}]}) diff --git a/hacksaws/tests/test_iam_policy_cli.py b/hacksaws/tests/test_iam_policy_cli.py new file mode 100644 index 0000000..9bf5b9d --- /dev/null +++ b/hacksaws/tests/test_iam_policy_cli.py @@ -0,0 +1,1539 @@ +"""Focused tests for the managed-policy CLI adapter.""" + +from __future__ import annotations + +import argparse +import json +import tomllib +from collections.abc import Iterator +from dataclasses import replace +from datetime import UTC +from datetime import datetime +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from botocore.exceptions import ClientError + +from hacksaws import _iam_policy_cli as cli +from hacksaws import _iam_recovery +from hacksaws import _state +from hacksaws._iam_managed_policies import ChangeAction +from hacksaws._iam_managed_policies import DiagnosticSeverity +from hacksaws._iam_managed_policies import EntityReference +from hacksaws._iam_managed_policies import ImmutablePolicyError +from hacksaws._iam_managed_policies import ManagedPolicyArn +from hacksaws._iam_managed_policies import ManagedPolicyRecord +from hacksaws._iam_managed_policies import OperationJournal +from hacksaws._iam_managed_policies import OperationPlan +from hacksaws._iam_managed_policies import PackedPolicyDiagnostic +from hacksaws._iam_managed_policies import PackedPolicyProbeError +from hacksaws._iam_managed_policies import PackedPolicyWarning +from hacksaws._iam_managed_policies import PolicyChangePlan +from hacksaws._iam_managed_policies import PolicyDeletionPlan +from hacksaws._iam_managed_policies import PolicyDependencies +from hacksaws._iam_managed_policies import PolicyDriftError +from hacksaws._iam_managed_policies import PolicyScope +from hacksaws._iam_managed_policies import PolicyValidationError +from hacksaws._iam_managed_policies import PolicyVersionRecord +from hacksaws._iam_managed_policies import PublishResult +from hacksaws._iam_managed_policies import RepairAction +from hacksaws._iam_managed_policies import ResolutionResult +from hacksaws._iam_managed_policies import Tag +from hacksaws._iam_managed_policies import TagChangePlan +from hacksaws._iam_managed_policies import ValidationDiagnostic +from hacksaws._iam_managed_policies import ValidationReport +from hacksaws._iam_policy_documents import JsonValue +from hacksaws._iam_policy_documents import PolicyFormat + +ACCOUNT = "123456789012" +ARN = f"arn:aws:iam::{ACCOUNT}:policy/hacksaws/AgentRead" +AWS_ARN = "arn:aws:iam::aws:policy/ReadOnlyAccess" +NOW = datetime(2026, 8, 1, tzinfo=UTC) +DOCUMENT: dict[str, JsonValue] = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "logs:GetLogEvents", "Resource": "*"}], +} +_REAL_DURABLE_RECONCILE = cli._durable_reconcile + + +def record( + *, aws: bool = False, owned: bool = True, document: bool = True +) -> ManagedPolicyRecord: + arn = ManagedPolicyArn.parse(AWS_ARN if aws else ARN) + tags = ( + ( + Tag("hacksaws:managed-by", "hacksaws"), + Tag("hacksaws:resource-id", "resource-1"), + Tag("hacksaws:resource-kind", "managed-policy"), + ) + if owned and not aws + else () + ) + version = PolicyVersionRecord( + version_id="v1", + is_default=True, + created_at=NOW, + document=DOCUMENT if document else None, + ) + return ManagedPolicyRecord( + arn=arn, + policy_id="ANPA123", + name=arn.name, + path=arn.path, + default_version_id="v1", + attachment_count=2, + permissions_boundary_usage_count=1, + tags=tags, + document=DOCUMENT if document else None, + versions=(version,), + ) + + +def parser() -> argparse.ArgumentParser: + value = argparse.ArgumentParser() + cli.register(value) + return value + + +def context() -> SimpleNamespace: + return SimpleNamespace( + iam=Mock(), + sts=Mock(), + access_analyzer=Mock(), + account_id=ACCOUNT, + partition="aws", + ) + + +def service() -> Mock: + value = Mock() + value.account_id = ACCOUNT + value.partition = "aws" + item = record() + value.resolve.return_value = ResolutionResult("AgentRead", (item,)) + value.get_policy.return_value = item + value.policy_dependencies.return_value = PolicyDependencies() + value.list_policies.return_value = ( + item, + record(aws=True, owned=False), + ) + return value + + +@pytest.fixture(autouse=True) +def stub_durable_reconcile(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Keep adapter tests local; recovery integration is covered separately.""" + cli._configs.configure_output() + monkeypatch.setattr(cli, "_durable_reconcile", lambda *_args: "journal-1") + yield + cli._configs.configure_output() + + +@pytest.mark.parametrize( + ("argv", "action"), + [ + (["create", "p.json"], "create"), + (["publish", "p.json"], "create"), + (["list", "*Read*", "--wide"], "list"), + (["get", "ReadOnlyAccess"], "get"), + (["export", "ReadOnlyAccess"], "export"), + (["update", "ReadOnlyAccess", "p.yaml"], "update"), + (["edit", "ReadOnlyAccess"], "edit"), + (["versions", "ReadOnlyAccess"], "versions"), + (["rollback", "ReadOnlyAccess", "v1"], "rollback"), + (["remove", "ReadOnlyAccess"], "delete"), + ( + [ + "check", + "ReadOnlyAccess", + "--role", + "arn:aws:iam::123456789012:role/Test", + ], + "check", + ), + (["tag", "set", "ReadOnlyAccess", "--tag", "env=test"], "tag"), + (["adopt", "ReadOnlyAccess"], "adopt"), + (["release", "ReadOnlyAccess"], "release"), + ], +) +def test_registers_locked_grammar(argv: list[str], action: str) -> None: + assert parser().parse_args(argv).policy_action == action + + +def test_loads_json_yaml_toml_and_stdin( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + for suffix, content in ( + ("json", json.dumps(DOCUMENT)), + ("yaml", "Version: '2012-10-17'\nStatement: []\n"), + ("toml", "Version = '2012-10-17'\nStatement = []\n"), + ): + path = tmp_path / f"policy.{suffix}" + path.write_text(content, encoding="utf-8") + args = parser().parse_args(["create", str(path)]) + assert cli._load_from_file(args, str(path)).document["Version"] == "2012-10-17" + + monkeypatch.setattr(cli.sys, "stdin", StringIO(json.dumps(DOCUMENT))) + args = parser().parse_args(["create", "-", "FromStdin", "--format", "json"]) + assert cli._load_from_file(args, "-").document == DOCUMENT + + +def test_stdin_requires_format(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli.sys, "stdin", StringIO("{}")) + result = cli.dispatch(parser().parse_args(["create", "-"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_ERROR" + assert result.exit_code == 3 + + +def test_toml_export_round_trips() -> None: + encoded = cli._serialize(DOCUMENT, PolicyFormat.TOML) + assert tomllib.loads(encoded) == DOCUMENT + + +def test_create_uses_naming_tags_and_yes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "agent-read.json" + path.write_text(json.dumps(DOCUMENT), encoding="utf-8") + fake = service() + fake.resolve.return_value = ResolutionResult("custom:AgentRead", ()) + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.CREATE, "Create AgentRead.", ()), + None, + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + (), + ) + fake.plan_create.return_value = plan + fake.execute_change.return_value = PublishResult( + ChangeAction.CREATE, record(), OperationJournal("plan", []) + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + monkeypatch.setattr(cli._state, "load_config", _state.default_config) + args = parser().parse_args(["create", str(path), "--tag", "team=agents", "--yes"]) + result = cli.dispatch(args, context()) + assert result is not None + assert result.code == "IAM_POLICY_CHANGED" + options = fake.plan_create.call_args.kwargs["options"] + assert fake.plan_create.call_args.args[0] == "AgentRead" + assert options.user_tags == (Tag("team", "agents"),) + + +def test_create_reports_no_change_and_conflict_requires_replace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "AgentRead.json" + path.write_text(json.dumps(DOCUMENT), encoding="utf-8") + fake = service() + monkeypatch.setattr(cli, "_service", lambda _: fake) + monkeypatch.setattr(cli._state, "load_config", _state.default_config) + result = cli.dispatch(parser().parse_args(["create", str(path)]), context()) + assert result is not None + assert result.code == "IAM_POLICY_NO_CHANGE" + + path.write_text(json.dumps({**DOCUMENT, "Statement": []}), encoding="utf-8") + result = cli.dispatch(parser().parse_args(["create", str(path)]), context()) + assert result is not None + assert result.code == "IAM_POLICY_COLLISION" + + +def test_generated_create_name_can_be_accepted_edited_or_cancelled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "agent-read.yaml" + path.write_text("Version: '2012-10-17'\nStatement: []\n", encoding="utf-8") + loaded = cli.load_policy_input(path) + args = argparse.Namespace(name=None, file=str(path), account=None) + monkeypatch.setattr(cli._state, "load_config", _state.default_config) + monkeypatch.setattr(cli.sys, "stdin", SimpleNamespace(isatty=lambda: True)) + + monkeypatch.setattr("builtins.input", lambda _prompt: "") + assert cli._create_name(args, loaded)[0] == "AgentRead" + + answers = iter(["edit", "TeamAgentRead"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(answers)) + assert cli._create_name(args, loaded)[0] == "TeamAgentRead" + + monkeypatch.setattr("builtins.input", lambda _prompt: "cancel") + with pytest.raises(cli.PolicyInputError, match="cancelled"): + cli._create_name(args, loaded) + + monkeypatch.setattr("builtins.input", lambda _prompt: "unknown") + with pytest.raises(cli.PolicyInputError, match="Accept, Edit, or Cancel"): + cli._create_name(args, loaded) + + answers = iter(["edit", ""]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(answers)) + with pytest.raises(cli.PolicyInputError, match="cannot be empty"): + cli._create_name(args, loaded) + + +def test_list_uses_scope_patterns_and_dynamic_legend( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args(["list", "*Read*", "--all", "--wide"]), context() + ) + assert result is not None + assert "AWS-managed" in result.message + assert "Hacksaws-owned" in result.message + assert result.data["view"] == "wide" + fake.list_policies.assert_called_once_with(scope=PolicyScope.ALL, include_tags=True) + + +def test_get_and_versions_resolve_to_arn(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch(parser().parse_args(["get", "AgentRead"]), context()) + assert result is not None + assert ARN in result.message + result = cli.dispatch(parser().parse_args(["versions", "AgentRead"]), context()) + assert result is not None + assert "v1" in result.message + + +def test_export_defaults_yaml_and_writes_nested_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + fake = service() + fake.export_policy.return_value = SimpleNamespace( + policy=record(), active_document=DOCUMENT, versions=() + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + output = tmp_path / "export.yaml" + result = cli.dispatch( + parser().parse_args( + ["export", "AgentRead", str(output), "--metadata", "nested"] + ), + context(), + ) + assert result is not None + assert result.code == "IAM_POLICY_EXPORT" + loaded = __import__("yaml").safe_load(output.read_text(encoding="utf-8")) + assert loaded["metadata"]["name"] == "AgentRead" + assert loaded["policy"] == DOCUMENT + + +def test_export_sidecar_requires_file(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + fake.export_policy.return_value = SimpleNamespace( + policy=record(), active_document=DOCUMENT, versions=() + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args(["export", "AgentRead", "--metadata", "sidecar"]), + context(), + ) + assert result is not None + assert result.code == "IAM_POLICY_ERROR" + + +def test_noninteractive_update_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "policy.json" + path.write_text(json.dumps(DOCUMENT), encoding="utf-8") + fake = service() + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.UPDATE, "Update policy.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + (), + expected_default_version_id="v1", + validation=ValidationReport(), + ) + fake.plan_publish.return_value = plan + monkeypatch.setattr(cli, "_service", lambda _: fake) + monkeypatch.setattr(cli.sys, "stdin", StringIO()) + result = cli.dispatch( + parser().parse_args(["update", "AgentRead", str(path)]), context() + ) + assert result is not None + assert result.code == "IAM_POLICY_CANCELLED" + fake.execute_change.assert_not_called() + + +def test_ambiguous_reference_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + fake.resolve.return_value = ResolutionResult( + "ReadOnlyAccess", (record(), record(aws=True, owned=False)) + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + monkeypatch.setattr(cli.sys, "stdin", StringIO()) + result = cli.dispatch(parser().parse_args(["get", "ReadOnlyAccess"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_ERROR" + assert "ambiguous" in result.message + + +def test_tag_set_and_reserved_safeguard(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + selected_context = context() + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args(["tag", "set", "AgentRead", "--tag", "env=test", "--yes"]), + selected_context, + ) + assert result is not None + assert result.code == "IAM_POLICY_TAG_CHANGED" + assert result.data["journalId"] == "journal-1" + result = cli.dispatch( + parser().parse_args( + [ + "tag", + "set", + "AgentRead", + "--tag", + "hacksaws:managed-by=other", + "--yes", + ] + ), + selected_context, + ) + assert result is not None + assert result.code == "IAM_POLICY_ERROR" + + +def test_delete_requires_owned_or_explicit_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + fake.plan_delete.return_value = SimpleNamespace( + policy=record(owned=False), + dependencies=SimpleNamespace( + permission_users=(), + permission_groups=(), + permission_roles=(), + boundary_users=(), + boundary_roles=(), + empty=True, + ), + operation=OperationPlan("plan", ChangeAction.DELETE, "Delete policy.", ()), + executable=True, + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args(["delete", "AgentRead", "--yes"]), context() + ) + assert result is not None + assert result.code == "IAM_POLICY_UNMANAGED" + fake.execute_delete.assert_not_called() + + +def test_packed_policy_failure_is_structured(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + fake.get_policy.return_value = record() + fake.validate_policy.return_value = ValidationReport() + fake.probe_assume_role.side_effect = PackedPolicyProbeError( + PackedPolicyDiagnostic( + "PackedPolicyTooLarge", + "Packed session policy is too large.", + 101, + (RepairAction("reduce-policy", "policy", "Reduce policy size.", None),), + ) + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args( + [ + "check", + "AgentRead", + "--role", + f"arn:aws:iam::{ACCOUNT}:role/AgentSession", + ] + ), + context(), + ) + assert result is not None + assert result.code == "IAM_POLICY_PACKED_TOO_LARGE" + assert result.data["packedPolicySize"] == 101 + + +def test_dispatch_returns_none_when_no_leaf() -> None: + assert cli.dispatch(argparse.Namespace(), context()) is None + + +def test_tag_parser_rejects_invalid_and_duplicate_values() -> None: + with pytest.raises(ValueError, match="KEY=VALUE"): + cli._tags(["broken"]) + with pytest.raises(ValueError, match="more than once"): + cli._tags(["Env=one", "env=two"]) + + +def test_metadata_and_format_precedence(tmp_path: Path) -> None: + nested = tmp_path / "nested.data" + nested.write_text( + json.dumps({"metadata": {"name": "Nested"}, "policy": DOCUMENT}), + encoding="utf-8", + ) + args = parser().parse_args( + ["create", str(nested), "--format", "json", "--metadata", "nested"] + ) + loaded = cli._load_from_file(args, str(nested)) + assert loaded.metadata.name == "Nested" + assert loaded.document == DOCUMENT + assert ( + cli._output_format(argparse.Namespace(format="json", output="policy.yaml")) + is PolicyFormat.JSON + ) + assert ( + cli._output_format(argparse.Namespace(format=None, output="unknown.ext")) + is PolicyFormat.YAML + ) + + +@pytest.mark.parametrize( + ("case", "expected"), + [ + ("Pascal", "PreAgentReadPost"), + ("camel", "PreagentReadPost"), + ("snake", "Preagent_readPost"), + ("kebab", "Preagent-readPost"), + ], +) +def test_naming_cases(case: str, expected: str) -> None: + assert ( + cli._named("agent-read", {"case": case, "prefix": "Pre", "suffix": "Post"}) + == expected + ) + + +def test_ambiguous_reference_can_be_selected_interactively( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + fake.resolve.return_value = ResolutionResult( + "ReadOnlyAccess", (record(), record(aws=True, owned=False)) + ) + terminal = StringIO() + terminal.isatty = lambda: True + monkeypatch.setattr(cli.sys, "stdin", terminal) + monkeypatch.setattr("builtins.input", lambda _: "2") + assert cli._select(fake, "ReadOnlyAccess").arn.value == AWS_ARN + monkeypatch.setattr("builtins.input", lambda _: "bogus") + with pytest.raises(RuntimeError, match="valid policy selection"): + cli._select(fake, "ReadOnlyAccess") + + +def test_sidecar_export_and_stdout_json( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + fake = service() + fake.export_policy.return_value = SimpleNamespace( + policy=record(), active_document=DOCUMENT, versions=() + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + output = tmp_path / "policy.json" + result = cli.dispatch( + parser().parse_args( + ["export", "AgentRead", str(output), "--metadata", "sidecar"] + ), + context(), + ) + assert result is not None + sidecar = tmp_path / "policy.metadata.json" + assert sidecar.exists() + assert json.loads(sidecar.read_text(encoding="utf-8"))["name"] == "AgentRead" + result = cli.dispatch( + parser().parse_args(["export", "AgentRead", "--format", "json"]), + context(), + ) + assert result is not None + assert json.loads(result.message) == DOCUMENT + + +def test_edit_success_and_failure(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + fake.export_policy.return_value = SimpleNamespace( + policy=record(), active_document=DOCUMENT, versions=() + ) + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.NOOP, "Unchanged.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + (), + validation=ValidationReport(), + ) + fake.plan_publish.return_value = plan + fake.execute_change.return_value = PublishResult( + ChangeAction.NOOP, record(), OperationJournal("plan", []) + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + monkeypatch.setattr( + cli.subprocess, "run", lambda *_args, **_kwargs: SimpleNamespace(returncode=0) + ) + result = cli.dispatch(parser().parse_args(["edit", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_CHANGED" + monkeypatch.setattr( + cli.subprocess, "run", lambda *_args, **_kwargs: SimpleNamespace(returncode=7) + ) + result = cli.dispatch(parser().parse_args(["edit", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_EDITOR_FAILED" + + +def test_rollback_and_owned_delete_execute(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + rollback_plan = PolicyChangePlan( + OperationPlan("rollback", ChangeAction.NOOP, "Already selected.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + (), + validation=ValidationReport(), + ) + fake.plan_rollback.return_value = rollback_plan + fake.execute_change.return_value = PublishResult( + ChangeAction.NOOP, record(), OperationJournal("rollback", []) + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args(["rollback", "AgentRead", "v1", "--yes"]), context() + ) + assert result is not None + assert result.code == "IAM_POLICY_CHANGED" + + dependencies = PolicyDependencies( + permission_roles=(EntityReference("Role", "Agent", "R1"),) + ) + delete_plan = PolicyDeletionPlan( + policy=record(), + dependencies=dependencies, + operation=OperationPlan("delete", ChangeAction.DELETE, "Delete policy.", ()), + cascade=True, + ) + fake.plan_delete.return_value = delete_plan + fake.policy_dependencies.return_value = dependencies + result = cli.dispatch( + parser().parse_args(["delete", "AgentRead", "--cascade", "--yes"]), + context(), + ) + assert result is not None + assert result.code == "IAM_POLICY_DELETED" + assert result.data["dependencies"]["permissionRoles"] == ["Agent"] + + +def test_check_success_warning_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + warning = ValidationDiagnostic( + DiagnosticSeverity.WARNING, "WARN", "Educational warning." + ) + fake.validate_policy.return_value = ValidationReport((warning,)) + fake.probe_assume_role.return_value = SimpleNamespace( + role_arn=f"arn:aws:iam::{ACCOUNT}:role/AgentSession", + assumed_role_arn=f"arn:aws:sts::{ACCOUNT}:assumed-role/AgentSession/check", + expires_at=NOW, + packed_policy_size=85, + warning=PackedPolicyWarning(85, 80, "Packed policy is at 85% capacity."), + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args( + [ + "check", + "AgentRead", + "--role", + f"arn:aws:iam::{ACCOUNT}:role/AgentSession", + ] + ), + context(), + ) + assert result is not None + assert result.code == "IAM_POLICY_CHECK" + assert "85%" in result.message + + error = ValidationDiagnostic(DiagnosticSeverity.ERROR, "DENIED", "Invalid.") + fake.validate_policy.return_value = ValidationReport((error,)) + result = cli.dispatch(parser().parse_args(["check", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_CHECK_FAILED" + + +def test_tag_list_remove_and_ownership(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + selected_context = context() + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args(["tag", "list", "AgentRead"]), selected_context + ) + assert result is not None + assert result.code == "IAM_POLICY_TAG_LIST" + result = cli.dispatch( + parser().parse_args(["tag", "remove", "AgentRead", "environment", "--yes"]), + selected_context, + ) + assert result is not None + assert result.data["journalId"] == "journal-1" + + ownership_plan = TagChangePlan( + record(), + OperationPlan("ownership", ChangeAction.ADOPT, "Adopt policy.", ()), + (), + (), + "digest", + ) + fake.plan_adopt.return_value = ownership_plan + fake.execute_tag_change.return_value = SimpleNamespace(policy=record()) + result = cli.dispatch( + parser().parse_args(["adopt", "AgentRead", "--yes"]), context() + ) + assert result is not None + assert result.code == "IAM_POLICY_OWNERSHIP_CHANGED" + fake.plan_release.return_value = ownership_plan + result = cli.dispatch( + parser().parse_args(["release", "AgentRead", "--yes"]), context() + ) + assert result is not None + assert result.data["action"] == "release" + + +def test_dispatch_normalizes_drift_and_unknown_action( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + fake.get_policy.side_effect = PolicyDriftError("changed after planning") + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch(parser().parse_args(["get", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_DRIFT" + result = cli.dispatch(argparse.Namespace(policy_action="unknown"), context()) + assert result is not None + assert result.code == "IAM_POLICY_HELP" + + +def test_stored_policy_drives_update(monkeypatch: pytest.MonkeyPatch) -> None: + stored = SimpleNamespace( + document=DOCUMENT, + metadata=SimpleNamespace(name="StoredRead"), + ) + monkeypatch.setattr(cli, "_stored_policy", lambda _: stored) + args = parser().parse_args( + ["update", "AgentRead", "--from-stored", "StoredRead", "--yes"] + ) + reference, loaded = cli._loaded_update(args) + assert reference == "AgentRead" + assert loaded.document == DOCUMENT + with pytest.raises(ValueError, match="cannot be combined"): + cli._loaded_update( + argparse.Namespace( + from_stored="StoredRead", + policy_or_file="AgentRead", + file="policy.json", + ) + ) + + +def test_toml_scalars_and_unrepresentable_shape() -> None: + with pytest.raises(ValueError, match="no null value"): + cli._toml_scalar(None) + truth = True + assert cli._toml_scalar(truth) == "true" + assert cli._toml_scalar(3) == "3" + assert cli._toml_scalar(["a", 2]) == '["a", 2]' + with pytest.raises(ValueError, match="losslessly"): + cli._toml_scalar({"nested": "value"}) + + +def test_validation_failure_is_fail_closed(monkeypatch: pytest.MonkeyPatch) -> None: + diagnostic = ValidationDiagnostic( + DiagnosticSeverity.ERROR, + "INVALID", + "Policy is invalid.", + "Statement", + RepairAction("rewrite", "Statement", "Rewrite the statement."), + ) + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.UPDATE, "Update.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + (), + validation=ValidationReport((diagnostic,)), + ) + fake = service() + monkeypatch.setattr(cli.sys, "stdin", StringIO()) + result = cli._execute_plan(fake, plan, argparse.Namespace(yes=False), context()) + assert result.code == "IAM_POLICY_VALIDATION_FAILED" + assert fake.execute_change.call_count == 0 + + +def test_policy_dry_run_returns_plan_without_confirmation_or_journal() -> None: + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.UPDATE, "Update AgentRead.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + {**DOCUMENT, "Statement": []}, + None, + (), + validation=ValidationReport(), + ) + fake = service() + result = cli._execute_plan( + fake, + plan, + argparse.Namespace(yes=False, dry_run=True), + context(), + ) + assert result.code == "IAM_POLICY_DRY_RUN" + assert result.data["dryRun"] is True + assert result.data["classification"] == "planned" + assert fake.execute_change.call_count == 0 + + +def test_missing_reference_and_missing_document_are_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + fake.resolve.return_value = ResolutionResult("missing", ()) + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch(parser().parse_args(["get", "missing"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_ERROR" + + fake.resolve.return_value = ResolutionResult("AgentRead", (record(),)) + fake.get_policy.return_value = record(document=False) + result = cli.dispatch(parser().parse_args(["check", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_ERROR" + + +def test_tag_help_empty_set_and_aws_immutable(monkeypatch: pytest.MonkeyPatch) -> None: + fake = service() + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + argparse.Namespace(policy_action="tag", policy_tag_action=None), context() + ) + assert result is not None + assert result.code == "IAM_POLICY_TAG_HELP" + result = cli.dispatch( + parser().parse_args(["tag", "set", "AgentRead", "--yes"]), context() + ) + assert result is not None + assert result.code == "IAM_POLICY_ERROR" + fake.get_policy.return_value = record(aws=True, owned=False) + result = cli.dispatch( + parser().parse_args(["tag", "set", "AgentRead", "--tag", "env=test", "--yes"]), + context(), + ) + assert result is not None + assert result.code == "IAM_POLICY_IMMUTABLE" + + +def test_delete_dependencies_and_confirmation_are_safe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + dependency = PolicyDependencies( + permission_roles=(EntityReference("Role", "Agent", "R1"),) + ) + fake.plan_delete.return_value = PolicyDeletionPlan( + policy=record(), + dependencies=dependency, + operation=OperationPlan("delete", ChangeAction.DELETE, "Delete policy.", ()), + cascade=False, + ) + fake.policy_dependencies.return_value = PolicyDependencies() + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args(["delete", "AgentRead", "--yes"]), context() + ) + assert result is not None + assert result.code == "IAM_POLICY_DEPENDENCIES" + + fake.plan_delete.return_value = PolicyDeletionPlan( + policy=record(), + dependencies=PolicyDependencies(), + operation=OperationPlan("delete", ChangeAction.DELETE, "Delete policy.", ()), + cascade=False, + ) + monkeypatch.setattr(cli.sys, "stdin", StringIO()) + result = cli.dispatch(parser().parse_args(["delete", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_CANCELLED" + + +def test_dispatch_normalizes_immutable_and_os_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + fake.get_policy.side_effect = ImmutablePolicyError("immutable") + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch(parser().parse_args(["get", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_IMMUTABLE" + fake.get_policy.side_effect = OSError("offline") + result = cli.dispatch(parser().parse_args(["get", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_AWS_ERROR" + + +def test_stored_and_metadata_inferred_updates( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config = _state.default_config() + config["policies"]["StoredRead"] = { + "file": "stored_session_policies/StoredRead.yaml" + } + policy_path = tmp_path / "stored_session_policies" / "StoredRead.yaml" + policy_path.parent.mkdir() + policy_path.write_text( + cli._serialize(DOCUMENT, PolicyFormat.YAML), encoding="utf-8" + ) + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + monkeypatch.setattr(cli._state, "load_config", lambda: config) + stored = cli._stored_policy("StoredRead") + assert stored.metadata.name == "StoredRead" + assert stored.document == DOCUMENT + + nested = tmp_path / "update.yaml" + nested.write_text( + cli._serialize( + {"metadata": {"name": "AgentRead"}, "policy": DOCUMENT}, + PolicyFormat.YAML, + ), + encoding="utf-8", + ) + args = parser().parse_args(["update", str(nested), "--metadata", "nested"]) + reference, loaded = cli._loaded_update(args) + assert reference == "AgentRead" + assert loaded.document == DOCUMENT + + result = cli._export( + argparse.Namespace( + policy="stored:StoredRead", + format=None, + output=None, + metadata="nested", + metadata_file=None, + all_versions=False, + ), + service(), + ) + assert result.data["provenance"] == "stored" + assert "StoredRead" in result.message + + +def test_naming_enforcement_and_exact_interactive_confirmation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + loaded = SimpleNamespace(metadata=SimpleNamespace(name=None)) + args = argparse.Namespace(name="Explicit", file="policy.json") + config = _state.default_config() + config["naming"]["resources"]["policy"] = { + "prefix": "Managed", + "enforcement": "warn", + } + monkeypatch.setattr(cli._state, "load_config", lambda: config) + selected, warnings = cli._create_name(args, loaded) + assert selected == "Explicit" + assert warnings + config["naming"]["resources"]["policy"]["enforcement"] = "error" + with pytest.raises(ValueError, match="configured name"): + cli._create_name(args, loaded) + + terminal = StringIO() + terminal.isatty = lambda: True + monkeypatch.setattr(cli.sys, "stdin", terminal) + monkeypatch.setattr("builtins.input", lambda _: "y") + assert not cli._confirm(argparse.Namespace(yes=False), "Continue?") + monkeypatch.setattr("builtins.input", lambda _: "yes") + assert cli._confirm(argparse.Namespace(yes=False), "Continue?") + + +def test_interactive_version_repair_replans_create( + monkeypatch: pytest.MonkeyPatch, +) -> None: + repair = RepairAction( + "set-version", + "Version", + "Set current IAM policy language version.", + "2012-10-17", + ) + warning = ValidationDiagnostic( + DiagnosticSeverity.WARNING, + "OLD_VERSION", + "Old version.", + "Version", + repair, + ) + old_document: dict[str, JsonValue] = {"Version": "2008-10-17", "Statement": []} + plan = PolicyChangePlan( + OperationPlan("create", ChangeAction.CREATE, "Create policy.", ()), + None, + "AgentRead", + "/hacksaws/", + old_document, + "Description", + (Tag("team", "agents"),), + validation=ValidationReport((warning,)), + ) + replacement = PolicyChangePlan( + OperationPlan("create", ChangeAction.NOOP, "No change.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + (), + ) + fake = service() + fake.plan_create.return_value = replacement + terminal = StringIO() + terminal.isatty = lambda: True + monkeypatch.setattr(cli.sys, "stdin", terminal) + monkeypatch.setattr("builtins.input", lambda _: "yes") + updated = cli._repair(plan, argparse.Namespace(yes=False), fake) + assert updated is replacement + assert fake.plan_create.call_args.args[1]["Version"] == "2012-10-17" + + +def test_dispatch_normalizes_service_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + report = ValidationReport( + (ValidationDiagnostic(DiagnosticSeverity.ERROR, "INVALID", "Invalid policy."),) + ) + fake = service() + fake.get_policy.side_effect = PolicyValidationError(report) + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch(parser().parse_args(["get", "AgentRead"]), context()) + assert result is not None + assert result.code == "IAM_POLICY_VALIDATION_FAILED" + assert result.data["diagnostics"][0]["code"] == "INVALID" + + +def test_check_probes_the_exact_selected_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + fake.validate_policy.return_value = ValidationReport() + fake.probe_assume_role.return_value = SimpleNamespace( + role_arn=f"arn:aws:iam::{ACCOUNT}:role/AgentSession", + assumed_role_arn=f"arn:aws:sts::{ACCOUNT}:assumed-role/AgentSession/check", + expires_at=NOW, + packed_policy_size=12, + warning=None, + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + + result = cli.dispatch( + parser().parse_args(["check", "AgentRead", "--role", "AgentSession"]), + context(), + ) + + assert result is not None + assert result.data["probe"]["packedPolicySize"] == 12 + assert fake.probe_assume_role.call_args.args == ( + f"arn:aws:iam::{ACCOUNT}:role/AgentSession", + DOCUMENT, + ) + assert fake.probe_assume_role.call_args.kwargs["options"].duration_seconds == 900 + + +def test_edit_fails_if_default_version_or_document_drifted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = service() + initial = record() + changed_document: dict[str, JsonValue] = { + "Version": "2012-10-17", + "Statement": [], + } + changed = replace( + initial, + default_version_id="v2", + document=changed_document, + ) + fake.export_policy.return_value = SimpleNamespace( + policy=initial, active_document=DOCUMENT, versions=() + ) + fake.get_policy.return_value = changed + monkeypatch.setattr(cli, "_service", lambda _: fake) + monkeypatch.setattr( + cli.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace(returncode=0), + ) + + result = cli.dispatch(parser().parse_args(["edit", "AgentRead"]), context()) + + assert result is not None + assert result.code == "IAM_POLICY_DRIFT" + fake.plan_publish.assert_not_called() + + +def test_export_all_versions_is_lossless_and_preserves_description( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + changed_document: dict[str, JsonValue] = { + "Version": "2012-10-17", + "Statement": [], + } + versions = ( + PolicyVersionRecord( + version_id="v1", + is_default=False, + created_at=NOW, + document=DOCUMENT, + ), + PolicyVersionRecord( + version_id="v2", + is_default=True, + created_at=NOW, + document=changed_document, + ), + ) + item = replace( + record(), + description="Read CloudWatch safely.", + default_version_id="v2", + document=changed_document, + versions=versions, + ) + fake = service() + fake.export_policy.return_value = SimpleNamespace( + policy=item, active_document=changed_document, versions=versions + ) + monkeypatch.setattr(cli, "_service", lambda _: fake) + output = tmp_path / "all.json" + + result = cli.dispatch( + parser().parse_args( + [ + "export", + "AgentRead", + str(output), + "--format", + "json", + "--metadata", + "nested", + "--all-versions", + ] + ), + context(), + ) + + assert result is not None + exported = json.loads(output.read_text(encoding="utf-8")) + assert exported["metadata"]["description"] == "Read CloudWatch safely." + assert exported["policy"] == changed_document + assert exported["versions"] == [ + { + "id": "v1", + "default": False, + "createdAt": NOW.isoformat(), + "policy": DOCUMENT, + }, + { + "id": "v2", + "default": True, + "createdAt": NOW.isoformat(), + "policy": changed_document, + }, + ] + + +def test_delete_requires_explicit_boundary_removal_and_snapshots_preview( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dependencies = PolicyDependencies( + permission_roles=(EntityReference("Role", "Reader", "R1"),), + boundary_users=(EntityReference("User", "Restricted", "U1"),), + ) + plan = PolicyDeletionPlan( + record(), + dependencies, + OperationPlan("delete", ChangeAction.DELETE, "Delete policy.", ()), + cascade=True, + ) + fake = service() + fake.plan_delete.return_value = plan + fake.policy_dependencies.return_value = dependencies + monkeypatch.setattr(cli, "_service", lambda _: fake) + + blocked = cli.dispatch( + parser().parse_args(["delete", "AgentRead", "--cascade", "--yes"]), + context(), + ) + assert blocked is not None + assert blocked.code == "IAM_POLICY_BOUNDARIES" + + snapshots: list[tuple[dict[str, object], dict[str, object]]] = [] + monkeypatch.setattr( + cli, + "_durable_reconcile", + lambda _context, _operation, forward, compensation: ( + snapshots.append((dict(forward), dict(compensation))) or "delete-journal" + ), + ) + deleted = cli.dispatch( + parser().parse_args( + [ + "delete", + "AgentRead", + "--cascade", + "--remove-boundaries", + "--yes", + ] + ), + context(), + ) + assert deleted is not None + assert deleted.code == "IAM_POLICY_DELETED" + assert deleted.data["preview"]["attachments"]["roles"] == ["Reader"] + assert deleted.data["preview"]["permissionBoundaries"]["users"] == ["Restricted"] + assert deleted.data["preview"]["versions"][0]["id"] == "v1" + assert snapshots[0][0]["exists"] is False + assert snapshots[0][1]["dependencies"]["boundaryUsers"] == [ + {"type": "User", "name": "Restricted", "id": "U1"} + ] + assert snapshots[0][1]["versions"][0]["document"] == DOCUMENT + + +def test_delete_rechecks_ownership_after_fresh_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + planned = record() + plan = PolicyDeletionPlan( + planned, + PolicyDependencies(), + OperationPlan("delete", ChangeAction.DELETE, "Delete policy.", ()), + cascade=True, + ) + fake = service() + fake.plan_delete.return_value = plan + fake.get_policy.return_value = replace(planned, tags=()) + fake.policy_dependencies.return_value = PolicyDependencies() + monkeypatch.setattr(cli, "_service", lambda _: fake) + + result = cli.dispatch( + parser().parse_args(["delete", "AgentRead", "--cascade", "--yes"]), + context(), + ) + + assert result is not None + assert result.code == "IAM_POLICY_UNMANAGED" + assert "changed after deletion planning" in result.message + + +def test_json_mode_never_prompts_and_tag_changes_detect_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + terminal = StringIO() + terminal.isatty = lambda: True + monkeypatch.setattr(cli.sys, "stdin", terminal) + monkeypatch.setattr(cli._configs, "json_output_enabled", lambda: True) + monkeypatch.setattr( + "builtins.input", + lambda _prompt: pytest.fail("JSON mode must not prompt"), + ) + assert not cli._confirm(argparse.Namespace(yes=False), "Continue?") + assert not cli._confirm_exact(argparse.Namespace(yes=False), "Delete?", "AgentRead") + + fake = service() + original = record() + drifted = replace(original, tags=(*original.tags, Tag("changed", "yes"))) + fake.get_policy.side_effect = [original, drifted] + monkeypatch.setattr(cli, "_service", lambda _: fake) + result = cli.dispatch( + parser().parse_args( + ["tag", "set", "AgentRead", "--tag", "environment=test", "--yes"] + ), + context(), + ) + assert result is not None + assert result.code == "IAM_POLICY_DRIFT" + + +def test_durable_policy_state_is_written_before_forward_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + _iam_recovery.clear_handlers() + dependencies = PolicyDependencies( + permission_users=(EntityReference("User", "Human", "U1"),) + ) + forward = cli._policy_state(record(), dependencies=dependencies) + compensation = cli._absent_state(ARN, "AgentRead", "/hacksaws/") + observed: dict[str, object] = {} + + def crash(journal_id: str, _context: object) -> None: + observed.update(_iam_recovery.get_journal(journal_id)) + raise RuntimeError("simulated crash before AWS") # noqa: TRY003 + + monkeypatch.setattr(_iam_recovery, "continue_journal", crash) + with pytest.raises(RuntimeError, match="simulated crash"): + _REAL_DURABLE_RECONCILE(context(), "update", forward, compensation) + + assert observed["status"] == "active" + assert observed["partition"] == "aws" + steps = observed["steps"] + assert isinstance(steps, list) + create_step, reconcile_step = steps + assert create_step["status"] == "pending" + assert create_step["forward"]["target"]["versions"][0]["document"] == DOCUMENT + assert create_step["forward"]["target"]["dependencies"]["permissionUsers"] == [] + assert reconcile_step["status"] == "pending" + assert reconcile_step["forward"]["target"]["dependencies"]["permissionUsers"] == [ + {"type": "User", "name": "Human", "id": "U1"} + ] + assert create_step["compensation"]["effectSourceStep"] == "self" + assert reconcile_step["forward"]["effectSourceStep"] == create_step["id"] + + monkeypatch.setattr( + _iam_recovery, + "continue_journal", + lambda journal_id, _context: _iam_recovery.get_journal(journal_id), + ) + completed_id = _REAL_DURABLE_RECONCILE(context(), "update", forward, compensation) + assert _iam_recovery.get_journal(completed_id)["steps"][0]["status"] == "pending" + + +def test_update_and_rollback_states_capture_complete_compensation() -> None: + fake = service() + current = record() + fake.get_policy.return_value = current + update = PolicyChangePlan( + OperationPlan("update", ChangeAction.UPDATE, "Update policy.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + {"Version": "2012-10-17", "Statement": []}, + current.description, + current.tags, + expected_default_version_id="v1", + expected_digest=cli.policy_digest(DOCUMENT), + ) + + forward, compensation = cli._change_states(fake, update) + + assert compensation["versions"][0]["document"] == DOCUMENT + assert forward["versions"][0]["default"] is False + assert forward["versions"][1] == { + "id": "pending", + "default": True, + "document": {"Version": "2012-10-17", "Statement": []}, + } + assert cli._semantic_diff(fake, update) + + versions = ( + current.versions[0], + PolicyVersionRecord( + version_id="v2", + is_default=False, + created_at=NOW, + document={"Version": "2012-10-17", "Statement": []}, + ), + ) + fake.get_policy.return_value = replace(current, versions=versions) + rollback = replace( + update, + operation=OperationPlan( + "rollback", ChangeAction.ROLLBACK, "Rollback policy.", () + ), + document={"Version": "2012-10-17", "Statement": []}, + rollback_version_id="v2", + ) + rolled_forward, rolled_compensation = cli._change_states(fake, rollback) + assert [item["default"] for item in rolled_forward["versions"]] == [False, True] + assert [item["default"] for item in rolled_compensation["versions"]] == [ + True, + False, + ] + + +def test_recovery_state_schema_rejects_identity_losing_payloads() -> None: + with pytest.raises(cli.OperationalError, match="tags are invalid"): + cli._state_tags({"tags": [{}]}) + with pytest.raises(cli.OperationalError, match="dependencies are invalid"): + cli._state_dependencies({"dependencies": []}) + with pytest.raises(cli.OperationalError, match="dependencies are invalid"): + cli._state_dependencies({"dependencies": {"permissionUsers": {}}}) + with pytest.raises(cli.OperationalError, match="retain principal identity IDs"): + cli._state_dependencies({"dependencies": {"permissionUsers": ["Human"]}}) + with pytest.raises(cli.OperationalError, match="dependencies are invalid"): + cli._state_dependencies( + { + "dependencies": { + "permissionUsers": [{"type": "User", "name": "Human", "id": ""}] + } + } + ) + valid_version = { + "id": "v1", + "default": True, + "document": DOCUMENT, + } + with pytest.raises(cli.OperationalError, match="versions are invalid"): + cli._versions_match( + {"versions": [valid_version]}, + {"versions": [{**valid_version, "id": None}]}, + ) + + +def test_recovery_checkpoint_helpers_reject_states_outside_exact_path() -> None: + base = cli._policy_state(record(), dependencies=PolicyDependencies()) + + wrong_document = json.loads(json.dumps(base)) + wrong_document["versions"][0]["document"] = { + "Version": "2012-10-17", + "Statement": [], + } + assert not cli._versions_subset(base, wrong_document) + assert not cli._identity_matches( + cli._absent_state(ARN, "AgentRead", "/hacksaws/"), base + ) + wrong_path = {**base, "path": "/other/"} + assert not cli._identity_matches(wrong_path, base) + assert not cli._valid_existing_checkpoint(wrong_path, base, base) + + role_dependency = PolicyDependencies( + permission_roles=(EntityReference("Role", "Reader", "R1"),) + ) + with_dependency = cli._policy_state(record(), dependencies=role_dependency) + assert not cli._valid_existing_checkpoint(base, base, with_dependency) + assert not cli._valid_existing_checkpoint(with_dependency, base, base) + + extra_tag = json.loads(json.dumps(base)) + extra_tag["tags"].append({"Key": "concurrent", "Value": "yes"}) + assert not cli._valid_existing_checkpoint(extra_tag, base, base) + changed_tag = json.loads(json.dumps(base)) + changed_tag["tags"][0]["Value"] = "someone-else" + assert not cli._valid_existing_checkpoint(changed_tag, base, base) + assert not cli._valid_existing_checkpoint(wrong_document, base, base) + + version_two = { + "id": "v2", + "default": False, + "document": {"Version": "2012-10-17", "Statement": []}, + } + two_versions = {**base, "versions": [*cli._state_versions(base), version_two]} + missing_shared = {**base, "versions": [version_two]} + assert not cli._valid_existing_checkpoint( + missing_shared, two_versions, two_versions + ) + absent = cli._absent_state(ARN, "AgentRead", "/hacksaws/") + assert not cli._valid_transition_checkpoint(absent, absent, absent) + assert not cli._states_match( + absent, + cli._absent_state("arn:aws:iam::123456789012:policy/Other", "Other", "/"), + ) + + +def test_recovery_helper_fail_closed_and_idempotent_edges( + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected_context = context() + fake = service() + expected = cli._policy_state(record(), dependencies=PolicyDependencies()) + + monkeypatch.setattr(cli, "_policy_exists", lambda *_args: False) + cli._delete_live_policy(selected_context, fake, ARN, expected) + monkeypatch.setattr(cli, "_policy_exists", lambda *_args: True) + monkeypatch.setattr(cli, "_live_policy_state", lambda *_args: expected) + with pytest.raises(PolicyDriftError, match="before deletion"): + cli._delete_live_policy( + selected_context, fake, ARN, {**expected, "path": "/drift/"} + ) + + all_default_versions = tuple( + PolicyVersionRecord( + version_id=f"v{number}", + is_default=True, + created_at=NOW, + document={"Version": "2012-10-17", "Statement": []}, + ) + for number in range(1, 6) + ) + with pytest.raises(cli.OperationalError, match="No nondefault"): + cli._ensure_version_capacity( + selected_context, + replace(record(), versions=all_default_versions), + set(), + ) + + cli._restore_dependencies(selected_context, fake, ARN, None) + dependency = PolicyDependencies( + permission_users=(EntityReference("User", "Human", "U1"),) + ) + fake.policy_dependencies.return_value = dependency + cli._restore_dependencies( + selected_context, fake, ARN, cli._dependency_payload(dependency) + ) + selected_context.iam.get_user.side_effect = ClientError( + {"Error": {"Code": "NoSuchEntity", "Message": "missing"}}, "GetUser" + ) + with pytest.raises(PolicyDriftError, match="missing user"): + cli._verify_principal_identity(selected_context, "User", "Missing", "U1") + + with pytest.raises(cli.OperationalError, match="exact expected and target"): + cli._reconcile_policy({}, selected_context) + with pytest.raises(cli.OperationalError, match="state ARNs do not match"): + cli._reconcile_policy( + { + "expected": cli._absent_state(ARN, "AgentRead", "/hacksaws/"), + "target": cli._absent_state( + "arn:aws:iam::123456789012:policy/Other", "Other", "/" + ), + }, + selected_context, + ) + + monkeypatch.setattr(cli, "_service", lambda _context: fake) + monkeypatch.setattr(cli, "_live_policy_state", lambda *_args: expected) + target = {**expected, "versions": []} + with pytest.raises(cli.OperationalError, match="no default document"): + cli._reconcile_policy( + {"expected": expected, "target": target}, selected_context + ) + + +def test_policy_adapter_fail_closed_helper_branches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert ( + cli._metadata_mode(argparse.Namespace(metadata=None, metadata_file=Path("x"))) + is cli.MetadataMode.SIDECAR + ) + terminal = StringIO() + terminal.isatty = lambda: True + monkeypatch.setattr(cli.sys, "stdin", terminal) + monkeypatch.setattr(cli._configs, "json_output_enabled", lambda: False) + monkeypatch.setattr("builtins.input", lambda _prompt: "AgentRead") + assert cli._confirm_exact( + argparse.Namespace(yes=False), "Delete policy?", "AgentRead" + ) + create_plan = PolicyChangePlan( + OperationPlan("create", ChangeAction.CREATE, "Create.", ()), + None, + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + (), + ) + assert cli._semantic_diff(service(), create_plan) == [] + with pytest.raises(ValueError, match="requires POLICY FILE"): + cli._loaded_update( + argparse.Namespace(from_stored=None, policy_or_file=None, file=None) + ) diff --git a/hacksaws/tests/test_iam_recovery_security.py b/hacksaws/tests/test_iam_recovery_security.py new file mode 100644 index 0000000..0f5c404 --- /dev/null +++ b/hacksaws/tests/test_iam_recovery_security.py @@ -0,0 +1,592 @@ +"""Security and crash-safety contracts for shared IAM recovery.""" + +# Tests intentionally simulate failures, prompts, and secret-shaped field rejection. +# ruff: noqa: SIM117, T201, TRY003 + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Iterator +from collections.abc import Mapping +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _iam_cli +from hacksaws import _iam_policy_cli +from hacksaws import _iam_recovery +from hacksaws import _sessions + + +@pytest.fixture(autouse=True) +def isolated_recovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[None]: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + _configs.configure_output() + _iam_recovery.clear_handlers() + yield + _configs.configure_output() + + +def _context(account: str = "123456789012") -> SimpleNamespace: + return SimpleNamespace(account_id=account, partition="aws") + + +def _handler( + calls: list[tuple[str, object]], + *, + fail_forward: list[bool] | None = None, + fail_compensation: list[bool] | None = None, +) -> None: + def forward(payload: object, _context: object) -> None: + calls.append(("forward", payload)) + if fail_forward and fail_forward.pop(0): + raise RuntimeError("simulated forward crash") + + def compensate(payload: object, _context: object) -> None: + calls.append(("compensate", payload)) + if fail_compensation and fail_compensation.pop(0): + raise RuntimeError("simulated compensation crash") + + _iam_recovery.register_handler( + "policy", "mutation", forward=forward, compensate=compensate + ) + + +def test_journal_schema_lifecycle_and_credential_rejection() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "publish", journal_id="operation-1" + ) + step = handle.record_before_mutation( + "mutation", + forward={"policyArn": "arn:aws:iam::123456789012:policy/Test"}, + compensation={"deleteVersion": "v2"}, + ) + journal = _iam_recovery.get_journal(handle.id) + assert journal["schemaVersion"] == 1 + assert journal["serviceType"] == "policy" + assert journal["accountId"] == "123456789012" + assert journal["steps"][0]["status"] == "pending" + with pytest.raises(_configs.OperationalError, match="pending steps"): + handle.finish() + handle.mark_completed(step) + handle.finish() + assert _iam_recovery.get_journal(handle.id)["status"] == "completed" + with pytest.raises(_configs.OperationalError, match="credential field"): + _iam_recovery.begin_journal( + "policy", "123456789012", "unsafe", journal_id="unsafe" + ).record_before_mutation( + "mutation", + forward={"aws_secret_access_key": "never-write-this"}, + compensation={}, + ) + persisted = _iam_recovery._journal_path(handle.id).read_text(encoding="utf-8") + assert "selected-secret" not in persisted + + +def test_continue_resumes_only_pending_steps_after_crash() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "update", journal_id="resume" + ) + completed = handle.record_before_mutation( + "mutation", forward={"number": 1}, compensation={"number": 1} + ) + handle.mark_completed(completed) + pending = handle.record_before_mutation( + "mutation", forward={"number": 2}, compensation={"number": 2} + ) + + recovered = _iam_recovery.continue_journal(handle.id, _context()) + + assert calls == [("forward", {"number": 2})] + assert recovered["status"] == "completed" + assert ( + next(step for step in recovered["steps"] if step["id"] == pending)["status"] + == "completed" + ) + + +def test_forward_failure_is_durable_and_retryable() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls, fail_forward=[True, False]) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "retry", journal_id="forward-failure" + ) + handle.record_before_mutation( + "mutation", forward={"attempt": 1}, compensation={"undo": 1} + ) + with pytest.raises(_configs.OperationalError, match="forward step"): + _iam_recovery.continue_journal(handle.id, _context()) + failed = _iam_recovery.get_journal(handle.id) + assert failed["status"] == "failed" + assert failed["steps"][0]["status"] == "pending" + assert failed["failure"]["type"] == "RuntimeError" + recovered = _iam_recovery.continue_journal(handle.id, _context()) + assert recovered["status"] == "completed" + assert len(calls) == 2 + + +def test_rollback_compensates_completed_steps_in_reverse_and_can_resume() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls, fail_compensation=[True, False]) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "rollback", journal_id="rollback" + ) + first = handle.record_before_mutation( + "mutation", forward={"number": 1}, compensation={"number": 1} + ) + handle.mark_completed(first) + second = handle.record_before_mutation( + "mutation", forward={"number": 2}, compensation={"number": 2} + ) + handle.mark_completed(second) + handle.record_before_mutation( + "mutation", forward={"number": 3}, compensation={"number": 3} + ) + with pytest.raises(_configs.OperationalError, match="compensation step"): + _iam_recovery.rollback_journal(handle.id, _context()) + assert _iam_recovery.get_journal(handle.id)["status"] == "failed" + + recovered = _iam_recovery.rollback_journal(handle.id, _context()) + + assert calls == [ + ("compensate", {"number": 3}), + ("compensate", {"number": 3}), + ("compensate", {"number": 2}), + ("compensate", {"number": 1}), + ] + assert recovered["status"] == "rolled_back" + assert [step["status"] for step in recovered["steps"]] == [ + "rolled_back", + "rolled_back", + "rolled_back", + ] + + +def test_rollback_compensates_crash_after_aws_before_mark_and_noop() -> None: + resources: set[str] = set() + calls: list[str] = [] + + def compensate(payload: Mapping[str, object], _context: object) -> None: + name = str(payload["name"]) + calls.append(name) + resources.discard(name) + + _iam_recovery.register_handler( + "policy", + "delete-if-present", + forward=lambda _payload, _context: None, + compensate=compensate, + ) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "ambiguous-crash", journal_id="ambiguous" + ) + handle.record_before_mutation( + "delete-if-present", + forward={"name": "never-created"}, + compensation={"name": "never-created"}, + ) + handle.record_before_mutation( + "delete-if-present", + forward={"name": "aws-applied"}, + compensation={"name": "aws-applied"}, + ) + # Simulate AWS success followed by a process crash before mark_completed(). + resources.add("aws-applied") + + recovered = _iam_recovery.rollback_journal(handle.id, _context()) + + assert calls == ["aws-applied", "never-created"] + assert resources == set() + assert recovered["status"] == "rolled_back" + assert [step["status"] for step in recovered["steps"]] == [ + "rolled_back", + "rolled_back", + ] + + +def test_account_binding_whitelist_and_corrupt_diagnostics() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "bound", journal_id="bound" + ) + handle.record_before_mutation( + "mutation", forward={"safe": True}, compensation={"safe": True} + ) + with pytest.raises(_configs.OperationalError, match="do not match") as mismatch: + _iam_recovery.continue_journal(handle.id, _context("999999999999")) + assert mismatch.value.details == { + "journalAccountId": "123456789012", + "callerAccountId": "999999999999", + } + _iam_recovery.clear_handlers() + with pytest.raises(_configs.OperationalError, match="No whitelisted"): + _iam_recovery.continue_journal(handle.id, _context()) + + corrupt = _iam_recovery.recovery_root() / "corrupt.json" + corrupt.write_text("{not-json", encoding="utf-8") + listed = _iam_recovery.list_journals() + assert ( + next(item for item in listed if item["id"] == "corrupt")["status"] == "corrupt" + ) + with pytest.raises(_configs.OperationalError, match="Unable to read") as error: + _iam_recovery.get_journal("corrupt") + assert error.value.repairs + + +@pytest.mark.parametrize("operation", ["continue", "rollback"]) +def test_generic_recovery_requires_exact_recorded_partition(operation: str) -> None: + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "partition-bound", journal_id=f"bound-{operation}" + ) + assert _iam_recovery.get_journal(handle.id)["partition"] == "aws" + action = ( + _iam_recovery.continue_journal + if operation == "continue" + else _iam_recovery.rollback_journal + ) + with pytest.raises( + _configs.OperationalError, match="journal partition" + ) as mismatch: + action( + handle.id, + SimpleNamespace(account_id="123456789012", partition="aws-cn"), + ) + assert mismatch.value.details == { + "journalPartition": "aws", + "callerPartition": "aws-cn", + } + + path = _iam_recovery._journal_path(handle.id) + journal = json.loads(path.read_text(encoding="utf-8")) + journal.pop("partition") + path.write_text(json.dumps(journal), encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="no recorded AWS partition"): + action(handle.id, _context()) + + +def test_lock_prevents_concurrent_journal_transition() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "locked", journal_id="locked" + ) + with _iam_recovery._locked(handle.id): + with pytest.raises(_configs.OperationalError, match="busy"): + with _iam_recovery._locked(handle.id, timeout=0): + pass + + +def test_json_mode_is_noninteractive_single_envelope_and_preserves_details( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + legacy_called = False + + def legacy_recovery() -> None: + nonlocal legacy_called + legacy_called = True + + def noisy_dispatch(_args: argparse.Namespace) -> _configs.Result: + print("incidental stdout") + print("incidental stderr", file=sys.stderr) + input("must never reach the terminal: ") + raise _configs.OperationalError( + "structured failure", + data={"journalId": "x"}, + details={"step": "create"}, + repairs=["retry with --yes"], + ) + + monkeypatch.setattr(_sessions, "recover_journal", legacy_recovery) + monkeypatch.setattr(_iam_cli, "dispatch", noisy_dispatch) + result = _cli.console_main(["iam", "recovery", "list", "--json"]) + captured = capsys.readouterr() + assert result.code == "OPERATIONAL_ERROR" + assert captured.out == "" + envelope = json.loads(captured.err) + assert envelope["error"] == { + "message": "Error: structured failure", + "exitCode": 1, + "data": {"journalId": "x"}, + "details": {"step": "create"}, + "repairs": ["retry with --yes"], + } + assert not legacy_called + + +def test_cli_recovery_requires_identifier_with_structured_repair( + capsys: pytest.CaptureFixture[str], +) -> None: + result = _cli.console_main(["iam", "recovery", "get", "--json"]) + envelope = json.loads(capsys.readouterr().err) + assert result.exit_code == _configs.EXIT_ERROR + assert envelope["error"]["repairs"] == [ + "Run 'hacksaws iam recovery list' to find journal IDs." + ] + + +def test_payload_registration_and_begin_validation_edges() -> None: + assert _iam_recovery._safe_payload([1, (True, None)]) == [1, [True, None]] + with pytest.raises(_configs.OperationalError, match="keys must be text"): + _iam_recovery._safe_payload({1: "bad"}) + with pytest.raises(_configs.OperationalError, match="only JSON values"): + _iam_recovery._safe_payload(object()) + with pytest.raises(_configs.OperationalError, match="Invalid IAM recovery journal"): + _iam_recovery.get_journal("../escape") + with pytest.raises(ValueError, match="portable IDs"): + _iam_recovery.register_handler( + "bad service", + "handler", + forward=lambda _p, _c: None, + compensate=lambda _p, _c: None, + ) + calls: list[tuple[str, object]] = [] + _handler(calls) + with pytest.raises(ValueError, match="already registered"): + _handler(calls) + with pytest.raises(_configs.OperationalError, match="service type"): + _iam_recovery.begin_journal("bad service", "123456789012", "bad") + with pytest.raises(_configs.OperationalError, match="account ID"): + _iam_recovery.begin_journal("policy", "not-account", "bad") + generated = _iam_recovery.begin_journal("policy", "123456789012", "generated") + assert len(generated.id) == 32 + with pytest.raises(_configs.OperationalError, match="already exists"): + _iam_recovery.begin_journal( + "policy", "123456789012", "duplicate", journal_id=generated.id + ) + with pytest.raises(_configs.OperationalError, match="was not found"): + _iam_recovery.get_journal("missing") + + +def test_schema_validation_diagnostics_cover_each_corrupt_shape() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "schema", journal_id="schema" + ) + path = _iam_recovery._journal_path(handle.id) + valid = _iam_recovery.get_journal(handle.id) + variants: list[tuple[object, str]] = [ + ({}, "invalid schema"), + ({**valid, "schemaVersion": 99}, "unsupported schema"), + ({**valid, "status": "unknown"}, "invalid status or steps"), + ({**valid, "steps": [{}]}, "invalid step"), + ( + { + **valid, + "steps": [ + { + "id": "x", + "handler": "mutation", + "status": "unknown", + "forward": {}, + "compensation": {}, + } + ], + }, + "invalid step status", + ), + ] + for document, message in variants: + path.write_text(json.dumps(document), encoding="utf-8") + with pytest.raises(_configs.OperationalError, match=message): + _iam_recovery.get_journal(handle.id) + path.write_text(json.dumps(valid), encoding="utf-8") + + +def test_manual_failure_illegal_transitions_and_stale_lock() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "manual", journal_id="manual" + ) + step = handle.record_before_mutation( + "mutation", forward={"step": 1}, compensation={"step": 1} + ) + handle.mark_failure(RuntimeError("manual crash"), step_id=step) + failed = _iam_recovery.get_journal(handle.id) + assert failed["status"] == "failed" + assert failed["steps"][0]["failure"]["type"] == "RuntimeError" + with pytest.raises(_configs.OperationalError, match="was not found"): + handle.mark_completed("missing-step") + handle.mark_completed(step) + handle.finish() + with pytest.raises(_configs.OperationalError, match="Cannot append"): + handle.record_before_mutation( + "mutation", forward={"late": True}, compensation={} + ) + + other = _iam_recovery.begin_journal( + "policy", "123456789012", "unregistered", journal_id="unregistered" + ) + with pytest.raises(_configs.OperationalError, match="not registered"): + other.record_before_mutation("missing", forward={}, compensation={}) + + lock_path = _iam_recovery.recovery_root() / ".locks" / "stale.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.write_text("not-a-pid\n", encoding="ascii") + with _iam_recovery._locked("stale", timeout=0): + assert lock_path.exists() + assert not lock_path.exists() + + +def test_marking_rolled_back_step_completed_is_rejected() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "rolled", journal_id="rolled" + ) + step = handle.record_before_mutation( + "mutation", forward={"step": 1}, compensation={"step": 1} + ) + handle.mark_completed(step) + _iam_recovery.rollback_journal(handle.id, _context()) + with pytest.raises(_configs.OperationalError, match="is rolled back"): + handle.mark_completed(step) + with pytest.raises(_configs.OperationalError, match="do not match"): + _iam_recovery.rollback_journal(handle.id, _context("999999999999")) + + +def test_central_recovery_registers_policy_handler_after_restart( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _iam_policy_cli.ensure_recovery_handlers() + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "restart", journal_id="policy-restart" + ) + handle.record_before_mutation( + "reconcile", + forward={"state": "forward"}, + compensation={"state": "before"}, + ) + _iam_recovery.clear_handlers() + calls: list[object] = [] + monkeypatch.setattr( + _iam_policy_cli, + "_reconcile_policy", + lambda payload, _context: calls.append(payload), + ) + monkeypatch.setattr(_iam_cli.IamCommandContext, "create", lambda _args: _context()) + + result = _iam_cli.recovery_result( + argparse.Namespace( + recovery_action="continue", + journal_id=handle.id, + ) + ) + + assert result.code == "IAM_RECOVERY_CONTINUE" + assert calls == [{"state": "forward"}] + assert isinstance(result.data, dict) + assert result.data["status"] == "completed" + + +def test_invalid_journal_id_cannot_touch_escape_target() -> None: + directory = _iam_recovery.recovery_root() + directory.mkdir(parents=True) + victim = directory / "victim.lock" + victim.write_text("not-a-pid\n", encoding="ascii") + before = victim.read_bytes() + + for action in ( + lambda: _iam_recovery.get_journal("../victim"), + lambda: _iam_recovery.begin_journal( + "policy", "123456789012", "escape", journal_id="../victim" + ), + ): + with pytest.raises(_configs.OperationalError, match="Invalid IAM recovery"): + action() + + assert victim.read_bytes() == before + assert not (directory / ".locks").exists() + assert sorted(path.name for path in directory.iterdir()) == ["victim.lock"] + + +def test_embedded_journal_id_mismatch_is_rejected_before_execution() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "identity", journal_id="requested" + ) + handle.record_before_mutation( + "mutation", forward={"safe": True}, compensation={"safe": True} + ) + path = _iam_recovery._journal_path(handle.id) + tampered = json.loads(path.read_text(encoding="utf-8")) + tampered["id"] = "different" + path.write_text(json.dumps(tampered), encoding="utf-8") + before = path.read_bytes() + + with pytest.raises(_configs.OperationalError, match="does not match its filename"): + _iam_recovery.continue_journal(handle.id, _context()) + + assert calls == [] + assert path.read_bytes() == before + assert not _iam_recovery._journal_path("different").exists() + + +@pytest.mark.parametrize("journal_status", ["rolling_back", "rolled_back"]) +def test_continue_rejects_journals_that_entered_rollback( + journal_status: str, +) -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "state", journal_id=f"state-{journal_status}" + ) + handle.record_before_mutation( + "mutation", forward={"step": 1}, compensation={"step": 1} + ) + path = _iam_recovery._journal_path(handle.id) + document = json.loads(path.read_text(encoding="utf-8")) + document["status"] = journal_status + path.write_text(json.dumps(document), encoding="utf-8") + before = path.read_bytes() + + with pytest.raises(_configs.OperationalError, match="entered rollback"): + _iam_recovery.continue_journal(handle.id, _context()) + + assert calls == [] + assert path.read_bytes() == before + + +def test_continue_rejects_rolled_back_step_mixture_and_completed_pending() -> None: + calls: list[tuple[str, object]] = [] + _handler(calls) + handle = _iam_recovery.begin_journal( + "policy", "123456789012", "mixture", journal_id="mixture" + ) + handle.record_before_mutation( + "mutation", forward={"step": 1}, compensation={"step": 1} + ) + handle.record_before_mutation( + "mutation", forward={"step": 2}, compensation={"step": 2} + ) + path = _iam_recovery._journal_path(handle.id) + mixture = json.loads(path.read_text(encoding="utf-8")) + mixture["status"] = "failed" + mixture["steps"][0]["status"] = "rolled_back" + path.write_text(json.dumps(mixture), encoding="utf-8") + before = path.read_bytes() + with pytest.raises(_configs.OperationalError, match="entered rollback"): + _iam_recovery.continue_journal(handle.id, _context()) + assert calls == [] + assert path.read_bytes() == before + + mixture["status"] = "completed" + mixture["steps"][0]["status"] = "completed" + path.write_text(json.dumps(mixture), encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="contains pending steps"): + _iam_recovery.continue_journal(handle.id, _context()) + assert calls == [] diff --git a/hacksaws/tests/test_iam_role_cli.py b/hacksaws/tests/test_iam_role_cli.py new file mode 100644 index 0000000..dae8cec --- /dev/null +++ b/hacksaws/tests/test_iam_role_cli.py @@ -0,0 +1,1494 @@ +"""Focused command-adapter tests for IAM role workflows.""" + +# ruff: noqa: ANN401, ARG005, D101, D102, D107, FBT002, FBT003, PT018 + +from __future__ import annotations + +import argparse +import json +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml +from botocore.exceptions import ClientError +from botocore.exceptions import EndpointConnectionError + +from hacksaws import _configs +from hacksaws import _iam_managed_policies as managed +from hacksaws import _iam_recovery as recovery +from hacksaws import _iam_role_cli as cli +from hacksaws import _iam_roles as roles +from hacksaws._configs import OperationalError + +ACCOUNT_ID = "123456789012" +CALLER = f"arn:aws:iam::{ACCOUNT_ID}:user/scott" +ROLE_ARN = f"arn:aws:iam::{ACCOUNT_ID}:role/hacksaws/Agent" +TRUST = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + } + ], +} +POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "logs:Get*", "Resource": "x"}], +} + + +def role_snapshot(**overrides: Any) -> roles.RoleSnapshot: + values: dict[str, Any] = { + "name": "Agent", + "arn": ROLE_ARN, + "path": "/hacksaws/", + "trust": TRUST, + "description": "agent", + "tags": {roles.MANAGED_TAG: "true", roles.OWNER_TAG: CALLER}, + "attached_policies": ("arn:aws:iam::aws:policy/ReadOnlyAccess",), + "inline_policies": ("Read",), + "inline_policy_documents": {"Read": POLICY}, + "role_id": "AIDAEXAMPLE", + } + values.update(overrides) + return roles.RoleSnapshot(**values) + + +class FakeService: + def __init__(self, role: roles.RoleSnapshot | None = None) -> None: + self.role = role or role_snapshot() + self.inline = dict(self.role.inline_policy_documents) + + def get_role(self, name: str) -> roles.RoleSnapshot: + if name != self.role.name: + raise client_error("NoSuchEntity") + return self.role + + def list_roles( + self, *, path_prefix: str = "/hacksaws/" + ) -> tuple[roles.RoleSnapshot, ...]: + assert path_prefix == "/" + return (self.role,) + + def get_trust(self, name: str) -> dict[str, Any]: + assert name == self.role.name + return dict(self.role.trust) + + def list_inline_policies(self, name: str) -> tuple[str, ...]: + assert name == self.role.name + return tuple(self.inline) + + def get_inline_policy(self, name: str, policy: str) -> dict[str, Any]: + assert name == self.role.name + if policy not in self.inline: + raise client_error("NoSuchEntity") + return dict(self.inline[policy]) + + +class Paginator: + def __init__(self, pages: list[dict[str, Any]]) -> None: + self.pages = pages + self.calls: list[dict[str, Any]] = [] + + def paginate(self, **params: Any) -> list[dict[str, Any]]: + self.calls.append(params) + return self.pages + + +class FakeIam: + def __init__(self) -> None: + self.policy_pages = Paginator( + [ + { + "Policies": [ + { + "PolicyName": "ReadOnlyAccess", + "Arn": "arn:aws:iam::aws:policy/ReadOnlyAccess", + } + ] + } + ] + ) + self.group_pages = Paginator([{"AttachedPolicies": []}]) + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.group_policy_exists = False + + def get_paginator(self, name: str) -> Paginator: + return self.policy_pages if name == "list_policies" else self.group_pages + + def get_user(self, **params: Any) -> dict[str, Any]: + self.calls.append(("get_user", params)) + return {"User": {"Arn": f"arn:aws:iam::{ACCOUNT_ID}:user/{params['UserName']}"}} + + def get_role(self, **params: Any) -> dict[str, Any]: + self.calls.append(("get_role", params)) + return { + "Role": { + "RoleName": params["RoleName"], + "Arn": f"arn:aws:iam::{ACCOUNT_ID}:role/team/{params['RoleName']}", + } + } + + def get_group(self, **params: Any) -> dict[str, Any]: + self.calls.append(("get_group", params)) + return {"Users": []} + + def get_policy(self, **params: Any) -> dict[str, Any]: + if not self.group_policy_exists: + raise client_error("NoSuchEntity") + return {"Policy": {"DefaultVersionId": "v1"}} + + def get_policy_version(self, **params: Any) -> dict[str, Any]: + del params + return { + "PolicyVersion": { + "Document": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "HacksawsGroupAssumeRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": ROLE_ARN, + } + ], + } + } + } + + def list_policy_tags(self, **params: Any) -> dict[str, Any]: + del params + return { + "Tags": ( + [ + {"Key": "hacksaws:managed-by", "Value": "hacksaws"}, + {"Key": "hacksaws:resource-kind", "Value": "managed-policy"}, + {"Key": "hacksaws:resource-id", "Value": "group-Agents"}, + ] + if self.group_policy_exists + else [] + ) + } + + +class FakeSts: + def __init__(self, fail: bool = False) -> None: + self.fail = fail + self.calls: list[dict[str, Any]] = [] + + def assume_role(self, **params: Any) -> dict[str, Any]: + self.calls.append(params) + if self.fail: + raise client_error("AccessDenied") + return {"PackedPolicySize": 1, "Credentials": {"SecretAccessKey": "hidden"}} + + +class Tty: + def __init__(self, tty: bool) -> None: + self.tty = tty + + def isatty(self) -> bool: + return self.tty + + +def client_error(code: str) -> ClientError: + return ClientError({"Error": {"Code": code, "Message": code}}, "operation") + + +def context(*, iam: Any | None = None, sts: Any | None = None) -> Any: + return SimpleNamespace( + iam=iam or FakeIam(), + sts=sts or FakeSts(), + account_id=ACCOUNT_ID, + partition="aws", + arn=CALLER, + ) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser() + cli.register(result) + return result + + +def parse(arguments: list[str]) -> argparse.Namespace: + return parser().parse_args(arguments) + + +@pytest.fixture +def configured(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + data = { + "accounts": {"prod": {"id": ACCOUNT_ID, "partition": "aws"}}, + "iam": {"path": "/hacksaws/"}, + "naming": { + "global": { + "case": "Pascal", + "prefix": "", + "suffix": "", + "enforcement": "off", + }, + "resources": {}, + "accounts": {}, + "account_resources": {}, + }, + } + monkeypatch.setattr(cli._state, "load_config", lambda: data) + return data + + +@pytest.fixture +def harness( + monkeypatch: pytest.MonkeyPatch, configured: dict[str, Any] +) -> tuple[FakeService, list[roles.MutationPlan]]: + del configured + service = FakeService() + plans: list[roles.MutationPlan] = [] + monkeypatch.setattr(cli, "_service", lambda _context: service) + monkeypatch.setattr( + cli, + "_execute", + lambda plan, _context, _args: plans.append(plan) or roles.ExecutionJournal(), + ) + monkeypatch.setattr( + cli, + "_owned_publish_attach_plan", + lambda role, policy_name, document, path, _context: roles.MutationPlan( + "policy-publish-attach", + (role.arn, f"arn:aws:iam::{ACCOUNT_ID}:policy{path}{policy_name}"), + ( + roles.Operation( + "managed_policy", + "publish_owned_policy", + {"PolicyDocument": dict(document)}, + ), + *roles.plan_attach_policy( + role.name, + f"arn:aws:iam::{ACCOUNT_ID}:policy{path}{policy_name}", + current=role, + ).operations, + ), + ), + ) + return service, plans + + +@pytest.mark.parametrize( + ("arguments", "command", "nested"), + [ + (["create", "Agent", "--trust-caller"], "create", None), + (["get", "Agent"], "get", None), + (["list", "Agent*", "--wide"], "list", None), + (["update", "Agent", "--ttl", "2h"], "update", None), + (["delete", "Agent", "--cascade"], "delete", None), + (["attach", "Agent", "ReadOnlyAccess"], "attach", None), + (["detach", "Agent", "ReadOnlyAccess"], "detach", None), + (["tag", "set", "Agent", "team=platform"], "tag", "set"), + (["inline-policy", "get", "Agent", "Read"], "inline-policy", "get"), + (["trust", "add", "user", "Agent", "scott"], "trust", "add"), + (["trust", "grant", "group", "Agent", "Agents"], "trust", "grant"), + (["trust", "sync", "group-members", "Agents", "Agent"], "trust", "sync"), + ], +) +def test_register_exposes_locked_grammar( + arguments: list[str], command: str, nested: str | None +) -> None: + args = parse(arguments) + assert args.role_command == command + if nested and command == "tag": + assert args.role_tag_action == nested + if nested and command == "inline-policy": + assert args.role_inline_action == nested + + +def test_create_uses_naming_duration_tags_and_exact_caller( + harness: Any, configured: dict[str, Any] +) -> None: + _, plans = harness + configured["naming"]["resources"]["role"] = { + "prefix": "RB-", + "enforcement": "warn", + } + result = cli.dispatch( + parse( + [ + "create", + "agent", + "--trust-caller", + "--ttl", + "2h", + "--tag", + "team=platform", + ] + ), + context(), + ) + assert result is not None and result.code == "IAM_ROLE_CREATED" + assert "Warning" in result.message + operation = plans[0].operations[0] + assert operation.params["RoleName"] == "RB-Agent" + assert operation.params["MaxSessionDuration"] == 7200 + trust = json.loads(operation.params["AssumeRolePolicyDocument"]) + assert trust["Statement"][0]["Principal"]["AWS"] == CALLER + + +def test_role_dry_run_returns_materialized_plan_without_journal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = FakeService() + monkeypatch.setattr(cli, "_service", lambda _context: service) + result = cli.dispatch( + parse(["update", "Agent", "--description", "changed", "--dry-run"]), + context(), + ) + assert result is not None + assert result.code == "IAM_ROLE_DRY_RUN" + assert result.data["dryRun"] is True + assert result.data["operations"][0]["action"] == "update_role" + + +def test_create_noninteractive_requires_explicit_trust( + monkeypatch: pytest.MonkeyPatch, harness: Any +) -> None: + del harness + monkeypatch.setattr(cli.sys, "stdin", Tty(False)) + with pytest.raises(OperationalError, match="Noninteractive"): + cli.dispatch(parse(["create", "Agent"]), context()) + + +def test_get_and_list_default_scope_with_probe(harness: Any) -> None: + service, _ = harness + result = cli.dispatch(parse(["get", ROLE_ARN]), context()) + assert result is not None and result.data["inlinePolicies"]["Read"] == POLICY + sts = FakeSts() + listed = cli.dispatch(parse(["list", "A*", "--wide", "--probe"]), context(sts=sts)) + assert listed is not None and "Legend:" in listed.message + assert listed.data["roles"][0]["probe"] == "ok" + assert "CloudTrail" in listed.message + service.role = role_snapshot(path="/aws-service-role/example/", tags={}) + service.inline = dict(service.role.inline_policy_documents) + service_result = cli.dispatch(parse(["list", "--service"]), context()) + assert service_result is not None and service_result.data["roles"] + + +def test_list_custom_all_pattern_and_failed_probe(harness: Any) -> None: + service, _ = harness + service.role = role_snapshot(tags={}) + custom = cli.dispatch(parse(["list", "--custom"]), context()) + assert custom is not None and custom.data["roles"] + missing = cli.dispatch(parse(["list", "Nope*"]), context()) + assert missing is not None and not missing.data["roles"] + failed = cli.dispatch( + parse(["list", "--all", "--probe"]), context(sts=FakeSts(True)) + ) + assert failed is not None and failed.data["roles"][0]["probe"] == "denied" + + class BrokenSts: + def assume_role(self, **_params: Any) -> object: + raise EndpointConnectionError(endpoint_url="https://sts.invalid") + + indeterminate = cli.dispatch( + parse(["list", "--all", "--probe"]), context(sts=BrokenSts()) + ) + assert indeterminate is not None + assert indeterminate.data["roles"][0]["probe"] == "indeterminate" + + +def test_update_and_delete_layered_safeguards(harness: Any) -> None: + service, plans = harness + updated = cli.dispatch( + parse( + [ + "update", + "Agent", + "--clear-description", + "--clear-permissions-boundary", + "--stl", + "3600", + ] + ), + context(), + ) + assert updated is not None and updated.code == "IAM_ROLE_UPDATED" + service.role = role_snapshot( + path="/aws-service-role/test/", inline_policies=(), inline_policy_documents={} + ) + with pytest.raises(OperationalError, match="Service-linked"): + cli.dispatch(parse(["delete", "Agent", "--yes"]), context()) + service.role = role_snapshot( + attached_policies=(), inline_policies=(), inline_policy_documents={} + ) + captured = cli.dispatch(parse(["delete", "Agent"]), context()) + assert captured is not None and captured.code == "IAM_ROLE_DELETED" + deleted = cli.dispatch(parse(["delete", "Agent", "--yes"]), context()) + assert deleted is not None and deleted.code == "IAM_ROLE_DELETED" + assert plans[-1].operations[-1].action == "delete_role" + + +def test_remote_and_local_attach_and_detach(tmp_path: Path, harness: Any) -> None: + service, plans = harness + service.role = role_snapshot(attached_policies=()) + remote = cli.dispatch(parse(["attach", "Agent", "ReadOnlyAccess"]), context()) + assert remote is not None and plans[-1].operations[0].action == "attach_role_policy" + service.role = role_snapshot() + detached = cli.dispatch(parse(["detach", "Agent", "ReadOnlyAccess"]), context()) + assert ( + detached is not None and plans[-1].operations[0].action == "detach_role_policy" + ) + path = tmp_path / "local.yaml" + path.write_text(yaml.safe_dump(POLICY), encoding="utf-8") + published = cli.dispatch(parse(["attach", "Agent", str(path)]), context()) + assert published is not None and [op.action for op in plans[-1].operations] == [ + "publish_owned_policy", + "attach_role_policy", + ] + inline = cli.dispatch( + parse(["attach", "Agent", str(path), "--inline", "--policy-name", "Local"]), + context(), + ) + assert inline is not None and plans[-1].kind == "inline-policy-put" + + +def test_tags_adopt_release_and_confirmations(harness: Any) -> None: + service, plans = harness + listed = cli.dispatch(parse(["tag", "list", "Agent"]), context()) + assert listed is not None and listed.data["tags"][roles.MANAGED_TAG] == "true" + cli.dispatch(parse(["tag", "set", "Agent", "team=platform"]), context()) + assert plans[-1].operations[0].action == "tag_role" + cli.dispatch(parse(["tag", "remove", "Agent", "team"]), context()) + assert plans[-1].operations[0].action == "untag_role" + service.role = role_snapshot(tags={"team": "platform"}) + adopted = cli.dispatch( + parse(["adopt", "Agent", "--yes", "--owner", "scott"]), context() + ) + assert adopted is not None and plans[-1].kind == "role-adopt" + released = cli.dispatch(parse(["release", "Agent", "--yes"]), context()) + assert released is not None and plans[-1].kind == "role-release" + + +def test_inline_policy_crud_export_and_new_put(tmp_path: Path, harness: Any) -> None: + service, plans = harness + listed = cli.dispatch(parse(["inline-policy", "list", "Agent"]), context()) + assert listed is not None and listed.data["policies"] == ["Read"] + got = cli.dispatch(parse(["inline-policy", "get", "Agent", "Read"]), context()) + assert got is not None and got.data == POLICY + output = tmp_path / "read.yaml" + exported = cli.dispatch( + parse(["inline-policy", "export", "Agent", "Read", "--output", str(output)]), + context(), + ) + assert exported is not None and output.exists() + new_file = tmp_path / "new.json" + new_file.write_text(json.dumps(POLICY), encoding="utf-8") + put = cli.dispatch( + parse(["inline-policy", "put", "Agent", "New", str(new_file)]), context() + ) + assert ( + put is not None + and plans[-1].operations[0].compensate_action == "delete_role_policy" + ) + deleted = cli.dispatch( + parse(["inline-policy", "delete", "Agent", "Read", "--yes"]), context() + ) + assert ( + deleted is not None and plans[-1].operations[0].action == "delete_role_policy" + ) + service.inline["Read"] = POLICY + + +def test_inline_edit_backup_and_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, harness: Any +) -> None: + service, plans = harness + monkeypatch.setenv("EDITOR", "fake --wait") + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + + def editor(command: list[str], **kwargs: Any) -> Any: + del kwargs + path = Path(command[-1]) + changed = {**POLICY, "Statement": []} + path.write_text(yaml.safe_dump(changed), encoding="utf-8") + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(cli, "_editor_runner", editor) + result = cli.dispatch(parse(["inline-policy", "edit", "Agent", "Read"]), context()) + assert result is not None and plans[-1].kind == "inline-policy-put" + assert list((tmp_path / "backups").glob("*.yaml")) + calls = 0 + original = service.get_inline_policy + + def drift(name: str, policy: str) -> dict[str, Any]: + nonlocal calls + calls += 1 + return ( + {"Version": "changed", "Statement": []} + if calls > 1 + else original(name, policy) + ) + + service.get_inline_policy = drift # type: ignore[method-assign] + with pytest.raises(OperationalError, match="changed while"): + cli.dispatch(parse(["inline-policy", "edit", "Agent", "Read"]), context()) + + +def test_trust_get_export_set_edit_and_check( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, harness: Any +) -> None: + _, plans = harness + got = cli.dispatch(parse(["trust", "get", "Agent"]), context()) + assert got is not None and got.data == TRUST + output = tmp_path / "trust.json" + cli.dispatch( + parse( + [ + "trust", + "export", + "Agent", + "--format", + "json", + "-o", + str(output), + "--metadata", + "nested", + ] + ), + context(), + ) + assert json.loads(output.read_text())["metadata"]["name"] == "Agent-trust" + source = tmp_path / "trust.yaml" + source.write_text(yaml.safe_dump(TRUST), encoding="utf-8") + cli.dispatch(parse(["trust", "set", "Agent", str(source)]), context()) + assert plans[-1].kind == "trust-set" + checked = cli.dispatch(parse(["trust", "check", "Agent", "--probe"]), context()) + assert checked is not None and checked.data["probe"]["ok"] is True + monkeypatch.setenv("EDITOR", "fake") + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + monkeypatch.setattr( + cli, "_editor_runner", lambda command, **kwargs: SimpleNamespace(returncode=0) + ) + edited = cli.dispatch(parse(["trust", "edit", "Agent"]), context()) + assert edited is not None and plans[-1].kind == "trust-set" + + +def test_trust_principals_conditions_remove_and_errors(harness: Any) -> None: + _, plans = harness + added = cli.dispatch( + parse( + [ + "trust", + "add", + "user", + "Agent", + "alice", + "--sid", + "Alice", + "--condition", + "StringEquals:aws:PrincipalTag/team=platform", + ] + ), + context(), + ) + assert added is not None and plans[-1].kind == "trust-set" + document = json.loads(plans[-1].operations[0].params["PolicyDocument"]) + assert document["Statement"][-1]["Condition"]["StringEquals"] + removed = cli.dispatch( + parse(["trust", "remove", "user", "Agent", "scott"]), context() + ) + assert removed is not None + with pytest.raises(OperationalError, match="without wildcards"): + cli.dispatch( + parse( + [ + "trust", + "add", + "role", + "Agent", + "Other", + "--condition", + "StringLike:key=*", + ] + ), + context(), + ) + with pytest.raises(OperationalError, match="Configured account"): + cli.dispatch(parse(["trust", "add", "account", "Agent", "missing"]), context()) + + +def test_group_grant_revoke_and_member_snapshots(harness: Any) -> None: + _, plans = harness + granted = cli.dispatch( + parse(["trust", "grant", "group", "Agent", "Agents"]), context() + ) + assert granted is not None and plans[-1].kind == "group-grant" + revoked = cli.dispatch( + parse(["trust", "revoke", "group", "Agent", "Agents"]), context() + ) + assert revoked is not None and plans[-1].kind == "group-revoke" + for action in ("add", "sync", "remove"): + result = cli.dispatch( + parse(["trust", action, "group-members", "Agents", "Agent"]), context() + ) + assert result is not None and plans[-1].kind == "group-snapshot" + + +def test_remaining_group_grants_requires_a_live_attached_exact_aggregate() -> None: + iam = FakeIam() + iam.group_policy_exists = True + arn = f"arn:aws:iam::{ACCOUNT_ID}:policy/hacksaws/hacksaws-Agents-assume-roles" + iam.policy_pages.pages = [ + {"Policies": [{"PolicyName": "hacksaws-Agents-assume-roles", "Arn": arn}]} + ] + assert cli._remaining_group_grants(ROLE_ARN, "excluded", context(iam=iam)) == () + iam.group_pages.pages = [{"AttachedPolicies": [{"PolicyArn": arn}]}] + assert cli._remaining_group_grants(ROLE_ARN, "excluded", context(iam=iam)) == (arn,) + + +def test_group_attachment_drift_fails_before_journaling( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + iam = FakeIam() + group = cli._group_snapshot("Agents", context(iam=iam)) + plan = roles.plan_sync_group_members(group, [ROLE_ARN]) + iam.group_pages.pages = [{"AttachedPolicies": [{"PolicyArn": group.policy_arn}]}] + with pytest.raises(OperationalError, match="attachment changed"): + cli._assert_preconditions(plan, context(iam=iam)) + assert recovery.list_journals() == [] + + +def test_helpers_errors_exports_and_dispatch_normalization( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, configured: dict[str, Any] +) -> None: + assert cli.name == "role" + assert cli.dispatch(argparse.Namespace(), context()) is None + with pytest.raises(OperationalError, match="Invalid IAM role"): + cli._role_name("bad/name", context()) + with pytest.raises(OperationalError, match="does not belong"): + cli._role_name("arn:aws:iam::210987654321:role/Other", context()) + with pytest.raises(OperationalError, match="KEY=VALUE"): + cli._parse_tags(["bad"]) + with pytest.raises(OperationalError, match="more than once"): + cli._parse_tags(["x=1", "x=2"]) + with pytest.raises(OperationalError, match="requires --output"): + cli._export( + POLICY, + SimpleNamespace( + format="yaml", metadata="sidecar", output=None, sidecar=None + ), + "Read", + ) + output = tmp_path / "read.yaml" + sidecar = tmp_path / "meta.yaml" + result = cli._export( + POLICY, + SimpleNamespace( + format="yaml", metadata="sidecar", output=output, sidecar=sidecar + ), + "Read", + ) + assert len(result.data["files"]) == 2 + monkeypatch.delenv("EDITOR", raising=False) + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + with pytest.raises(OperationalError, match="VISUAL"): + cli._edit_document(POLICY, "Read") + configured["naming"]["resources"]["role"] = {"prefix": "X", "enforcement": "error"} + with pytest.raises(OperationalError, match="violates"): + cli._configured_name( + "Agent", + argparse.Namespace( + case=None, prefix=None, suffix=None, naming_enforcement=None + ), + context(), + ) + + +def test_execution_role_reference_case_and_confirmation_helpers( + monkeypatch: pytest.MonkeyPatch, configured: dict[str, Any] +) -> None: + recorder = SimpleNamespace(calls=[]) + + def mutate(**params: Any) -> None: + recorder.calls.append(params) + + recorder.mutate = mutate + plan = roles.MutationPlan( + "test", ("x",), (roles.Operation("iam", "mutate", {"value": 1}),) + ) + roles.execute_plan(plan, lambda _name: recorder) + assert recorder.calls == [{"value": 1}] + with pytest.raises(OperationalError, match="Invalid IAM role ARN"): + cli._role_name("arn:aws:iam::bad:role/X", context()) + assert cli._account_name(context(iam=FakeIam())) == "prod" + configured["accounts"] = {} + assert cli._account_name(context()) is None + assert cli._apply_case("", "snake") == "" + assert cli._apply_case("hello world", "snake") == "hello_world" + assert cli._apply_case("hello world", "kebab") == "hello-world" + monkeypatch.setattr(cli.sys, "stdin", Tty(True)) + monkeypatch.setattr(cli, "_input", lambda _prompt: "Agent") + assert cli._confirm_exact("delete", "Agent", yes=False) + + +def test_document_and_trust_creation_variants( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, harness: Any +) -> None: + del harness + invalid = tmp_path / "invalid.json" + invalid.write_text("{", encoding="utf-8") + with pytest.raises(OperationalError, match="Invalid JSON"): + cli._load_document(invalid, SimpleNamespace(metadata="none", sidecar=None)) + policy = tmp_path / "trust.yaml" + policy.write_text(yaml.safe_dump(TRUST), encoding="utf-8") + args = parse(["create", "Agent", "--trust-policy", str(policy)]) + assert cli._trust_for_create(args, context()) == TRUST + with pytest.raises(OperationalError, match="only one"): + cli._trust_for_create( + parse( + [ + "create", + "Agent", + "--trust-policy", + str(policy), + "--trust-caller", + ] + ), + context(), + ) + monkeypatch.setattr(cli.sys, "stdin", Tty(True)) + interactive = cli._trust_for_create(parse(["create", "Agent"]), context()) + assert interactive["Statement"][0]["Principal"]["AWS"] == CALLER + + +def test_caller_trust_shapes_and_policy_resolution_errors(harness: Any) -> None: + del harness + ctx = context() + assert cli._caller_trust( + role_snapshot(trust={"Statement": TRUST["Statement"][0]}), ctx + ) + assert cli._caller_trust(role_snapshot(trust={"Statement": "bad"}), ctx) is None + denied = role_snapshot( + trust={ + "Statement": [ + {"Effect": "Deny", "Principal": {"AWS": CALLER}}, + { + "Effect": "Allow", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"x": "y"}}, + }, + ] + } + ) + assert cli._caller_trust(denied, ctx) is None + assert cli._resolve_policy_arn("arn:aws:iam::aws:policy/X", ctx).endswith("/X") + empty = FakeIam() + empty.policy_pages = Paginator([{"Policies": []}]) + with pytest.raises(OperationalError, match="not found"): + cli._resolve_policy_arn("Missing", context(iam=empty)) + ambiguous = FakeIam() + ambiguous.policy_pages = Paginator( + [ + { + "Policies": [ + {"PolicyName": "Read", "Arn": "arn:one"}, + {"PolicyName": "Read", "Arn": "arn:two"}, + ] + } + ] + ) + with pytest.raises(OperationalError, match="ambiguous"): + cli._resolve_policy_arn("Read", context(iam=ambiguous)) + + +def test_principal_resolution_forms_and_user_failure( + configured: dict[str, Any], +) -> None: + del configured + ctx = context() + root = f"arn:aws:iam::{ACCOUNT_ID}:root" + assert cli._principal("principal", root, None, ctx).kind == "account" + assert cli._principal("account", "prod", None, ctx).account_id == ACCOUNT_ID + assert ( + cli._principal("account", "210987654321", None, ctx).account_id + == "210987654321" + ) + assert cli._principal("role", "prod:Other", None, ctx).arn.endswith( + "role/team/Other" + ) + + class BadUserIam(FakeIam): + def get_user(self, **params: Any) -> dict[str, Any]: + del params + raise client_error("NoSuchEntity") + + with pytest.raises(OperationalError, match="Unable to resolve IAM user"): + cli._principal("user", "missing", None, context(iam=BadUserIam())) + + +def test_simple_trust_add_group_existing_and_group_errors(harness: Any) -> None: + _, plans = harness + added = cli.dispatch(parse(["trust", "add", "role", "Agent", "Other"]), context()) + assert added is not None + assert plans[-1].kind == "trust-set" + iam = FakeIam() + iam.group_policy_exists = True + snapshot = cli._group_snapshot("Agents", context(iam=iam)) + assert snapshot.exists + assert snapshot.role_arns == (ROLE_ARN,) + + class BadGroupIam(FakeIam): + def get_group(self, **params: Any) -> dict[str, Any]: + del params + raise client_error("NoSuchEntity") + + with pytest.raises(OperationalError, match="Unable to resolve IAM group"): + cli._group_snapshot("Missing", context(iam=BadGroupIam())) + + +def test_inline_missing_cancel_drift_and_probe_denial( + tmp_path: Path, harness: Any +) -> None: + service, _ = harness + with pytest.raises(OperationalError, match="NoSuchEntity"): + cli.dispatch(parse(["inline-policy", "get", "Agent", "Missing"]), context()) + captured = cli.dispatch( + parse(["inline-policy", "delete", "Agent", "Read"]), context() + ) + assert captured is not None + assert captured.code == "IAM_ROLE_INLINE_MUTATED" + source = tmp_path / "new.json" + source.write_text(json.dumps(POLICY), encoding="utf-8") + calls = 0 + original = service.get_inline_policy + + def drift(name: str, policy: str) -> dict[str, Any]: + nonlocal calls + calls += 1 + value = original(name, policy) + return {"Version": "changed", "Statement": []} if calls > 1 else value + + service.get_inline_policy = drift # type: ignore[method-assign] + with pytest.raises(OperationalError, match="changed before"): + cli.dispatch( + parse(["inline-policy", "put", "Agent", "Read", str(source)]), + context(), + ) + checked = cli.dispatch( + parse(["trust", "check", "Agent", "--probe"]), + context(sts=FakeSts(fail=True)), + ) + assert checked is not None + assert checked.data["probe"]["ok"] is False + + +def test_editor_failure_help_cancellations_and_error_normalization( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, harness: Any +) -> None: + del harness + monkeypatch.setenv("EDITOR", "fake") + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + monkeypatch.setattr( + cli, + "_editor_runner", + lambda _command, **_kwargs: SimpleNamespace(returncode=2), + ) + with pytest.raises(OperationalError, match="status 2"): + cli._edit_document(POLICY, "Read") + inline_help = cli.dispatch(parse(["inline-policy"]), context()) + trust_help = cli.dispatch(parse(["trust"]), context()) + assert inline_help is not None and inline_help.exit_code == 2 + assert trust_help is not None and trust_help.exit_code == 2 + ownership = cli.dispatch(parse(["release", "Agent"]), context()) + assert ownership is not None and ownership.code == "IAM_ROLE_OWNERSHIP" + assert cli._dispatch(argparse.Namespace(role_command="unknown"), context()) is None + + monkeypatch.setattr( + cli, + "_dispatch", + lambda _args, _context: (_ for _ in ()).throw(roles.IamRoleError("bad")), + ) + with pytest.raises(OperationalError, match="bad"): + cli.dispatch(argparse.Namespace(), context()) + monkeypatch.setattr( + cli, + "_dispatch", + lambda _args, _context: (_ for _ in ()).throw(client_error("Denied")), + ) + with pytest.raises(OperationalError, match="AWS IAM"): + cli.dispatch(argparse.Namespace(), context()) + + +def test_all_mutations_fail_closed_and_preview_exact_plan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + plan = roles.MutationPlan( + "trust-set", + (ROLE_ARN,), + ( + roles.Operation( + "iam", + "update_assume_role_policy", + {"RoleName": "Agent", "PolicyDocument": json.dumps(TRUST)}, + "update_assume_role_policy", + {"RoleName": "Agent", "PolicyDocument": json.dumps(TRUST)}, + ), + ), + ) + monkeypatch.setattr(cli.sys, "stdin", Tty(False)) + assert not cli._confirm_plan(argparse.Namespace(yes=False), plan) + monkeypatch.setattr(cli.sys, "stdin", Tty(True)) + prompts: list[str] = [] + monkeypatch.setattr(cli, "_input", lambda prompt: prompts.append(prompt) or "no") + assert not cli._confirm_plan(argparse.Namespace(yes=False), plan) + assert "trust-set" in prompts[0] and ROLE_ARN in prompts[0] + monkeypatch.setattr(cli, "_input", lambda _prompt: "yes") + assert cli._confirm_plan(argparse.Namespace(yes=False), plan) + delete_plan = roles.plan_delete_role( + role_snapshot( + attached_policies=(), inline_policies=(), inline_policy_documents={} + ) + ) + assert not cli._confirm_plan(argparse.Namespace(yes=False), delete_plan) + monkeypatch.setattr(cli, "_input", lambda _prompt: "Agent") + assert cli._confirm_plan(argparse.Namespace(yes=False), delete_plan) + _configs.configure_output(json_output=True) + try: + assert not cli._confirm_plan(argparse.Namespace(yes=False), plan) + assert cli._confirm_plan(argparse.Namespace(yes=True), plan) + finally: + _configs.configure_output(json_output=False) + + +class JournalIam: + def __init__(self, *, crash: bool = False) -> None: + self.tags: dict[str, str] = {} + self.crash = crash + self.calls: list[str] = [] + + def tag_role(self, **params: Any) -> None: + self.calls.append("tag_role") + for tag in params["Tags"]: + self.tags[tag["Key"]] = tag["Value"] + if self.crash: + self.crash = False + raise KeyboardInterrupt + + def untag_role(self, **params: Any) -> None: + self.calls.append("untag_role") + for key in params["TagKeys"]: + self.tags.pop(key, None) + + +def test_role_execution_journals_before_aws_and_recovers_crash_windows( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + recovery.clear_handlers() + iam = JournalIam(crash=True) + ctx = context(iam=iam) + plan = roles.plan_put_tags("Agent", {"team": "platform"}, current={}) + with pytest.raises(KeyboardInterrupt): + cli._execute(plan, ctx, argparse.Namespace(yes=True)) + pending = recovery.list_journals() + assert len(pending) == 1 and pending[0]["status"] == "active" + journal_id = str(pending[0]["id"]) + journal = recovery.get_journal(journal_id) + assert journal["partition"] == "aws" + assert journal["steps"][0]["status"] == "pending" + assert iam.tags == {"team": "platform"} + recovery.rollback_journal(journal_id, ctx) + assert iam.tags == {} + assert recovery.get_journal(journal_id)["status"] == "rolled_back" + + iam.crash = True + with pytest.raises(KeyboardInterrupt): + cli._execute(plan, ctx, argparse.Namespace(yes=True)) + second = next( + item for item in recovery.list_journals() if item["status"] == "active" + ) + recovery.continue_journal(str(second["id"]), ctx) + assert iam.tags == {"team": "platform"} + assert recovery.get_journal(str(second["id"]))["status"] == "completed" + + +def test_expected_role_hash_is_enforced_before_journaling( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + before = role_snapshot(description="before") + after = role_snapshot(description="concurrent") + plan = roles.plan_update_role( + before, + roles.RoleSpec( + before.name, + before.trust, + path=before.path, + description="desired", + tags={}, + owner=CALLER, + ), + ) + monkeypatch.setattr(cli, "_service", lambda _context: FakeService(after)) + with pytest.raises(OperationalError, match="changed after planning"): + cli._execute(plan, context(), argparse.Namespace(yes=True)) + assert recovery.list_journals() == [] + + +def test_role_delete_recovery_stops_at_irreversible_commit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + recovery.clear_handlers() + cli.ensure_role_recovery_handlers() + journal = recovery.begin_journal("iam-role", ACCOUNT_ID, "role-delete") + step_id = journal.record_before_mutation( + "delete-role--none", + forward={"params": {"RoleName": "Agent"}}, + compensation={"params": {"RoleName": "Agent", "ExpectedRoleId": "RID"}}, + ) + journal.mark_completed(step_id) + journal.finish() + + class MissingRoleService: + def get_role(self, _name: str) -> roles.RoleSnapshot: + raise client_error("NoSuchEntity") + + monkeypatch.setattr(cli, "_service", lambda _context: MissingRoleService()) + with pytest.raises(OperationalError, match=r"irreversible.*commit point"): + recovery.rollback_journal(journal.id, context()) + + pending = recovery.begin_journal("iam-role", ACCOUNT_ID, "role-delete") + pending.record_before_mutation( + "delete-role--none", + forward={"params": {"RoleName": "Agent"}}, + compensation={"params": {"RoleName": "Agent", "ExpectedRoleId": "AIDAEXAMPLE"}}, + ) + monkeypatch.setattr(cli, "_service", lambda _context: FakeService()) + recovery.rollback_journal(pending.id, context()) + assert recovery.get_journal(pending.id)["status"] == "rolled_back" + + +def test_role_create_receipt_guards_continue_and_rollback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + recovery.clear_handlers() + cli.ensure_role_recovery_handlers() + spec = roles.RoleSpec("Agent", TRUST, owner=CALLER) + plan = roles.plan_create_role(spec) + params = dict(plan.operations[0].params) + + class CreateIam: + def __init__(self, *, crash: bool = False) -> None: + self.exists = False + self.role_id = "AIDACREATED" + self.crash = crash + self.deletes: list[str] = [] + + def create_role(self, **_params: Any) -> dict[str, object]: + if self.exists: + raise client_error("EntityAlreadyExists") + self.exists = True + if self.crash: + self.crash = False + raise KeyboardInterrupt + return {"Role": {"RoleId": self.role_id}} + + def delete_role(self, **request: Any) -> None: + self.deletes.append(str(request["RoleName"])) + self.exists = False + + class ReceiptService: + def __init__(self, iam: CreateIam) -> None: + self.iam = iam + + def get_role(self, _name: str) -> roles.RoleSnapshot: + if not self.iam.exists: + raise client_error("NoSuchEntity") + return role_snapshot( + description=None, + attached_policies=(), + inline_policies=(), + inline_policy_documents={}, + tags={ + roles.MANAGED_TAG: "true", + roles.OWNER_TAG: CALLER, + roles.ORIGIN_TAG: "created", + }, + role_id=self.iam.role_id, + ) + + iam = CreateIam() + ctx = context(iam=iam) + monkeypatch.setattr(cli, "_service", lambda _context: ReceiptService(iam)) + journal = cli._execute(plan, ctx, argparse.Namespace(yes=True)) + assert journal is not None + stored = recovery.get_journal(journal.id) + assert stored["steps"][0]["effect"] == {"roleId": "AIDACREATED"} + assert cli._create_role_with_receipt( + {"params": params, "effect": {"roleId": "AIDACREATED"}}, ctx + ) == {"roleId": "AIDACREATED"} + + exact_iam = CreateIam() + exact_context = context(iam=exact_iam) + monkeypatch.setattr(cli, "_service", lambda _context: ReceiptService(exact_iam)) + exact_journal = cli._execute(plan, exact_context, argparse.Namespace(yes=True)) + assert exact_journal is not None + recovery.rollback_journal(exact_journal.id, exact_context) + recovery.rollback_journal(exact_journal.id, exact_context) + assert exact_iam.deletes == ["Agent"] + + monkeypatch.setattr(cli, "_service", lambda _context: ReceiptService(iam)) + iam.role_id = "AIDASPOOFED" + with pytest.raises(OperationalError, match="compensation step"): + recovery.rollback_journal(journal.id, ctx) + assert iam.deletes == [] + + crashing = CreateIam(crash=True) + crash_context = context(iam=crashing) + monkeypatch.setattr(cli, "_service", lambda _context: ReceiptService(crashing)) + with pytest.raises(KeyboardInterrupt): + cli._execute(plan, crash_context, argparse.Namespace(yes=True)) + pending = next( + item for item in recovery.list_journals() if item["status"] == "active" + ) + pending_data = recovery.get_journal(str(pending["id"])) + assert "effect" not in pending_data["steps"][0] + with pytest.raises(OperationalError, match="forward step"): + recovery.continue_journal(str(pending["id"]), crash_context) + assert crashing.deletes == [] + + +def test_role_create_receipt_allows_exact_rollback_and_rejects_spoofed_continue( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(cli._state, "root", lambda: tmp_path) + recovery.clear_handlers() + cli.ensure_role_recovery_handlers() + spec = roles.RoleSpec("Agent", TRUST, owner=CALLER) + plan = roles.plan_create_role(spec) + params = dict(plan.operations[0].params) + + class ExistingIam: + def __init__(self) -> None: + self.deletes: list[str] = [] + + def create_role(self, **_params: Any) -> object: + raise client_error("EntityAlreadyExists") + + def delete_role(self, **request: Any) -> None: + self.deletes.append(str(request["RoleName"])) + + iam = ExistingIam() + ctx = context(iam=iam) + exact = role_snapshot( + description=None, + attached_policies=(), + inline_policies=(), + inline_policy_documents={}, + tags={ + roles.MANAGED_TAG: "true", + roles.OWNER_TAG: CALLER, + roles.ORIGIN_TAG: "created", + }, + role_id="AIDARECEIPT", + ) + monkeypatch.setattr(cli, "_service", lambda _context: FakeService(exact)) + assert cli._create_role_with_receipt( + {"params": params, "effect": {"roleId": "AIDARECEIPT"}}, ctx + ) == {"roleId": "AIDARECEIPT"} + with pytest.raises(OperationalError, match="no durable AWS RoleId receipt"): + cli._create_role_with_receipt({"params": params}, ctx) + + cli._delete_created_role_with_receipt( + {"params": {"RoleName": "Agent"}, "effect": {"roleId": "AIDARECEIPT"}}, + ctx, + ) + assert iam.deletes == ["Agent"] + + +def test_reserved_tags_and_account_aliases_are_case_insensitive( + configured: dict[str, Any], +) -> None: + with pytest.raises(OperationalError, match="Reserved"): + cli._parse_tags(["hacksaws:managed=false"]) + assert cli._principal("account", "PROD", None, context()).account_id == ACCOUNT_ID + + +def test_owned_publication_rejects_collisions_and_version_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + arn = f"arn:aws:iam::{ACCOUNT_ID}:policy/hacksaws/AgentPolicy" + unmanaged = managed.ManagedPolicyRecord( + managed.ManagedPolicyArn.parse(arn), + "PID", + "AgentPolicy", + "/hacksaws/", + "v1", + 0, + 0, + (), + POLICY, + ) + monkeypatch.setattr(cli, "_managed_service", lambda _context: object()) + monkeypatch.setattr(cli, "_managed_record", lambda _service, _arn: unmanaged) + with pytest.raises(OperationalError, match="not the exact"): + cli._owned_publish_attach_plan( + role_snapshot(attached_policies=()), + "AgentPolicy", + POLICY, + "/hacksaws/", + context(), + ) + + wrong_identity = replace( + unmanaged, + tags=( + managed.Tag("hacksaws:managed-by", "hacksaws"), + managed.Tag("hacksaws:resource-kind", "managed-policy"), + managed.Tag("hacksaws:resource-id", "different-feature-object"), + ), + ) + monkeypatch.setattr(cli, "_managed_record", lambda _service, _arn: wrong_identity) + with pytest.raises( + OperationalError, match=r"different-feature-object|not the exact" + ): + cli._owned_publish_attach_plan( + role_snapshot(attached_policies=()), + "AgentPolicy", + POLICY, + "/hacksaws/", + context(), + ) + + +def test_recovery_iam_call_is_idempotent_and_collision_safe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class IamCalls: + def __init__(self) -> None: + self.failure = "NoSuchEntity" + + def delete_role(self, **_params: Any) -> None: + raise client_error(self.failure) + + def create_role(self, **_params: Any) -> None: + raise client_error("EntityAlreadyExists") + + iam = IamCalls() + ctx = context(iam=iam) + with pytest.raises(OperationalError, match="parameters are invalid"): + cli._iam_call(ctx, "delete_role", {"params": "bad"}) + cli._iam_call(ctx, "delete_role", {"params": {"RoleName": "missing"}}) + iam.failure = "AccessDenied" + with pytest.raises(ClientError): + cli._iam_call(ctx, "delete_role", {"params": {"RoleName": "Agent"}}) + + params = { + "RoleName": "Agent", + "Path": "/hacksaws/", + "AssumeRolePolicyDocument": json.dumps(TRUST), + "MaxSessionDuration": 3600, + "Tags": [ + {"Key": roles.MANAGED_TAG, "Value": "true"}, + {"Key": roles.OWNER_TAG, "Value": CALLER}, + ], + } + monkeypatch.setattr(cli, "_service", lambda _context: FakeService()) + with pytest.raises(ClientError): + cli._iam_call(ctx, "create_role", {"params": params}) + + +def test_owned_publication_create_restore_and_attach_plan_branches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + arn = f"arn:aws:iam::{ACCOUNT_ID}:policy/hacksaws/AgentPolicy" + current_document = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Deny", "Action": "*", "Resource": "*"}], + } + resource_id = f"role-attachment-{roles.document_hash(arn)[:24]}" + versions = tuple( + managed.PolicyVersionRecord( + f"v{index}", + index == 5, + None, + current_document + if index == 5 + else {"Statement": [], "Version": "2012-10-17"}, + ) + for index in range(1, 6) + ) + record = managed.ManagedPolicyRecord( + managed.ManagedPolicyArn.parse(arn), + "PID", + "AgentPolicy", + "/hacksaws/", + "v1", + 0, + 0, + ( + managed.Tag("hacksaws:managed-by", "hacksaws"), + managed.Tag("hacksaws:resource-kind", "managed-policy"), + managed.Tag("hacksaws:resource-id", resource_id), + ), + current_document, + versions, + ) + + class ManagedService: + def plan_publish(self, *_args: Any, **_kwargs: Any) -> object: + return object() + + def policy_dependencies(self, _arn: str) -> managed.PolicyDependencies: + return managed.PolicyDependencies() + + service = ManagedService() + before_versions: list[dict[str, object]] = [ + { + "id": version.version_id, + "default": version.is_default, + "document": version.document, + } + for version in versions + ] + before: dict[str, object] = { + "exists": True, + "arn": arn, + "name": "AgentPolicy", + "path": "/hacksaws/", + "description": None, + "tags": [tag.as_request() for tag in record.tags], + "versions": before_versions, + "dependencies": { + "permissionUsers": [], + "permissionGroups": [], + "permissionRoles": [], + "boundaryUsers": [], + "boundaryRoles": [], + }, + } + after = { + **before, + "versions": [ + *before_versions[1:-1], + {"id": "v5", "default": False, "document": current_document}, + {"id": "pending", "default": True, "document": POLICY}, + ], + } + monkeypatch.setattr(cli, "_managed_service", lambda _context: service) + monkeypatch.setattr(cli, "_managed_record", lambda _service, _arn: record) + monkeypatch.setattr( + cli.policy_cli, "_change_states", lambda _service, _change: (after, before) + ) + plan = cli._owned_publish_attach_plan( + role_snapshot(attached_policies=()), + "AgentPolicy", + POLICY, + "/hacksaws/", + context(), + ) + assert plan.kind == "policy-publish-attach" + assert [operation.client for operation in plan.operations] == [ + "managed_policy", + "iam", + ] + publication = cli._materialize_managed_operation(plan.operations[0], context()) + assert publication.compensate_params is not None + compensation = publication.compensate_params["State"] + assert isinstance(compensation, dict) + assert len(compensation["versions"]) == 5 + assert sum(item["default"] is True for item in compensation["versions"]) == 1 + assert all(item["document"] is not None for item in compensation["versions"]) + + changed_ids = { + **before, + "versions": [ + {**item, "id": f"new-{index}"} for index, item in enumerate(before_versions) + ], + } + assert cli._policy_state_hash(before) == cli._policy_state_hash(changed_ids) + absent = {"exists": False, "arn": arn} + assert cli._policy_state_hash(absent) == cli._policy_state_hash(dict(absent)) + + reconciled: list[dict[str, object]] = [] + observed_states = iter((before, after, after, before)) + monkeypatch.setattr(cli, "_policy_state", lambda *_args: next(observed_states)) + monkeypatch.setattr( + cli.policy_cli, + "_reconcile_policy", + lambda state, _context: reconciled.append(dict(state)), + ) + assert publication.compensate_params is not None + cli._publish_owned_policy(publication.params, context()) + cli._restore_owned_policy(publication.compensate_params, context()) + assert reconciled == [after, before] + + with pytest.raises(OperationalError, match="state is invalid"): + cli._publish_owned_policy({}, context()) + + class CreateService: + def plan_create(self, *_args: Any, **_kwargs: Any) -> object: + return object() + + monkeypatch.setattr(cli, "_managed_service", lambda _context: CreateService()) + monkeypatch.setattr(cli, "_managed_record", lambda _service, _arn: None) + monkeypatch.setattr( + cli.policy_cli, + "_change_states", + lambda _service, _change: (after, absent), + ) + created = cli._materialize_managed_operation(plan.operations[0], context()) + assert created.params["ExpectedStateHash"] == cli._policy_state_hash(absent) + + monkeypatch.setattr( + cli.policy_cli, + "_reconcile_policy", + lambda _state, _context: None, + ) + cli._publish_owned_policy( + { + "State": absent, + "ExpectedStateHash": cli._policy_state_hash(absent), + "ResourceId": resource_id, + }, + context(), + ) + with pytest.raises(OperationalError, match="changed after planning"): + cli._publish_owned_policy( + { + "State": after, + "ExpectedStateHash": "drifted", + "ResourceId": resource_id, + }, + context(), + ) + with pytest.raises(OperationalError, match="reconciliation was incomplete"): + cli._publish_owned_policy( + { + "State": after, + "ExpectedStateHash": cli._policy_state_hash(absent), + "ResourceId": resource_id, + }, + context(), + ) + + +def test_dry_run_editor_does_not_create_policy_backup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("EDITOR", "editor") + monkeypatch.setattr(cli._state, "root", lambda: tmp_path / "state") + monkeypatch.setattr( + cli, + "_editor_runner", + lambda *_args, **_kwargs: SimpleNamespace(returncode=0), + ) + + assert cli._edit_document(POLICY, "Agent-trust", write_backup=False) == POLICY + assert not (tmp_path / "state" / "backups").exists() diff --git a/hacksaws/tests/test_iam_roles.py b/hacksaws/tests/test_iam_roles.py new file mode 100644 index 0000000..bc3ca07 --- /dev/null +++ b/hacksaws/tests/test_iam_roles.py @@ -0,0 +1,599 @@ +"""Focused tests for pure IAM role and trust service contracts.""" + +# ruff: noqa: ANN401, D101, D102, D105, D107 + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from botocore.exceptions import ClientError + +from hacksaws import _iam_roles as roles + +ACCOUNT = roles.AccountRef("prod", "123456789012") +ROLE_ARN = "arn:aws:iam::123456789012:role/hacksaws/Agent" +USER_ARN = "arn:aws:iam::123456789012:user/scott" +TRUST = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": USER_ARN}, + "Action": "sts:AssumeRole", + } + ], +} + + +def snapshot(**overrides: Any) -> roles.RoleSnapshot: + values: dict[str, Any] = { + "name": "Agent", + "arn": ROLE_ARN, + "path": "/hacksaws/", + "trust": TRUST, + "tags": {roles.MANAGED_TAG: "true", "old": "x"}, + } + values.update(overrides) + return roles.RoleSnapshot(**values) + + +class Recorder: + def __init__(self, fail: str | None = None) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.fail = fail + + def __getattr__(self, name: str) -> Any: + def call(**params: Any) -> dict[str, Any]: + self.calls.append((name, params)) + if name == self.fail: + raise RuntimeError(name) + return {} + + return call + + +class Paginator: + def __init__(self, pages: list[dict[str, Any]]) -> None: + self.pages = pages + self.params: dict[str, Any] | None = None + + def paginate(self, **params: Any) -> list[dict[str, Any]]: + self.params = params + return self.pages + + +class ReadClient: + def __init__(self) -> None: + self.fail_once = False + self.calls = 0 + self.paginators = { + "list_attached_role_policies": Paginator( + [ + {"AttachedPolicies": [{"PolicyArn": "arn:policy/one"}]}, + {"AttachedPolicies": [{"PolicyArn": "arn:policy/two"}]}, + ] + ), + "list_role_policies": Paginator( + [{"PolicyNames": ["InlineOne"]}, {"PolicyNames": ["InlineTwo"]}] + ), + "list_instance_profiles_for_role": Paginator( + [{"InstanceProfiles": [{"InstanceProfileName": "profile"}]}] + ), + "list_roles": Paginator( + [ + { + "Roles": [ + { + "RoleName": "Agent", + "Arn": ROLE_ARN, + "Path": "/hacksaws/", + "AssumeRolePolicyDocument": TRUST, + } + ] + } + ] + ), + } + + def get_paginator(self, name: str) -> Paginator: + return self.paginators[name] + + def get_role(self, **params: Any) -> dict[str, Any]: + self.calls += 1 + if self.fail_once and self.calls == 1: + raise ClientError( + {"Error": {"Code": "NoSuchEntity", "Message": "wait"}}, "GetRole" + ) + return { + "Role": { + "RoleName": params["RoleName"], + "Arn": ROLE_ARN, + "Path": "/hacksaws/", + "AssumeRolePolicyDocument": TRUST, + "Description": "agent", + "MaxSessionDuration": 7200, + "PermissionsBoundary": {"PermissionsBoundaryArn": "arn:boundary"}, + "Tags": [{"Key": roles.MANAGED_TAG, "Value": "true"}], + } + } + + def get_role_policy(self, **params: Any) -> dict[str, Any]: + del params + return { + "PolicyDocument": ( + "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%5D%7D" + ) + } + + +def test_document_path_account_and_principal_normalization() -> None: + assert roles.decode_document(json.dumps(TRUST)) == TRUST + assert roles.decode_document("%7B%22Version%22%3A%222012-10-17%22%7D") == { + "Version": "2012-10-17" + } + with pytest.raises(roles.IamRoleError, match="object"): + roles.decode_document([]) + assert roles.normalize_path("/hacksaws/") == "/hacksaws/" + with pytest.raises(roles.IamRoleError, match="begin and end"): + roles.normalize_path("hacksaws") + with pytest.raises(roles.IamRoleError, match="12 digits"): + roles.AccountRef("bad", "1") + with pytest.raises(roles.IamRoleError, match="partition"): + roles.AccountRef("bad", "123456789012", "mars") + assert roles.normalize_caller_principal(USER_ARN) == USER_ARN + root = "arn:aws:iam::123456789012:root" + assert roles.normalize_caller_principal(root) == root + assumed = "arn:aws:sts::123456789012:assumed-role/path/Agent/session" + assert roles.normalize_caller_principal(assumed) == ( + "arn:aws:iam::123456789012:role/path/Agent" + ) + with pytest.raises(roles.IamRoleError, match="Wildcard"): + roles.normalize_caller_principal("arn:aws:iam::123456789012:role/*") + with pytest.raises(roles.IamRoleError, match="durable"): + roles.normalize_caller_principal("arn:aws:sts::123456789012:federated-user/x") + + +def test_resolve_all_principal_forms() -> None: + accounts = {"prod": ACCOUNT} + assert ( + roles.resolve_principal( + roles.PrincipalRef("user", "scott", "prod"), accounts + ).arn + == USER_ARN + ) + assert roles.resolve_principal( + roles.PrincipalRef("role", "path/Agent", "prod"), accounts + ).arn.endswith("role/path/Agent") + assert roles.resolve_principal( + roles.PrincipalRef("account", "prod", "prod"), accounts + ).arn.endswith(":root") + assert ( + roles.resolve_principal( + roles.PrincipalRef("principal", USER_ARN), accounts + ).kind + == "user" + ) + root = "arn:aws:iam::123456789012:root" + assert ( + roles.resolve_principal(roles.PrincipalRef("principal", root), accounts).kind + == "account" + ) + assert ( + roles.resolve_principal( + roles.PrincipalRef("caller", "", None), accounts, caller_arn=USER_ARN + ).arn + == USER_ARN + ) + with pytest.raises(roles.IamRoleError, match="configured account"): + roles.resolve_principal(roles.PrincipalRef("user", "x"), accounts) + with pytest.raises(roles.IamRoleError, match="durable IAM user or role"): + roles.resolve_principal(roles.PrincipalRef("caller", "bad"), accounts) + with pytest.raises(roles.IamRoleError, match="Unsupported principal"): + roles.resolve_principal( + roles.PrincipalRef("service", "lambda", "prod"), # type: ignore[arg-type] + accounts, + ) + + +def test_execute_plan_success_and_reverse_compensation() -> None: + client = Recorder() + plan = roles.MutationPlan( + "multi", + ("x",), + ( + roles.Operation("iam", "one", {"x": 1}, "undo_one", {"x": 1}), + roles.Operation("iam", "two", {"x": 2}, "undo_two", {"x": 2}), + ), + ) + journal = roles.execute_plan(plan, lambda _name: client) + assert [name for name, _ in client.calls] == ["one", "two"] + assert len(journal.completed) == 2 + failing = Recorder(fail="two") + with pytest.raises(RuntimeError, match="two"): + roles.execute_plan(plan, lambda _name: failing) + assert [name for name, _ in failing.calls] == ["one", "two", "undo_one"] + + +def test_role_create_update_ownership_and_tag_plans() -> None: + spec = roles.RoleSpec( + "Agent", + TRUST, + description="agent", + max_session_duration=7200, + permissions_boundary="arn:boundary", + tags={"team": "platform"}, + owner="scott", + audit_id="change-1", + ) + create = roles.plan_create_role(spec) + params = create.operations[0].params + assert params["Path"] == "/hacksaws/" + assert {item["Key"] for item in params["Tags"]} >= { + roles.MANAGED_TAG, + roles.OWNER_TAG, + roles.AUDIT_TAG, + } + assert create.operations[0].compensate_action == "delete_role" + with pytest.raises(roles.IamRoleError, match="Wildcard"): + roles.plan_create_role( + roles.RoleSpec("Bad", {"Statement": [{"Principal": "*"}]}) + ) + + current = snapshot(description="old", permissions_boundary=None) + update = roles.plan_update_role(current, spec) + actions = [item.action for item in update.operations] + assert actions[:3] == [ + "update_role", + "put_role_permissions_boundary", + "tag_role", + ] + assert "untag_role" in actions + with pytest.raises(roles.ConflictError, match="replacement"): + roles.plan_update_role(current, roles.RoleSpec("Other", TRUST)) + remove_boundary = roles.plan_update_role( + snapshot(permissions_boundary="arn:boundary"), roles.RoleSpec("Agent", TRUST) + ) + assert "delete_role_permissions_boundary" in [ + op.action for op in remove_boundary.operations + ] + changed_trust = {"Version": "2012-10-17", "Statement": []} + trust_update = roles.plan_update_role( + current, roles.RoleSpec("Agent", changed_trust) + ) + assert "update_assume_role_policy" in [op.action for op in trust_update.operations] + + adopted = roles.plan_adopt_role(current, "scott", "audit") + assert adopted.kind == "role-adopt" + with pytest.raises(roles.ConflictError, match="already managed"): + roles.plan_adopt_role( + snapshot(tags={roles.MANAGED_TAG: "true", roles.OWNER_TAG: "other"}), + "scott", + ) + released = roles.plan_release_role( + snapshot(tags={roles.MANAGED_TAG: "true", roles.OWNER_TAG: "x"}) + ) + assert released.operations[0].action == "untag_role" + assert not roles.plan_put_tags("Agent", {}).operations + assert not roles.plan_remove_tags("Agent", []).operations + + +def test_trust_set_add_remove_and_complex_ambiguity() -> None: + principal = roles.DurablePrincipal( + "role", "arn:aws:iam::123456789012:role/Caller", ACCOUNT.account_id, "aws" + ) + added = roles.plan_add_trust("Agent", TRUST, principal, sid="Caller") + desired = json.loads(added.operations[0].params["PolicyDocument"]) + assert desired["Statement"][-1]["Principal"]["AWS"] == principal.arn + assert not roles.plan_add_trust("Agent", desired, principal).operations + removed = roles.plan_remove_trust("Agent", desired, principal) + assert ( + json.loads(removed.operations[0].params["PolicyDocument"])["Statement"] + == TRUST["Statement"] + ) + assert not roles.plan_remove_trust("Agent", TRUST, principal).operations + with pytest.raises(roles.ConflictError, match="changed"): + roles.plan_set_trust("Agent", TRUST, desired, expected_hash="stale") + with pytest.raises(roles.IamRoleError, match="Wildcard"): + roles.plan_set_trust("Agent", TRUST, {"Statement": [{"Principal": "*"}]}) + with pytest.raises(roles.IamRoleError, match="NotPrincipal"): + roles.plan_set_trust( + "Agent", + TRUST, + { + "Statement": [ + { + "Effect": "Allow", + "NotPrincipal": {"AWS": USER_ARN}, + "Action": "sts:AssumeRole", + } + ] + }, + ) + with pytest.raises(roles.IamRoleError, match="Wildcard"): + roles.plan_update_role( + snapshot(), + roles.RoleSpec( + "Agent", + { + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": f"{USER_ARN}*"}, + "Action": "sts:AssumeRole", + } + ] + }, + ), + ) + complex_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": [principal.arn, USER_ARN]}, + "Action": "sts:AssumeRole", + } + ], + } + with pytest.raises(roles.AmbiguousTrustError, match="complex"): + roles.plan_remove_trust("Agent", complex_doc, principal) + with pytest.raises(roles.IamRoleError, match="Statement"): + roles.plan_add_trust("Agent", {"Statement": "bad"}, principal) + single_statement = {"Version": "2012-10-17", "Statement": TRUST["Statement"][0]} + assert roles.plan_add_trust("Agent", single_statement, principal).operations + denied = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Principal": {"AWS": principal.arn}, + "Action": "sts:AssumeRole", + } + ], + } + assert roles.plan_add_trust("Agent", denied, principal).operations + + +def test_managed_attachment_publish_and_inline_policy_plans() -> None: + attach = roles.plan_attach_policy("Agent", "arn:policy") + assert attach.operations[0].compensate_action == "detach_role_policy" + assert roles.plan_detach_policy("Agent", "arn:policy").operations[0].action == ( + "detach_role_policy" + ) + document = {"Version": "2012-10-17", "Statement": []} + publish = roles.plan_publish_and_attach("Agent", "Read", document, ACCOUNT) + assert [op.action for op in publish.operations] == [ + "create_policy", + "attach_role_policy", + ] + assert publish.resources[1].endswith("policy/hacksaws/Read") + put = roles.plan_put_inline_policy("Agent", "Read", document) + assert put.operations[0].compensate_action == "delete_role_policy" + current = {"Version": "2012-10-17", "Statement": [{"Effect": "Deny"}]} + replace = roles.plan_put_inline_policy( + "Agent", + "Read", + document, + current=current, + expected_hash=roles.document_hash(current), + ) + assert replace.operations[0].compensate_action == "put_role_policy" + with pytest.raises(roles.ConflictError, match="changed"): + roles.plan_put_inline_policy( + "Agent", "Read", document, current=current, expected_hash="bad" + ) + assert roles.plan_delete_inline_policy("Agent", "Read").operations[0].action == ( + "delete_role_policy" + ) + delete = roles.plan_delete_inline_policy("Agent", "Read", current=document) + assert delete.operations[0].compensate_action == "put_role_policy" + exported = roles.export_inline_policy(document) + exported["Statement"].append("changed") + assert document["Statement"] == [] + + +def test_dependency_complete_role_delete_boundaries() -> None: + unmanaged = snapshot(tags={}) + with pytest.raises(roles.DependencyError, match="not adopted"): + roles.plan_delete_role(unmanaged) + dependent = snapshot( + attached_policies=("arn:one",), + inline_policies=("Inline",), + permissions_boundary="arn:boundary", + instance_profiles=("Profile",), + inline_policy_documents={"Inline": TRUST}, + ) + with pytest.raises(roles.DependencyError, match="cascade"): + roles.plan_delete_role(dependent) + with pytest.raises(roles.DependencyError, match="Instance-profile"): + roles.plan_delete_role(dependent, cascade=True) + plan = roles.plan_delete_role( + dependent, cascade=True, remove_from_instance_profiles=True + ) + assert [op.action for op in plan.operations] == [ + "detach_role_policy", + "delete_role_policy", + "delete_role_permissions_boundary", + "remove_role_from_instance_profile", + "delete_role", + ] + assert any("preserved" in warning for warning in plan.warnings) + assert any("irreversible" in warning for warning in plan.warnings) + assert plan.operations[1].compensate_action == "put_role_policy" + assert plan.operations[-1].compensate_action is None + assert roles.plan_delete_role(snapshot()).operations[-1].action == "delete_role" + assert roles.plan_delete_role(unmanaged, allow_unmanaged=True).operations + + +def test_group_aggregate_grant_and_member_snapshot_plans() -> None: + group = roles.GroupGrantSnapshot( + "Agents", + ACCOUNT, + "hacksaws-Agents", + "arn:aws:iam::123456789012:policy/hacksaws/hacksaws-Agents", + (), + exists=False, + attached=False, + ) + grant = roles.plan_group_grant( + snapshot(), {"Version": "2012-10-17", "Statement": []}, group + ) + assert [op.action for op in grant.operations] == [ + "update_assume_role_policy", + "publish_owned_policy", + "attach_group_policy", + ] + existing = roles.GroupGrantSnapshot( + group.group_name, + group.account, + group.policy_name, + group.policy_arn, + (ROLE_ARN,), + ) + existing = roles.GroupGrantSnapshot( + existing.group_name, + existing.account, + existing.policy_name, + existing.policy_arn, + existing.role_arns, + document=roles._group_document(existing.role_arns), + owned=True, + ) + assert roles.plan_add_group_member(existing, ROLE_ARN).operations[0].action == ( + "publish_owned_policy" + ) + second = "arn:aws:iam::123456789012:role/hacksaws/Other" + sync = roles.plan_sync_group_members(existing, [second]) + assert second in sync.resources + remove = roles.plan_remove_group_member(existing, ROLE_ARN) + document = remove.operations[0].params["PolicyDocument"] + assert isinstance(document, dict) + assert document["Statement"] == [] + other_account = "arn:aws:iam::210987654321:role/hacksaws/Other" + with pytest.raises(roles.IamRoleError, match="group's account"): + roles.plan_add_group_member(existing, other_account) + with pytest.raises(roles.IamRoleError, match="same-account"): + roles.plan_group_grant(snapshot(arn=other_account), TRUST, existing) + unrelated = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Unrelated", + "Effect": "Deny", + "Action": "iam:*", + "Resource": "*", + }, + { + "Sid": "HacksawsGroupAssumeRoles", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": ROLE_ARN, + }, + ], + } + preserved = roles._group_document([second], unrelated) + assert preserved["Statement"][0] == unrelated["Statement"][0] + assert preserved["Statement"][1]["Resource"] == [second] + + +def test_group_trust_uses_owned_statement_and_preserves_preexisting_root() -> None: + account_root = f"arn:aws:iam::{ACCOUNT.account_id}:root" + trust = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Preexisting", + "Effect": "Allow", + "Principal": {"AWS": account_root}, + "Action": "sts:AssumeRole", + } + ], + } + principal = roles.DurablePrincipal( + "account", account_root, ACCOUNT.account_id, "aws" + ) + added = roles.plan_add_owned_group_trust("Agent", trust, principal) + desired = roles.decode_document(added.operations[0].params["PolicyDocument"]) + assert [item["Sid"] for item in desired["Statement"]] == [ + "Preexisting", + "HacksawsGroupAccount", + ] + removed = roles.plan_remove_owned_group_trust("Agent", desired, principal) + restored = roles.decode_document(removed.operations[0].params["PolicyDocument"]) + assert restored == trust + + +def test_static_assumability_and_explicit_probe() -> None: + assert ( + roles.classify_assumability( + trust_allows=True, identity_allows=True + ).classification + == "potentially-allowed" + ) + assert ( + roles.classify_assumability( + trust_allows=True, identity_allows=True, explicit_deny=True + ).classification + == "denied" + ) + assert ( + roles.classify_assumability( + trust_allows=None, identity_allows=True + ).classification + == "indeterminate" + ) + client = Recorder() + client.assume_role = lambda **params: ( + client.calls.append(("assume_role", params)) + or { + "Credentials": {"AccessKeyId": "never exposed"}, + "PackedPolicySize": 4, + } + ) + result = roles.BotoStsProbe(client).probe(ROLE_ARN, "probe", "external") + assert result == {"ok": True, "packed_policy_size": 4} + request = client.calls[0][1] + assert request["Policy"] == roles.DENY_ALL + assert request["DurationSeconds"] == 900 + assert request["ExternalId"] == "external" + roles.BotoStsProbe(client).probe(ROLE_ARN, "probe") + assert "ExternalId" not in client.calls[-1][1] + + +def test_role_service_paginates_reads_and_retries_eventual_consistency() -> None: + client = ReadClient() + client.fail_once = True + sleeps: list[float] = [] + service = roles.IamRoleService(client, attempts=2, delay=0.01, sleep=sleeps.append) + role = service.get_role("Agent") + assert role.attached_policies == ("arn:policy/one", "arn:policy/two") + assert role.inline_policies == ("InlineOne", "InlineTwo") + assert role.instance_profiles == ("profile",) + assert role.inline_policy_documents["InlineOne"]["Version"] == "2012-10-17" + assert role.permissions_boundary == "arn:boundary" + assert sleeps == [0.01] + listed = service.list_roles() + assert listed[0].name == "Agent" + assert client.paginators["list_roles"].params == {"PathPrefix": "/hacksaws/"} + assert service.list_inline_policies("Agent") == ("InlineOne", "InlineTwo") + assert service.get_inline_policy("Agent", "Read")["Version"] == "2012-10-17" + assert service.get_trust("Agent") == TRUST + + +def test_role_service_retry_exhaustion_propagates_client_error() -> None: + client = ReadClient() + + def always_fail(**params: Any) -> dict[str, Any]: + del params + raise ClientError( + {"Error": {"Code": "NoSuchEntity", "Message": "wait"}}, "GetRole" + ) + + client.get_role = always_fail # type: ignore[method-assign] + service = roles.IamRoleService( + client, attempts=2, delay=0, sleep=lambda _value: None + ) + with pytest.raises(ClientError): + service.get_trust("Agent") diff --git a/hacksaws/tests/test_live_iam_smoke_harness.py b/hacksaws/tests/test_live_iam_smoke_harness.py new file mode 100644 index 0000000..1dc2d13 --- /dev/null +++ b/hacksaws/tests/test_live_iam_smoke_harness.py @@ -0,0 +1,72 @@ +"""Safety coverage for the non-CI live IAM smoke harness.""" + +from __future__ import annotations + +import runpy +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def test_live_smoke_refuses_without_both_required_environment_guards( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("HACKSAWS_LIVE_AWS", raising=False) + monkeypatch.delenv("HACKSAWS_LIVE_AWS_ACCOUNT_ID", raising=False) + script = Path(__file__).parent / "scripts" / "live_iam_smoke.py" + namespace = runpy.run_path(str(script)) + with pytest.raises(SystemExit, match="Refusing live AWS smoke test"): + namespace["main"]() + + +def test_live_smoke_executes_account_scoped_lifecycle_and_valid_cleanup_selector( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("HACKSAWS_LIVE_AWS", "1") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_ACCOUNT_ID", "123456789012") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_CLEANUP", "1") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_TARGET", "smoke") + script = Path(__file__).parent / "scripts" / "live_iam_smoke.py" + namespace = runpy.run_path(str(script)) + commands: list[list[str]] = [] + + class Iam: + def tag_role(self, **_kwargs: object) -> None: + return None + + def tag_policy(self, **_kwargs: object) -> None: + return None + + def get_role(self, **_kwargs: object) -> None: + raise namespace["ClientError"]( + {"Error": {"Code": "NoSuchEntity", "Message": "gone"}}, "GetRole" + ) + + def get_policy(self, **_kwargs: object) -> None: + raise namespace["ClientError"]( + {"Error": {"Code": "NoSuchEntity", "Message": "gone"}}, + "GetPolicy", + ) + + def run(arguments: list[str]) -> None: + commands.append(arguments) + + monkeypatch.setitem(namespace["main"].__globals__, "_run", run) + monkeypatch.setattr( + namespace["_iam_cli"].IamCommandContext, + "create", + lambda _args: SimpleNamespace( + account_id="123456789012", partition="aws", iam=Iam() + ), + ) + assert namespace["main"]() == 0 + output = capsys.readouterr().out + cleanup_commands = [command for command in commands if command[0] == "cleanup"] + assert len(commands) == 7 + assert len(cleanup_commands) == 2 + assert cleanup_commands[0][1:3] == ["--smoke-run", cleanup_commands[1][2]] + assert "--dry-run" in cleanup_commands[0] + assert "--yes" in cleanup_commands[1] + assert all("--target" in command and "smoke" in command for command in commands) + assert "Verified lifecycle and absence" in output diff --git a/hacksaws/tests/test_local_lifecycle.py b/hacksaws/tests/test_local_lifecycle.py new file mode 100644 index 0000000..49aede9 --- /dev/null +++ b/hacksaws/tests/test_local_lifecycle.py @@ -0,0 +1,1047 @@ +"""Focused coverage for local profile, session, logout, and cache lifecycle UX.""" + +from __future__ import annotations + +import argparse +import base64 +import configparser +import json +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _policies +from hacksaws import _sessions +from hacksaws import _state + +DOCUMENT = {"Version": "2012-10-17", "Statement": []} + + +def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "user" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + _state.save_config(_state.default_config()) + return home + + +def _ini(path: Path, sections: dict[str, dict[str, str]]) -> None: + parser = configparser.ConfigParser(interpolation=None) + parser.read_dict(sections) + _sessions._write_ini(path, parser) + + +def _logout_args( + directory: Path, profile: str = "dev", **values: object +) -> argparse.Namespace: + defaults: dict[str, object] = { + "target": None, + "directory": str(directory), + "profile": profile, + "aws_account_name": None, + "to": None, + "to_directory": None, + "to_profile": None, + "except_profiles": [], + "force": False, + "keep_ecr": False, + "ecr": False, + "podman": False, + } + defaults.update(values) + return argparse.Namespace(**defaults) + + +def _record_session(directory: Path, profile: str = "dev") -> None: + journal = _sessions._begin( + [directory / "credentials", directory / "config", _state.sessions_path()] + ) + credentials = _sessions._read_ini(directory / "credentials") + credentials[profile] = { + "aws_access_key_id": "temporary", + "aws_secret_access_key": "temporary-secret", + "aws_session_token": "temporary-token", + } + _sessions._write_ini(directory / "credentials", credentials) + config = _sessions._read_ini(directory / "config") + config[_sessions._section(profile, config=True)] = {"region": "us-west-2"} + _sessions._write_ini(directory / "config", config) + _sessions._record( + directory, + profile, + {"expires_at": (datetime.now(UTC) + timedelta(minutes=30)).isoformat()}, + journal, + method="mfa", + ) + _sessions._commit() + + +def test_profile_inventory_scans_locations_targets_and_reports_parse_warnings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _isolated_home(tmp_path, monkeypatch) + default = home / ".aws" + horizon = home / ".aws-horizon" + custom = tmp_path / "custom" + _ini(default / "credentials", {"default": {}, "dev": {}}) + _ini(default / "config", {"profile prod": {"region": "us-east-1"}}) + _ini(horizon / "config", {"profile admin": {"region": "us-west-2"}}) + _ini(custom / "credentials", {"source": {}}) + broken = home / ".aws-broken" / "config" + broken.parent.mkdir() + broken.write_text("[", encoding="utf-8") + data = _state.load_config() + data["accounts"]["unused"] = {"id": "123456789012", "partition": "aws"} + data["targets"]["custom"] = { + "source_account": "unused", + "source_profile": "source", + "source_directory": str(custom), + } + _state.save_config(data) + + report = _sessions.profile_inventory() + values = { + (item["location"], item["profile"], item["directory"]) + for item in report["profiles"] + } + + assert ("default", "default", str(default.absolute())) in values + assert ("default", "dev", str(default.absolute())) in values + assert ("horizon", "admin", str(horizon.absolute())) in values + assert (None, "source", str(custom.absolute())) in values + assert report["warnings"][0]["path"] == str(broken) + assert "aws_secret_access_key" not in json.dumps(report) + + +def test_status_is_structured_neutral_and_network_free_by_default( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated_home(tmp_path, monkeypatch) + directory = tmp_path / "aws" + _ini( + directory / "credentials", + {"dev": {"aws_access_key_id": "original", "aws_secret_access_key": "secret"}}, + ) + _ini(directory / "config", {"profile dev": {"region": "us-east-1"}}) + _record_session(directory) + + with patch( + "hacksaws._sessions.boto3.Session", side_effect=AssertionError("network") + ): + result = _cli.console_main(["status", "--json"]) + envelope = json.loads(capsys.readouterr().out) + assert result.code == "STATUS" + assert envelope["data"]["sessions"][0]["state"] == "active" + assert "message" not in envelope["data"] + assert "backup" not in json.dumps(envelope) + + result = _cli.console_main(["status", "--no-color"]) + human = capsys.readouterr().out + assert result.kind == "info" + assert "LOCATION" in human + assert "PROFILE" in human + assert "ACTIVE" in human.upper() + assert not human.lstrip().startswith("{") + + +def test_status_verification_is_opt_in_and_per_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + directory = tmp_path / "aws" + _ini(directory / "credentials", {"dev": {}}) + _ini(directory / "config", {"profile dev": {}}) + _record_session(directory) + with patch( + "hacksaws._sessions._verify_status", + return_value={"status": "error", "message": "expired"}, + ) as verify: + report = _sessions.status_report(verify=True) + verify.assert_called_once() + assert report["sessions"][0]["verification"] == { + "status": "error", + "message": "expired", + } + + +def test_section_cas_logout_preserves_unrelated_edits_and_blocks_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + directory = tmp_path / "aws" + _ini( + directory / "credentials", + { + "dev": {"aws_access_key_id": "original", "aws_secret_access_key": "secret"}, + "other": {"aws_access_key_id": "other"}, + }, + ) + _ini( + directory / "config", + {"profile dev": {"region": "us-east-1"}, "profile other": {"region": "a"}}, + ) + _record_session(directory) + credentials = _sessions._read_ini(directory / "credentials") + credentials["other"]["aws_access_key_id"] = "edited-other" + _sessions._write_ini(directory / "credentials", credentials) + + assert _sessions.logout(_configs.Context(_logout_args(directory))) + restored = _sessions._read_ini(directory / "credentials") + assert restored["dev"]["aws_access_key_id"] == "original" + assert restored["other"]["aws_access_key_id"] == "edited-other" + + _record_session(directory) + credentials = _sessions._read_ini(directory / "credentials") + credentials["dev"]["aws_access_key_id"] = "external-change" + _sessions._write_ini(directory / "credentials", credentials) + with pytest.raises(_configs.OperationalError, match="changed after login"): + _sessions.logout(_configs.Context(_logout_args(directory))) + assert _sessions.logout(_configs.Context(_logout_args(directory, force=True))) + assert ( + _sessions._read_ini(directory / "credentials")["dev"]["aws_access_key_id"] + == "original" + ) + + +def test_bulk_logout_except_and_keep_ecr_are_scoped( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + one = tmp_path / "one" + two = tmp_path / "two" + for directory, profile in ((one, "one"), (two, "two")): + _ini(directory / "credentials", {profile: {}}) + _ini(directory / "config", {f"profile {profile}": {}}) + _record_session(directory, profile) + report = _sessions.logout_all(_logout_args(one, all=True, except_profiles=["two"])) + assert {item["state"] for item in report["outcomes"]} == {"logged-out", "excluded"} + assert len(_state.load_sessions()) == 1 + + key = next(iter(_state.load_sessions())) + sessions = _state.load_sessions() + sessions[key]["ecr"] = ["registry.example"] + sessions[key]["ecr_engine"] = "docker" + _state.save_sessions(sessions) + with patch("hacksaws._ecr._run_container_engine") as engine: + outcome = _sessions._logout_key( + key, _logout_args(two, profile="two", keep_ecr=True) + ) + engine.assert_not_called() + assert outcome["state"] == "ecr-only" + assert _state.load_sessions()[key]["ecr"] == ["registry.example"] + + with patch("hacksaws._ecr._run_container_engine") as engine: + _sessions._logout_key(key, _logout_args(two, profile="two")) + engine.assert_called_once_with("docker", ["docker", "logout", "registry.example"]) + + +def test_policy_cache_inventory_show_and_selective_clear( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + _policies.cache_write( + "fresh", DOCUMENT, origin="local", resolver="file", source_identity="fresh.yaml" + ) + _policies.cache_write( + "stale", DOCUMENT, origin="stored", resolver="stored", source_identity="Stored" + ) + stale = _policies.cache_root() / "stale.json" + value = json.loads(stale.read_text(encoding="utf-8")) + value["fetched_at"] = (datetime.now(UTC) - timedelta(days=2)).isoformat() + stale.write_text(json.dumps(value), encoding="utf-8") + (_policies.cache_root() / "invalid.json").write_text("{", encoding="utf-8") + native = tmp_path / "native-login-cache.json" + native.write_text("credential-provider-state", encoding="utf-8") + + report = _policies.cache_inventory(max_age=3600) + assert report["counts"] == {"fresh": 1, "stale": 1, "invalid": 1} + assert "document" not in json.dumps(report) + assert _policies.cache_show("fresh")["document"] == DOCUMENT + removed = _policies.clear_cache_entries(stale_only=True) + assert set(removed) == {"stale", "invalid"} + assert native.read_text(encoding="utf-8") == "credential-provider-state" + + +def test_cache_cli_get_list_show_and_noninteractive_clear( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated_home(tmp_path, monkeypatch) + _policies.cache_write( + "fresh", DOCUMENT, origin="local", resolver="file", source_identity="fresh.yaml" + ) + result = _cli.console_main(["cache", "get", "max-age", "--json"]) + assert json.loads(capsys.readouterr().out)["data"] == { + "setting": "max-age", + "value": 3600, + } + assert result.code == "CACHE_GET" + result = _cli.console_main(["cache", "list", "--json"]) + listed = json.loads(capsys.readouterr().out)["data"] + assert listed["entries"][0]["identity"] == "fresh" + assert "document" not in json.dumps(listed) + result = _cli.console_main(["cache", "show", "fresh", "--json"]) + assert json.loads(capsys.readouterr().out)["data"]["document"] == DOCUMENT + assert result.code == "CACHE_SHOW" + + result = _cli.console_main(["cache", "clear", "--json"]) + capsys.readouterr() + assert result.code == "CACHE_CLEAR_CANCELLED" + assert (_policies.cache_root() / "fresh.json").exists() + result = _cli.console_main(["cache", "clear", "--yes", "--json"]) + assert json.loads(capsys.readouterr().out)["data"]["removed"] == ["fresh"] + assert result.code == "CACHE_CLEAR" + + +def test_status_classifies_conservative_local_states_and_filters( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _isolated_home(tmp_path, monkeypatch) + destination = home / ".aws-horizon" + now = datetime.now(UTC) + missing_path = destination / "credentials" + stable = { + "credentials": { + "path": str(missing_path), + "section": "dev", + "installed": _sessions._section_state(missing_path, "dev"), + "original": {"exists": False, "values": {}}, + } + } + sessions: dict[str, dict[str, Any]] = { + "ecr": { + "destination": str(destination), + "profile": "ecr", + "auth_method": "ecr-only", + "ecr": ["example"], + }, + "legacy": {"destination": str(destination), "profile": "legacy"}, + "missing": { + "destination": str(destination), + "profile": "missing", + "section_backup": { + "credentials": { + **stable["credentials"], + "installed": {"exists": True, "fingerprint": "gone"}, + } + }, + }, + "drifted": { + "destination": str(destination), + "profile": "drifted", + "section_backup": {"credentials": "invalid"}, + }, + "expired": { + "destination": str(destination), + "profile": "expired", + "expires_at": (now - timedelta(seconds=1)).isoformat(), + "section_backup": stable, + }, + "expiring": { + "destination": str(destination), + "profile": "expiring", + "expires_at": (now + timedelta(minutes=5)).isoformat(), + "section_backup": stable, + }, + "active": { + "destination": str(destination), + "profile": "active", + "expires_at": "not-a-date", + "section_backup": stable, + }, + } + _state.save_sessions(sessions) + states = {item["profile"]: item for item in _sessions.status()} + assert {name: item["state"] for name, item in states.items()} == { + "ecr": "ecr-only", + "legacy": "legacy-unverified", + "missing": "missing", + "drifted": "drifted", + "expired": "expired", + "expiring": "expiring", + "active": "active", + } + assert all(item["location"] == "horizon" for item in states.values()) + assert _sessions.status_report(profile="active")["counts"] == {"active": 1} + assert _sessions.status_report(location="horizon")["sessions"] + assert _sessions.status_report(directory=destination)["sessions"] + + +def test_status_verification_skips_unsafe_state_and_reports_success_or_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + destination = tmp_path / "aws" + skipped = _sessions._verify_status( + {"state": "drifted", "destination": str(destination), "profile": "dev"} + ) + assert skipped == {"status": "skipped", "reason": "local state is drifted"} + with ( + patch("hacksaws._sessions.boto3.Session"), + patch( + "hacksaws._sessions._identity", + return_value=("123456789012", "aws", "arn:aws:iam::123456789012:user/me"), + ), + ): + verified = _sessions._verify_status( + {"state": "active", "destination": str(destination), "profile": "dev"} + ) + assert verified["status"] == "verified" + with ( + patch("hacksaws._sessions.boto3.Session"), + patch( + "hacksaws._sessions._identity", + side_effect=_configs.OperationalError("expired"), + ), + ): + failed = _sessions._verify_status( + {"state": "active", "destination": str(destination), "profile": "dev"} + ) + assert failed == {"status": "error", "message": "expired"} + + +def test_legacy_section_restore_requires_force_and_only_restores_profile( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + destination = tmp_path / "aws" + credentials = destination / "credentials" + _ini( + credentials, + { + "dev": {"aws_access_key_id": "original"}, + "other": {"aws_access_key_id": "other"}, + }, + ) + original = credentials.read_bytes() + _ini( + credentials, + { + "dev": {"aws_access_key_id": "temporary"}, + "other": {"aws_access_key_id": "edited"}, + }, + ) + session = { + "backup": [ + { + "path": str(credentials.absolute()), + "exists": True, + "data": base64.b64encode(original).decode(), + } + ] + } + with pytest.raises(_configs.OperationalError, match="legacy session"): + _sessions._restore_profile_sections( + session, destination.absolute(), "dev", force=False + ) + _sessions._restore_profile_sections( + session, destination.absolute(), "dev", force=True + ) + parser = _sessions._read_ini(credentials) + assert parser["dev"]["aws_access_key_id"] == "original" + assert parser["other"]["aws_access_key_id"] == "edited" + + +@pytest.mark.parametrize( + ("section_backup", "message"), + [ + ({"credentials": "bad"}, "section state is invalid"), + ( + { + "credentials": { + "path": "wrong", + "section": "dev", + "original": {"exists": False, "values": {}}, + } + }, + "does not match its destination", + ), + ( + { + "credentials": { + "path": "DESTINATION", + "section": "dev", + "original": "bad", + } + }, + "original section is invalid", + ), + ( + { + "credentials": { + "path": "DESTINATION", + "section": "dev", + "original": {"exists": True, "values": "bad"}, + } + }, + "original section values are invalid", + ), + ], +) +def test_section_restore_rejects_corrupt_metadata( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + section_backup: dict[str, object], + message: str, +) -> None: + _isolated_home(tmp_path, monkeypatch) + destination = tmp_path / "aws" + item = section_backup.get("credentials") + if isinstance(item, dict) and item.get("path") == "DESTINATION": + item["path"] = str((destination / "credentials").absolute()) + with pytest.raises(_configs.OperationalError, match=message): + _sessions._restore_profile_sections( + {"section_backup": section_backup}, + destination.absolute(), + "dev", + force=True, + ) + + +def test_browser_cache_cleanup_is_scoped_and_fingerprint_guarded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + destination = tmp_path / "aws" + root = destination / "login" / "cache" + root.mkdir(parents=True) + matched = root / "matched.json" + changed = root / "changed.json" + outside = tmp_path / "outside.json" + for path in (matched, changed, outside): + path.write_text(path.stem, encoding="utf-8") + session = { + "auth_method": "browser-native", + "login_cache_directories": [str(root)], + "login_cache_files": [str(matched), str(changed), str(outside)], + "login_cache_fingerprints": { + str(matched): _state.digest(matched.read_bytes()), + str(changed): "different", + str(outside): _state.digest(outside.read_bytes()), + }, + } + with pytest.raises(_configs.OperationalError, match="no logout changes"): + _sessions._tracked_login_cache_plan(session, destination, force=False) + roots, removals, residue = _sessions._tracked_login_cache_plan( + session, destination, force=True + ) + assert roots == [root.absolute()] + residue = _sessions._remove_tracked_login_cache(removals, residue, force=True) + assert not matched.exists() + assert not changed.exists() + assert outside.exists() + assert residue == [ + {"path": str(outside.absolute()), "reason": "outside tracked cache roots"} + ] + + +def test_logout_not_managed_and_bulk_collects_independent_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + assert ( + _sessions._logout_key("missing", argparse.Namespace())["state"] == "not-managed" + ) + _state.save_sessions({"a": {}, "b": {}}) + with patch( + "hacksaws._sessions._logout_key", + side_effect=[ + {"key": "a", "state": "logged-out", "changed": True}, + _configs.OperationalError("drift"), + ], + ): + report = _sessions.logout_all(argparse.Namespace()) + assert report["outcomes"][0]["key"] == "a" + assert report["errors"] == [{"key": "b", "message": "drift"}] + + +def test_cache_status_filters_clear_validation_and_profile_help( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated_home(tmp_path, monkeypatch) + _policies.cache_write( + "fresh", DOCUMENT, origin="local", resolver="file", source_identity="fresh.yaml" + ) + assert _cli.console_main(["cache", "status", "--json"]).code == "CACHE_STATUS" + status_data = json.loads(capsys.readouterr().out)["data"] + assert "entries" not in status_data + assert status_data["counts"]["fresh"] == 1 + assert ( + _cli.console_main( + ["cache", "list", "--fresh", "--origin", "local", "--json"] + ).code + == "CACHE_LIST" + ) + assert json.loads(capsys.readouterr().out)["data"]["count"] == 1 + result = _cli.console_main( + ["cache", "clear", "fresh", "--stale", "--yes", "--json"] + ) + assert result.code == "OPERATIONAL_ERROR" + capsys.readouterr() + assert _cli.console_main(["profile", "list", "--json"]).code == "PROFILE_LIST" + assert "profiles" in json.loads(capsys.readouterr().out)["data"] + assert ( + _cli._run_profile(argparse.Namespace(profile_action=None)).code + == "PROFILE_HELP" + ) + + +def test_bulk_logout_cli_handles_legacy_exclusions_and_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + inventory = { + "profiles": [ + { + "auth_method": "legacy-mfa", + "location": "horizon", + "profile": "skip", + "directory": str(tmp_path / "one"), + }, + { + "auth_method": "legacy-mfa", + "location": None, + "profile": "fail", + "directory": str(tmp_path / "two"), + }, + {"auth_method": None, "profile": "unmanaged", "directory": "ignored"}, + ] + } + with ( + patch( + "hacksaws._sessions.logout_all", + return_value={"outcomes": [], "errors": []}, + ), + patch("hacksaws._sessions.profile_inventory", return_value=inventory), + patch( + "hacksaws._aws.logout", + side_effect=_configs.OperationalError("legacy failure"), + ), + ): + result = _cli.console_main( + ["logout", "--all", "--except", "horizon:skip", "--json"] + ) + assert result.code == "LOGOUT_ALL" + assert result.exit_code == 1 + assert isinstance(result.data, dict) + assert result.data["outcomes"][0]["state"] == "excluded" + assert result.data["errors"][0]["message"] == "legacy failure" + + +def test_policy_cache_show_and_clear_reject_bad_or_missing_entries( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + with pytest.raises( + _configs.OperationalError, match="Invalid policy cache identity" + ): + _policies.cache_show("../escape") + with pytest.raises(_configs.OperationalError, match="does not exist"): + _policies.cache_show("missing") + assert _policies.clear_cache_entries(["missing"]) == [] + + +def test_policy_cache_reads_fail_closed_on_tampering_and_clear_supports_patterns( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + _policies.cache_write( + "aws-read-only", DOCUMENT, origin="aws", resolver="arn", source_identity="x" + ) + _policies.cache_write( + "local-debug", DOCUMENT, origin="local", resolver="file", source_identity="y" + ) + path = _policies.cache_root() / "aws-read-only.json" + record = json.loads(path.read_text(encoding="utf-8")) + record["digest"] = "tampered" + path.write_text(json.dumps(record), encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="digest mismatch"): + _policies.cache_read("aws-read-only", 60) + with pytest.raises(_configs.OperationalError, match="digest mismatch"): + _policies.cache_show("aws-read-only") + assert _policies.clear_cache_entries(["LOCAL-*"]) == ["local-debug"] + + +def test_default_normalization_arbitrary_target_shorthand_and_logout_patterns( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _isolated_home(tmp_path, monkeypatch) + args = _logout_args(home / ".aws", profile=".", to=".:.") + _source, source_profile, destination, destination_profile = _sessions._paths(args) + assert (source_profile, destination, destination_profile) == ( + "default", + home / ".aws", + "default", + ) + assert _sessions.matches_logout_exclusion( + destination=str(home / ".aws-horizon"), + profile="ProdAdmin", + location="horizon", + excluded={"HORIZON:prod*"}, + ) + + +def test_logout_preflights_before_ecr_and_rolls_back_partial_profile_restore( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + directory = tmp_path / "aws" + _ini(directory / "credentials", {"dev": {"aws_access_key_id": "original"}}) + _ini(directory / "config", {"profile dev": {"region": "us-east-1"}}) + _record_session(directory) + key = f"{directory.absolute()}::dev" + sessions = _state.load_sessions() + sessions[key]["ecr"] = ["registry.example"] + sessions[key]["ecr_engine"] = "docker" + _state.save_sessions(sessions) + credentials = directory / "credentials" + config = directory / "config" + credentials_before = credentials.read_bytes() + config_before = config.read_bytes() + parser = _sessions._read_ini(credentials) + parser["dev"]["aws_access_key_id"] = "drifted" + _sessions._write_ini(credentials, parser) + with ( + patch("hacksaws._ecr._run_container_engine") as engine, + pytest.raises(_configs.OperationalError, match="changed after login"), + ): + _sessions._logout_key(key, _logout_args(directory)) + engine.assert_not_called() + credentials.write_bytes(credentials_before) + + original_write = _sessions._write_ini + calls = 0 + second_write_error = _configs.OperationalError("second write failed") + + def fail_second(path: Path, value: configparser.ConfigParser) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise second_write_error + original_write(path, value) + + with ( + patch("hacksaws._sessions._write_ini", side_effect=fail_second), + pytest.raises(_configs.OperationalError, match="second write failed"), + ): + _sessions._logout_key(key, _logout_args(directory, keep_ecr=True)) + assert credentials.read_bytes() == credentials_before + assert config.read_bytes() == config_before + assert key in _state.load_sessions() + + +def test_config_fix_walks_remote_boundary_and_account_issues( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + data = _state.load_config() + data["accounts"]["Prod"] = {"id": "111111111111", "partition": "aws"} + data["boundaries"]["Guard"] = { + "role_arn": "arn:aws:iam::111111111111:role/Old", + "account": "Prod", + "duration": 3600, + "verified": False, + } + data["targets"]["Agent"] = { + "source_account": "Prod", + "source_profile": "default", + "source_location": "default", + "boundary": "Guard", + } + _state.save_config(data) + args = argparse.Namespace( + account="Prod", + yes=False, + remote=True, + probe=False, + profile="default", + target=None, + location="default", + directory=None, + ) + with ( + patch("hacksaws._sessions._check_config") as check, + patch("hacksaws._sessions.sys.stdin.isatty", return_value=True), + patch( + "builtins.input", + side_effect=["update", "arn:aws:iam::111111111111:role/New"], + ), + ): + check.return_value = { + "ok": False, + "errors": ["Boundary Guard: missing (gone)."], + "warnings": [], + } + assert _sessions.fix_config(args).code == "CONFIG_FIX" + assert _state.load_config()["boundaries"]["Guard"]["role_arn"].endswith("/New") + + with ( + patch("hacksaws._sessions._check_config") as check, + patch("hacksaws._sessions.sys.stdin.isatty", return_value=True), + patch("builtins.input", side_effect=["update", "replacement.json"]), + ): + check.return_value = { + "ok": False, + "errors": ["Boundary Guard policy: remote policy is missing"], + "warnings": [], + } + assert _sessions.fix_config(args).code == "CONFIG_FIX" + assert _state.load_config()["boundaries"]["Guard"]["policy"] == "replacement.json" + + with ( + patch("hacksaws._sessions._check_config") as check, + patch("hacksaws._sessions.sys.stdin.isatty", return_value=True), + patch("builtins.input", return_value="update"), + ): + check.return_value = { + "ok": False, + "errors": [ + "Selected account does not match caller aws-us-gov:222222222222." + ], + "warnings": [], + } + assert _sessions.fix_config(args).code == "CONFIG_FIX" + account = _state.load_config()["accounts"]["Prod"] + assert (account["partition"], account["id"]) == ( + "aws-us-gov", + "222222222222", + ) + + with ( + patch("hacksaws._sessions._check_config") as check, + patch("hacksaws._sessions.sys.stdin.isatty", return_value=True), + patch("builtins.input", return_value="remove"), + ): + check.return_value = { + "ok": False, + "errors": ["Boundary Guard: missing (gone)."], + "warnings": [], + } + assert _sessions.fix_config(args).code == "CONFIG_FIX" + fixed = _state.load_config() + assert "Guard" not in fixed["boundaries"] + assert "Agent" not in fixed["targets"] + + +def test_cache_record_is_bound_to_filename_and_source_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + _policies.cache_write( + "original", DOCUMENT, origin="local", resolver="file", source_identity="a.json" + ) + original = _policies.cache_root() / "original.json" + copied = _policies.cache_root() / "copied.json" + copied.write_bytes(original.read_bytes()) + with pytest.raises(_configs.OperationalError, match="identity does not match"): + _policies.cache_read("copied", 60) + record = json.loads(original.read_text(encoding="utf-8")) + record["source_identity"] = "b.json" + original.write_text(json.dumps(record), encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="digest mismatch"): + _policies.cache_show("original") + + +def test_browser_logout_cache_drift_fails_closed_and_force_tracks_residue( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + directory = tmp_path / "aws" + _ini(directory / "credentials", {"dev": {"aws_access_key_id": "original"}}) + _ini(directory / "config", {"profile dev": {"region": "us-east-1"}}) + _record_session(directory) + root = directory / "login" / "cache" + root.mkdir(parents=True) + changed = root / "changed.json" + outside = tmp_path / "outside.json" + changed.write_text("new", encoding="utf-8") + outside.write_text("outside", encoding="utf-8") + key = f"{directory.absolute()}::dev" + sessions = _state.load_sessions() + sessions[key].update( + auth_method="browser-native", + login_cache_directories=[str(root)], + login_cache_files=[str(changed), str(outside)], + login_cache_fingerprints={ + str(changed.absolute()): _state.digest(b"old"), + str(outside.absolute()): _state.digest(outside.read_bytes()), + }, + ) + _state.save_sessions(sessions) + before_credentials = (directory / "credentials").read_bytes() + with pytest.raises(_configs.OperationalError, match="no logout changes"): + _sessions._logout_key(key, _logout_args(directory)) + assert (directory / "credentials").read_bytes() == before_credentials + assert key in _state.load_sessions() + assert changed.exists() + + outcome = _sessions._logout_key(key, _logout_args(directory, force=True)) + assert outcome["state"] == "logout-residue" + assert not changed.exists() + assert outside.exists() + residue_session = _state.load_sessions()[key] + assert residue_session["auth_method"] == "browser-cache-residue" + assert residue_session["login_cache_residue"][0]["path"] == str(outside.absolute()) + + +def test_config_check_scopes_local_stored_policies_to_selected_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + data = _state.load_config() + for name, account_id in (("One", "111111111111"), ("Two", "222222222222")): + data["accounts"][name] = {"id": account_id, "partition": "aws"} + data["policies"][name] = {"file": f"stored_session_policies/{name}.yaml"} + data["boundaries"][name] = { + "role_arn": f"arn:aws:iam::{account_id}:role/{name}", + "account": name, + "policy": name, + "duration": 3600, + "verified": False, + } + _state.save_config(data) + + errors = { + "One": _configs.OperationalError("bad One"), + "Two": _configs.OperationalError("bad Two"), + } + + def parse(path: Path) -> tuple[dict[str, Any], bytes]: + raise errors[path.stem] + + args = argparse.Namespace(account="One", remote=False, probe=False) + with patch("hacksaws._policies.parse_policy", side_effect=parse): + report = _sessions.check_config(args) + assert report["errors"] == ["bad One"] + + +def test_remote_config_check_and_fix_infer_local_policy_scope_from_caller( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + data = _state.load_config() + for name, account_id in (("One", "111111111111"), ("Two", "222222222222")): + data["accounts"][name] = {"id": account_id, "partition": "aws"} + data["policies"][name] = {"file": f"stored_session_policies/{name}.yaml"} + data["boundaries"][name] = { + "role_arn": f"arn:aws:iam::{account_id}:role/{name}", + "account": name, + "policy": name, + "duration": 3600, + "verified": False, + } + _state.save_config(data) + parsed: list[str] = [] + policy_errors = { + "One": _configs.OperationalError("bad One"), + "Two": _configs.OperationalError("bad Two"), + } + + def parse(path: Path) -> tuple[dict[str, Any], bytes]: + parsed.append(path.stem) + raise policy_errors[path.stem] + + session = MagicMock() + session.client.return_value.get_role.return_value = {} + args = argparse.Namespace( + account=None, + remote=True, + probe=False, + profile="default", + target=None, + location="default", + directory=None, + yes=True, + ) + with ( + patch("hacksaws._sessions.boto3.Session", return_value=session), + patch( + "hacksaws._sessions._identity", + return_value=("111111111111", "aws", "arn"), + ), + patch("hacksaws._policies.parse_policy", side_effect=parse), + patch("hacksaws._policies.resolve"), + ): + report = _sessions.check_config(args) + assert parsed == ["One"] + assert report["errors"] == ["bad One"] + + parsed.clear() + with ( + patch("hacksaws._sessions.boto3.Session", return_value=session), + patch( + "hacksaws._sessions._identity", + return_value=("111111111111", "aws", "arn"), + ), + patch("hacksaws._policies.parse_policy", side_effect=parse), + patch( + "hacksaws._sessions._check_config", + return_value={"ok": True, "errors": [], "warnings": []}, + ), + ): + assert _sessions.fix_config(args).code == "CONFIG_FIX_UNRESOLVED" + assert parsed == ["One"] + + +def test_cache_consumers_reject_self_consistent_wrong_resolver_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _isolated_home(tmp_path, monkeypatch) + account = "111111111111" + aws_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" + aws_identity = _policies._cache_identity(aws_arn, account=account, partition="aws") + _policies.cache_write( + aws_identity, + DOCUMENT, + origin="remote-customer", + resolver="name", + source_identity=f"arn:aws:iam::{account}:policy/ReadOnlyAccess", + ) + with pytest.raises(_configs.OperationalError, match="metadata does not match"): + _policies._fetch_aws_managed( + aws_arn, + account_id=account, + partition="aws", + profile="default", + max_age=60, + ) + + name_identity = _policies._cache_identity( + "name:Debug", account=account, partition="aws" + ) + _policies.cache_write( + name_identity, + DOCUMENT, + origin="remote-customer", + resolver="name", + source_identity=f"arn:aws:iam::{account}:policy/Other", + ) + with pytest.raises(_configs.OperationalError, match="requested policy name"): + _policies._resolve_remote_name("Debug", account, "aws", "default", 60) + + +def test_console_invocations_do_not_leak_json_output_mode( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated_home(tmp_path, monkeypatch) + assert _cli.console_main(["cache", "get", "--json"]).exit_code == 0 + capsys.readouterr() + _configs.Result("PLAIN", "plain", stream="stderr").echo() + assert capsys.readouterr().err == "plain\n" diff --git a/hacksaws/tests/test_output_foundation.py b/hacksaws/tests/test_output_foundation.py new file mode 100644 index 0000000..9d6f0eb --- /dev/null +++ b/hacksaws/tests/test_output_foundation.py @@ -0,0 +1,387 @@ +"""Focused coverage for shared presentation and schema-one UX foundations.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _output +from hacksaws import _state + + +class _NotATerminal: + def isatty(self) -> bool: + return False + + +class _Terminal: + @staticmethod + def isatty() -> bool: + return True + + +def test_color_policy_handles_windows_style_non_tty_no_color_and_json() -> None: + automatic = _output.OutputOptions(color="auto") + assert not _output.color_enabled(automatic, stream=_NotATerminal(), environ={}) + assert not _output.color_enabled( + automatic, stream=object(), environ={"NO_COLOR": "1"} + ) + assert _output.color_enabled( + _output.OutputOptions(color="always"), stream=_NotATerminal(), environ={} + ) + assert not _output.color_enabled( + _output.OutputOptions(color="always", json=True), + stream=_NotATerminal(), + environ={}, + ) + table = _output.compact_table(["name"], [["value"]], title="Items") + assert table.columns[0].header == "name" + assert "ready" in _output.legend([("ready", "usable")]).plain + with patch("builtins.input", return_value="yes"): + assert _output.confirm("Continue?", stdin=_Terminal()) + assert not _output.confirm("Continue?", stdin=_NotATerminal()) + + +def test_global_output_flags_work_anywhere_and_emit_a_stable_envelope( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + result = _cli.console_main(["status", "--no-color", "--json"]) + rendered = json.loads(capsys.readouterr().out) + assert result.code == "STATUS" + assert rendered == { + "schemaVersion": 1, + "ok": True, + "code": "STATUS", + "data": {"sessions": [], "counts": {}, "warnings": []}, + } + + +def test_global_json_wraps_argument_errors( + capsys: pytest.CaptureFixture[str], +) -> None: + result = _cli.console_main(["unknown-command", "--json"]) + rendered = json.loads(capsys.readouterr().err) + assert result.exit_code == _configs.EXIT_USAGE + assert rendered["schemaVersion"] == 1 + assert rendered["ok"] is False + assert rendered["code"] == "ARGUMENT_ERROR" + + +def test_json_prescan_wraps_every_early_exit_once_and_preserves_human_help( + capsys: pytest.CaptureFixture[str], +) -> None: + cases = [ + (["--color", "bogus", "--json"], "ARGUMENT_ERROR", False), + (["--json", "--definitely-invalid"], "ARGUMENT_ERROR", False), + (["--json"], "ACCESS_TYPE_HELP", False), + (["mfa", "login", "--json"], "ARGUMENT_ERROR", False), + (["--help", "--json"], "HELP", True), + ] + for arguments, code, ok in cases: + result = _cli.console_main(arguments) + captured = capsys.readouterr() + assert captured.out == "" + envelope = json.loads(captured.err) + assert envelope["schemaVersion"] == 1 + assert envelope["ok"] is ok + assert envelope["code"] == code + assert result.code == code + if arguments == ["--json"]: + assert "usage: hacksaws" in envelope["error"]["data"]["help"] + if arguments[:2] == ["mfa", "login"]: + assert "usage: hacksaws" in envelope["error"]["data"]["usage"] + if code == "HELP": + assert "usage: hacksaws" in envelope["data"]["help"] + + human = _cli.console_main(["--help"]) + captured = capsys.readouterr() + assert human.code == "HELP" + assert captured.err == "" + assert captured.out.startswith("usage: hacksaws") + + +def test_schema_one_foundation_defaults_and_naming_precedence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + data = _state.default_config() + assert data["schema_version"] == 1 + assert data["iam"]["path"] == "/hacksaws/" + assert data["session"]["packed_policy_warning"] == 80 + assert data["session"]["packed_policy_enforcement"] == "off" + data["naming"]["resources"]["role"] = {"prefix": "role-"} + data["naming"]["accounts"]["Prod"] = {"case": "snake"} + data["naming"]["account_resources"]["Prod"] = {"role": {"suffix": "-x"}} + _state.save_config(data) + assert _state.resolve_naming( + _state.load_config(), + resource="role", + account="Prod", + explicit={"prefix": "manual-"}, + ) == { + "case": "snake", + "prefix": "manual-", + "suffix": "-x", + "enforcement": "off", + } + + +def test_config_option_commands_and_credential_selector( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + assert ( + _cli.console_main(["config", "option", "set", "output.color", "never"]).code + == "CONFIG_OPTION_SET" + ) + result = _cli.console_main(["config", "option", "get", "output.color", "--json"]) + assert result.message == '"never"' + assert ( + _cli.console_main(["config", "option", "reset", "output.color"]).code + == "CONFIG_OPTION_RESET" + ) + assert ( + _cli.console_main( + ["config", "set", "naming.resources.role.prefix", "managed-"] + ).code + == "CONFIG_OPTION_SET" + ) + assert _state.load_config()["naming"]["resources"]["role"]["prefix"] == "managed-" + assert ( + _cli.console_main(["config", "reset", "naming.resources.role.prefix"]).code + == "CONFIG_OPTION_RESET" + ) + selector = _configs.resolve_credential_selector( + argparse.Namespace( + profile=None, location="west", directory=str(tmp_path), target="+Prod" + ) + ) + assert selector.profile == "default" + assert selector.location == "west" + assert selector.directory == tmp_path.absolute() + assert selector.target == "+Prod" + + +def test_remote_dry_run_skips_automatic_local_recovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + recovered: list[bool] = [] + monkeypatch.setattr( + _cli._sessions, "recover_journal", lambda: recovered.append(True) + ) + monkeypatch.setattr( + _cli._iam_cli, + "dispatch_root_cleanup", + lambda _args: _configs.Result("IAM_CLEANUP_PLAN", "dry run"), + ) + + result = _cli.console_main(["cleanup", "--all", "--dry-run", "--no-color"]) + + assert result.code == "IAM_CLEANUP_PLAN" + assert recovered == [] + + +def test_config_show_text_uses_domain_tables() -> None: + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + data["boundaries"]["Read"] = { + "account": "Prod", + "role_arn": "arn:aws:iam::123456789012:role/Read", + "policy": "Logs", + } + + text = _cli._config_text(data) + + assert "Accounts\nNAME" in text + assert "Boundaries\nNAME" in text + assert "{'Prod':" not in text + + +def test_global_option_and_login_validation_edge_cases( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + assert _cli._extract_global_options(["status", "--", "--json"]) == ( + ["status", "--", "--json"], + None, + False, + ) + assert _cli._extract_global_options(["--color=always", "status"]) == ( + ["status"], + "always", + False, + ) + assert not _cli._json_requested(["status", "--", "--json"]) + for arguments in ( + ["--color", "sometimes"], + ["--color=sometimes"], + ["--color", "always", "--color", "never"], + ["--color=always", "--no-color"], + ): + with pytest.raises(_configs.OperationalError): + _cli._extract_global_options(arguments) + + defaults = { + "profile": None, + "target": None, + "policy": None, + "role": None, + "boundary": None, + "external_id": None, + "session_name": None, + "to": None, + "to_directory": None, + "to_profile": None, + "aws_account_name": None, + "account": None, + } + default_profile = argparse.Namespace(**{**defaults, "profile": "."}) + _cli._validate_login(default_profile) + assert default_profile.profile == "default" + invalid = ( + {"profile": "+x", "target": "+other"}, + {"profile": "+"}, + {"external_id": "secret"}, + {"to": "west:agent", "to_directory": str(tmp_path)}, + {"to_directory": str(tmp_path)}, + {"role": "Agent", "boundary": "Read"}, + ) + for changes in invalid: + with pytest.raises(_configs.OperationalError): + _cli._validate_login(argparse.Namespace(**{**defaults, **changes})) + + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + data["targets"]["Prod"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_location": "west", + } + _state.save_config(data) + target = argparse.Namespace(**{**defaults, "target": "Prod"}) + _cli._validate_login(target) + assert target.target == "+Prod" + + +def test_target_credential_session_and_scoped_config_text( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + source = tmp_path / "aws-source" + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + data["policies"]["Logs"] = {"file": "stored_session_policies/Logs.yaml"} + data["boundaries"]["Read"] = { + "account": "Prod", + "role_arn": "arn:aws:iam::123456789012:role/Read", + "policy": "Logs", + } + data["targets"]["Agent"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_directory": str(source), + "boundary": "Read", + } + _state.save_config(data) + sentinel = object() + calls: list[dict[str, object]] = [] + + def session(**kwargs: object) -> object: + calls.append(kwargs) + assert Path(os.environ["AWS_CONFIG_FILE"]) == source / "config" + return sentinel + + monkeypatch.setattr(_cli.boto3, "Session", session) + args = argparse.Namespace( + profile="default", location="default", directory=None, target="+Agent" + ) + with _cli._selected_credential_session(args) as selected: + assert selected is sentinel + assert calls == [{"profile_name": "admin"}] + text = _cli._config_text(data, account="Prod") + assert "Agent" in text + assert "Logs" in text + with pytest.raises(_configs.OperationalError, match="Unknown configured account"): + _cli._config_text(data, account="Missing") + + +def test_cache_filters_confirmation_and_help_branches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + entry = { + "identity": "aws-CloudWatchReadOnly", + "state": "stale", + "origin": "remote", + "source_identity": "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess", + "age_seconds": 5000, + "size": 120, + } + inventory = { + "root": str(tmp_path), + "max_age": 3600, + "entries": [entry], + "counts": {"fresh": 0, "stale": 1, "invalid": 0}, + "total_bytes": 120, + } + monkeypatch.setattr(_cli._policies, "cache_inventory", lambda: inventory) + listed = _cli._run_cache( + argparse.Namespace( + cache_action="list", + patterns=["*cloudwatch*"], + fresh=False, + stale=True, + invalid=False, + origin="remote", + ) + ) + assert listed.data["count"] == 1 # type: ignore[index] + + with pytest.raises(_configs.OperationalError, match="cannot be combined"): + _cli._run_cache( + argparse.Namespace( + cache_action="clear", + entries=["*"], + stale=True, + all=False, + yes=True, + ) + ) + monkeypatch.setattr(_cli._output, "confirm", lambda *_args, **_kwargs: False) + cancelled = _cli._run_cache( + argparse.Namespace( + cache_action="clear", + entries=["*CloudWatch*"], + stale=False, + all=False, + yes=False, + ) + ) + assert cancelled.code == "CACHE_CLEAR_CANCELLED" + monkeypatch.setattr( + _cli._policies, + "clear_cache_entries", + lambda _entries, *, stale_only: ( + ["aws-CloudWatchReadOnly"] if stale_only else [] + ), + ) + cleared = _cli._run_cache( + argparse.Namespace( + cache_action="clear", + entries=[], + stale=True, + all=False, + yes=True, + ) + ) + assert cleared.data == {"removed": ["aws-CloudWatchReadOnly"], "count": 1} + assert _cli._run_cache(argparse.Namespace(cache_action=None)).code == "CACHE_HELP" diff --git a/hacksaws/tests/test_sessions_coverage.py b/hacksaws/tests/test_sessions_coverage.py index 19d0659..ac4ccbc 100644 --- a/hacksaws/tests/test_sessions_coverage.py +++ b/hacksaws/tests/test_sessions_coverage.py @@ -7,6 +7,7 @@ import json import os import subprocess +import tomllib import zipfile from datetime import UTC from datetime import datetime @@ -153,8 +154,9 @@ def test_journal_commit_and_crash_recovery_restore_files_and_cache( created = tmp_path / "config" cache = tmp_path / "cache" old_cache = cache / "old.json" + preexisting_directory = cache / "keep" / "empty" original.write_bytes(b"original") - old_cache.parent.mkdir() + preexisting_directory.mkdir(parents=True) old_cache.write_bytes(b"cached") journal = _sessions._begin([original, created], cache_roots=[cache]) @@ -163,12 +165,17 @@ def test_journal_commit_and_crash_recovery_restore_files_and_cache( created.write_bytes(b"new") old_cache.write_bytes(b"changed-cache") (cache / "new.json").write_bytes(b"new-cache") + nested_cache = cache / "created" / "nested" / "new.json" + nested_cache.parent.mkdir(parents=True) + nested_cache.write_bytes(b"nested-cache") _sessions.recover_journal() assert original.read_bytes() == b"original" assert not created.exists() assert old_cache.read_bytes() == b"cached" assert not (cache / "new.json").exists() + assert not (cache / "created").exists() + assert preexisting_directory.is_dir() assert not _sessions._journal_path().exists() _sessions._begin([]) @@ -177,6 +184,21 @@ def test_journal_commit_and_crash_recovery_restore_files_and_cache( assert journal["safe_to_rollback"] is True +def test_cache_rollback_removes_an_entire_new_nested_cache_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + cache = tmp_path / "absent-before" + assert not cache.exists() + journal = _sessions._begin([], cache_roots=[cache]) + token = cache / "provider" / "nested" / "token.json" + token.parent.mkdir(parents=True) + token.write_text("broad", encoding="utf-8") + _sessions._rollback(journal) + assert not cache.exists() + assert not _sessions._journal_path().exists() + + def test_recovery_rejects_corrupt_or_unsafe_journal( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -592,11 +614,14 @@ def test_aws_environment_scrubs_and_restores_all_conflicts( monkeypatch.setenv(key, f"old-{key}") config = tmp_path / "config" credentials = tmp_path / "credentials" - cleaned = _sessions._clean_env(config, credentials) + login_cache = tmp_path / "login-cache" + cleaned = _sessions._clean_env(config, credentials, login_cache) assert cleaned["AWS_CONFIG_FILE"] == str(config) + assert cleaned["AWS_LOGIN_CACHE_DIRECTORY"] == str(login_cache) assert "AWS_PROFILE" not in cleaned - with _sessions._aws_environment(config, credentials): + with _sessions._aws_environment(config, credentials, login_cache): assert os.environ["AWS_CONFIG_FILE"] == str(config) + assert os.environ["AWS_LOGIN_CACHE_DIRECTORY"] == str(login_cache) assert "AWS_PROFILE" not in os.environ for key in _sessions._CONFLICTING_ENV: assert os.environ[key] == f"old-{key}" @@ -605,13 +630,21 @@ def test_aws_environment_scrubs_and_restores_all_conflicts( def test_aws_login_passes_remote_and_wraps_subprocess_errors(tmp_path: Path) -> None: config = tmp_path / "nested" / "config" credentials = tmp_path / "nested" / "credentials" + login_cache = tmp_path / "login-cache" with ( patch("hacksaws._sessions._aws_cli_version"), patch("hacksaws._sessions.subprocess.run") as run, ): - _sessions._aws_login(config, credentials, "dev", remote=True) + _sessions._aws_login( + config, + credentials, + "dev", + remote=True, + login_cache=login_cache, + ) assert run.call_args.args[0] == ["aws", "login", "--profile", "dev", "--remote"] assert run.call_args.kwargs["env"]["AWS_CONFIG_FILE"] == str(config) + assert run.call_args.kwargs["env"]["AWS_LOGIN_CACHE_DIRECTORY"] == str(login_cache) with ( patch("hacksaws._sessions._aws_cli_version"), patch( @@ -620,7 +653,28 @@ def test_aws_login_passes_remote_and_wraps_subprocess_errors(tmp_path: Path) -> ), pytest.raises(_configs.OperationalError, match="browser login failed"), ): - _sessions._aws_login(config, credentials, "dev", remote=False) + _sessions._aws_login( + config, + credentials, + "dev", + remote=False, + login_cache=login_cache, + ) + + +def test_browser_runtime_dependency_is_declared_and_preflight_is_actionable() -> None: + project = tomllib.loads( + (Path(__file__).parents[2] / "pyproject.toml").read_text(encoding="utf-8") + ) + assert "boto3[crt]>=1.41,<2" in project["project"]["dependencies"] + with ( + patch( + "hacksaws._sessions.importlib.import_module", + side_effect=ImportError("missing CRT"), + ), + pytest.raises(_configs.OperationalError, match=r"CRT.*uv sync"), + ): + _sessions._require_browser_runtime() def test_native_browser_remote_cache_ecr_success_and_logout( @@ -632,6 +686,7 @@ def test_native_browser_remote_cache_ecr_success_and_logout( old_cache.parent.mkdir(parents=True) old_cache.write_text("old") new_cache = old_cache.with_name("new.json") + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(old_cache.parent)) native = MagicMock(region_name="us-west-2") registry = f"{ACCOUNT}.dkr.ecr.us-west-2.amazonaws.com" @@ -662,6 +717,104 @@ def login(*args: object, **kwargs: object) -> None: engine.assert_called_once() +def test_native_browser_logout_preserves_a_later_same_path_replacement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + aws = tmp_path / "alternate-aws" + cache = tmp_path / "shared-login-cache" + old_cache = cache / "old.json" + new_cache = cache / "debug.json" + old_cache.parent.mkdir(parents=True) + old_cache.write_text("old", encoding="utf-8") + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(cache)) + native = MagicMock(region_name="us-west-2") + + def login( + config: Path, + credentials: Path, + profile: str, + *, + remote: bool, + login_cache: Path, + ) -> None: + del credentials, remote + assert profile == "debug" + assert login_cache == cache.absolute() + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text( + "[profile debug]\nregion=us-west-2\nlogin_session=x\n", + encoding="utf-8", + ) + new_cache.write_text("new", encoding="utf-8") + + args = _args(directory=str(aws), profile="debug") + with ( + patch("hacksaws._sessions._aws_login", side_effect=login), + patch("hacksaws._sessions.boto3.Session", return_value=native), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + ): + _sessions.browser_login(_configs.Context(args)) + saved = _state.load_sessions()[f"{aws.absolute()}::debug"] + assert saved["login_cache_files"] == [str(new_cache.absolute())] + assert saved["login_cache_directories"] == [str(cache.absolute())] + assert old_cache.read_text(encoding="utf-8") == "old" + new_cache.write_text("independent replacement", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="changed after login"): + _sessions.logout(_configs.Context(args)) + assert new_cache.read_text(encoding="utf-8") == "independent replacement" + assert old_cache.read_text(encoding="utf-8") == "old" + assert f"{aws.absolute()}::debug" in _state.load_sessions() + + +def test_native_browser_post_login_failure_reports_complete_rollback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + config = aws / "config" + config.parent.mkdir(parents=True) + config.write_bytes(b"[default]\nregion=us-east-1\n") + cache = tmp_path / "cache" + old_cache = cache / "old.json" + new_cache = cache / "new.json" + cache.mkdir() + old_cache.write_text("old", encoding="utf-8") + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(cache)) + + def login( + config_path: Path, + credentials: Path, + profile: str, + *, + remote: bool, + login_cache: Path, + ) -> None: + del credentials, profile, remote + assert login_cache == cache.absolute() + config_path.write_text("[profile debug]\nlogin_session=x\n", encoding="utf-8") + new_cache.write_text("broad", encoding="utf-8") + + args = _args(directory=str(aws), profile="debug") + with ( + patch("hacksaws._sessions._aws_login", side_effect=login), + patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), + patch( + "hacksaws._sessions._identity", + side_effect=_configs.OperationalError("identity failed"), + ), + pytest.raises( + _configs.OperationalError, + match=r"identity failed.*profile 'debug' were rolled back", + ), + ): + _sessions.browser_login(_configs.Context(args)) + assert config.read_bytes() == b"[default]\nregion=us-east-1\n" + assert old_cache.read_text(encoding="utf-8") == "old" + assert not new_cache.exists() + assert not _sessions._journal_path().exists() + + def test_bounded_browser_success_removes_login_session_and_staging( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -669,10 +822,26 @@ def test_bounded_browser_success_removes_login_session_and_staging( aws = tmp_path / "aws" intermediate = MagicMock(region_name="us-west-2") - def login(config: Path, credentials: Path, *args: object, **kwargs: object) -> None: + inherited_cache = tmp_path / "global-cache" + inherited_cache.mkdir() + unrelated = inherited_cache / "unrelated.json" + unrelated.write_text("old", encoding="utf-8") + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(inherited_cache)) + + def login( + config: Path, + credentials: Path, + *args: object, + login_cache: Path, + **kwargs: object, + ) -> None: + assert root / "staging" in login_cache.parents + assert login_cache != inherited_cache config.parent.mkdir(parents=True, exist_ok=True) config.write_text("[profile dev]\nregion=us-west-2\nlogin_session=x\n") credentials.write_text("[dev]\na=x\n") + login_cache.mkdir(parents=True) + (login_cache / "broad.json").write_text("broad", encoding="utf-8") with ( patch("hacksaws._sessions._aws_login", side_effect=login), @@ -689,6 +858,7 @@ def login(config: Path, credentials: Path, *args: object, **kwargs: object) -> N assert "login_session" not in config["profile out"] assert config["profile out"]["region"] == "us-west-2" assert not (root / "staging").exists() or not any((root / "staging").iterdir()) + assert unrelated.read_text(encoding="utf-8") == "old" def test_bounded_browser_restores_environment_when_assume_fails( @@ -697,8 +867,27 @@ def test_bounded_browser_restores_environment_when_assume_fails( _configured(tmp_path, monkeypatch) monkeypatch.setenv("AWS_CONFIG_FILE", "original-config") monkeypatch.delenv("AWS_SHARED_CREDENTIALS_FILE", raising=False) + inherited_cache = tmp_path / "inherited-cache" + inherited_cache.mkdir() + unrelated = inherited_cache / "unrelated.json" + unrelated.write_text("old", encoding="utf-8") + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(inherited_cache)) + + def login( + config: Path, + credentials: Path, + profile: str, + *, + remote: bool, + login_cache: Path, + ) -> None: + del config, credentials, profile, remote + assert inherited_cache not in login_cache.parents + login_cache.mkdir(parents=True) + (login_cache / "broad.json").write_text("broad", encoding="utf-8") + with ( - patch("hacksaws._sessions._aws_login"), + patch("hacksaws._sessions._aws_login", side_effect=login), patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), patch("hacksaws._sessions._assume", side_effect=RuntimeError("after-auth")), @@ -707,6 +896,10 @@ def test_bounded_browser_restores_environment_when_assume_fails( _sessions.browser_login(_configs.Context(_args(target="Prod"))) assert os.environ["AWS_CONFIG_FILE"] == "original-config" assert "AWS_SHARED_CREDENTIALS_FILE" not in os.environ + assert os.environ["AWS_LOGIN_CACHE_DIRECTORY"] == str(inherited_cache) + assert unrelated.read_text(encoding="utf-8") == "old" + staging = _state.root() / "staging" + assert not staging.exists() or not any(staging.iterdir()) def test_record_preserves_original_backup_and_merges_cache_and_ecr( @@ -758,15 +951,52 @@ def test_logout_missing_snapshot_ecr_only_and_cache_path_guard( "auth_method": "browser-native", "backup": [{"path": str(_state.sessions_path()), "exists": False}], "login_cache_files": [str(cache), str(outside)], + "login_cache_fingerprints": { + str(cache.absolute()): _state.digest(cache.read_bytes()), + str(outside.absolute()): _state.digest(outside.read_bytes()), + }, "ecr": [], } } ) - context = _configs.Context(_args(directory=str(aws), profile="dev")) + context = _configs.Context(_args(directory=str(aws), profile="dev", force=True)) assert _sessions.logout(context) is True assert not cache.exists() assert outside.exists() - assert _sessions.logout(context) is False + saved = _state.load_sessions()[key] + assert saved["auth_method"] == "browser-cache-residue" + assert saved["login_cache_residue"][0]["path"] == str(outside.absolute()) + + +def test_logout_conservatively_preserves_legacy_unfingerprinted_cache( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + cache = aws / "login" / "cache" / "legacy.json" + cache.parent.mkdir(parents=True) + cache.write_text("unknown owner", encoding="utf-8") + key = f"{aws.absolute()}::dev" + _state.save_sessions( + { + key: { + "destination": str(aws.absolute()), + "profile": "dev", + "auth_method": "browser-native", + "backup": [], + "login_cache_files": [str(cache)], + "ecr": [], + } + } + ) + context = _configs.Context(_args(directory=str(aws), profile="dev")) + with pytest.raises(_configs.OperationalError, match="changed after login"): + _sessions.logout(context) + assert cache.read_text(encoding="utf-8") == "unknown owner" + assert _sessions.logout( + _configs.Context(_args(directory=str(aws), profile="dev", force=True)) + ) + assert not cache.exists() def test_status_is_secret_free_and_handles_expiry_values( @@ -1094,7 +1324,21 @@ def test_shared_test_runner_preserves_pytest_exit_code() -> None: completed: subprocess.CompletedProcess[str] = subprocess.CompletedProcess( ["pytest"], 7 ) - with patch("hacksaws._test_runner.subprocess.run", return_value=completed) as run: - assert _test_runner.main() == 7 + with ( + patch("hacksaws._test_runner.find_spec", return_value=object()), + patch("hacksaws._test_runner.subprocess.run", return_value=completed) as run, + ): + assert _test_runner.main(["-k", "focused"]) == 7 assert run.call_args.args[0][1:3] == ["-m", "pytest"] assert "--cov-fail-under=95" in run.call_args.args[0] + assert run.call_args.args[0][-2:] == ["-k", "focused"] + + +def test_shared_test_runner_explains_missing_development_dependencies( + capsys: pytest.CaptureFixture[str], +) -> None: + from hacksaws import _test_runner + + with patch("hacksaws._test_runner.find_spec", return_value=None): + assert _test_runner.main([]) == 2 + assert "requires development dependencies" in capsys.readouterr().err diff --git a/hacksaws/tests/test_v04.py b/hacksaws/tests/test_v04.py index 59ee973..bdf00aa 100644 --- a/hacksaws/tests/test_v04.py +++ b/hacksaws/tests/test_v04.py @@ -248,6 +248,7 @@ def test_native_browser_cache_is_removed_after_identity_failure( monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) _minimal_target(tmp_path) cache_file = tmp_path / "aws" / "login" / "cache" / "new.json" + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(cache_file.parent)) def fake_login(*args: object, **kwargs: object) -> None: cache_file.parent.mkdir(parents=True, exist_ok=True) @@ -280,7 +281,10 @@ def test_import_rejects_extra_and_corrupt_members( _sessions.import_config(archive, replace=False, yes=False) clean = _sessions.export_config(str(tmp_path / "clean.zip")) - with zipfile.ZipFile(clean, "a") as zipped: + with ( + pytest.warns(UserWarning, match="Duplicate name"), + zipfile.ZipFile(clean, "a") as zipped, + ): zipped.writestr("config.json", b"{}") with pytest.raises(_configs.OperationalError, match="duplicate"): _sessions.import_config(clean, replace=False, yes=False) @@ -622,7 +626,7 @@ def ecr_login( ) -def test_plain_logout_retains_ecr_record_then_explicit_logout_cleans_it( +def test_logout_cleans_ecr_by_default_and_keep_ecr_retains_it( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) @@ -649,10 +653,11 @@ def test_plain_logout_retains_ecr_record_then_explicit_logout_cleans_it( to_directory=None, ecr=False, podman=False, + keep_ecr=True, ) assert _sessions.logout(_configs.Context(args)) is True assert _state.load_sessions()[key]["auth_method"] == "ecr-only" - args.ecr = True + args.keep_ecr = False with patch("hacksaws._ecr._run_container_engine") as engine: assert _sessions.logout(_configs.Context(args)) is True engine.assert_called_once_with("docker", ["docker", "logout", registry]) diff --git a/package.json b/package.json index 3af9745..42aab8e 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "hacksaws-development", "private": true, "scripts": { - "format": "prettier --write package.json package-lock.json example_mfa_iam_policy.json README.md .prettierrc .github/workflows/*.yaml", - "format:check": "prettier --check package.json package-lock.json example_mfa_iam_policy.json README.md .prettierrc .github/workflows/*.yaml" + "format": "uv run python scripts/prettier.py write .", + "format:check": "uv run python scripts/prettier.py check ." }, "devDependencies": { "prettier": "3.9.6" diff --git a/pyproject.toml b/pyproject.toml index 87a7091..8d927a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,9 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "boto3>=1.40,<2", + "boto3[crt]>=1.41,<2", "pyyaml>=6.0,<7", + "rich>=13.9,<15", ] [dependency-groups] @@ -124,6 +125,8 @@ ignore = [ "SIM105", "TRY003", ] "hacksaws/_test_runner.py" = ["S603"] +"hacksaws/_iam_cli.py" = ["TRY003", "TRY301"] +"scripts/prettier.py" = ["S603"] "**/tests/**" = [ "ARG001", "ARG002", @@ -152,6 +155,25 @@ warn_return_any = true warn_unused_configs = true ignore_missing_imports = true +# IAM command tests intentionally exercise argparse namespaces and lightweight +# recording doubles. Keep production modules fully checked while suppressing +# only the dynamic-test-double diagnostics that cannot describe that runtime +# surface accurately. +[[tool.mypy.overrides]] +module = [ + "hacksaws.tests.test_iam_cli_scaffold", + "hacksaws.tests.test_iam_policy_cli", + "hacksaws.tests.test_iam_role_cli", + "hacksaws.tests.test_iam_roles", +] +disable_error_code = [ + "arg-type", + "attr-defined", + "func-returns-value", + "index", + "method-assign", +] + [tool.pytest.ini_options] testpaths = ["hacksaws/tests"] cache_dir = ".cache/pytest" @@ -164,18 +186,21 @@ omit = ["hacksaws/tests/*"] precision = 2 [tool.taskipy.tasks] -format_ruff = "ruff format ." -format_prettier = "npm run format" -format = "task format_ruff && task format_prettier" -lint_ruff = "ruff check ." -lint_ruff_format = "ruff format --check ." -lint_mypy = "mypy hacksaws" -lint_prettier = "npm run format:check" -lint = "task lint_ruff_format && task lint_ruff && task lint_mypy && task lint_prettier" +format_ruff = "uvx ruff format" +format_prettier = "python scripts/prettier.py write" +format = "task format_ruff . && task format_prettier ." +lint_ruff = "uvx ruff check" +lint_ruff_format = "uvx ruff format --check" +lint_mypy = "mypy --install-types --non-interactive --ignore-missing-imports" +lint_prettier = "python scripts/prettier.py check" +lint = "task lint_ruff_format . && task lint_ruff . && task lint_mypy . && task lint_prettier ." test = "python -m hacksaws._test_runner" -check = "task lint && task test" +check = "task format && task lint && task test" build = "uv build" +[tool.hatch.build] +artifacts = ["hacksaws/py.typed"] + [tool.hatch.build.targets.wheel] packages = ["hacksaws"] exclude = ["hacksaws/tests"] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..dfd1f0f --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository-only development helpers.""" diff --git a/scripts/prettier.py b/scripts/prettier.py new file mode 100644 index 0000000..04e4d01 --- /dev/null +++ b/scripts/prettier.py @@ -0,0 +1,89 @@ +"""Run Prettier on Git-visible files without traversing ignored directories.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + from collections.abc import Sequence + +MAX_COMMAND_LENGTH = 24_000 +MIN_ARGUMENTS = 2 + + +def _candidates(paths: Sequence[str]) -> tuple[int, list[str]]: + """Return Git-tracked and nonignored untracked files under ``paths``.""" + git = shutil.which("git") + if git is None: + sys.stderr.write("Unable to find git on PATH.\n") + return 127, [] + completed = subprocess.run( + [ + git, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + "--", + *paths, + ], + check=False, + stdout=subprocess.PIPE, + ) + if completed.returncode: + return completed.returncode, [] + candidates = [os.fsdecode(item) for item in completed.stdout.split(b"\0") if item] + return 0, candidates + + +def _batches(files: Sequence[str]) -> Iterable[list[str]]: + """Split file arguments below a conservative cross-platform command length.""" + batch: list[str] = [] + length = 0 + for file in files: + file_length = len(file) + 3 + if batch and length + file_length > MAX_COMMAND_LENGTH: + yield batch + batch = [] + length = 0 + batch.append(file) + length += file_length + if batch: + yield batch + + +def main(arguments: Sequence[str] | None = None) -> int: + """Run Prettier in check or write mode over caller-selected paths.""" + arguments = sys.argv[1:] if arguments is None else arguments + if len(arguments) < MIN_ARGUMENTS or arguments[0] not in {"check", "write"}: + sys.stderr.write("usage: prettier.py {check|write} PATH [PATH ...]\n") + return 2 + + mode, *paths = arguments + returncode, files = _candidates(paths) + if returncode: + return returncode + if not files: + return 0 + npx = shutil.which("npx") + if npx is None: + sys.stderr.write("Unable to find npx on PATH. Run `npm install` first.\n") + return 127 + for batch in _batches(files): + completed = subprocess.run( + [npx, "prettier", f"--{mode}", "--ignore-unknown", "--", *batch], + check=False, + ) + if completed.returncode: + return completed.returncode + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 04ed6f2..be9e419 100644 --- a/uv.lock +++ b/uv.lock @@ -43,6 +43,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[package]] +name = "awscrt" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/7d/fd87588cffbef8fbdb8436f14fa673ee3735cf8600a1a2a36ef78718cfd6/awscrt-0.36.0.tar.gz", hash = "sha256:ad2198461f3b2a2851f37891d75dcb9173bfe2474d8550ad6260bf9970b4064a", size = 37058473, upload-time = "2026-07-16T19:36:20.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/37/1dd6a63dc5325bdb36f082490fd770aec1fd6565d1aaacb3ff9fd9c2bad7/awscrt-0.36.0-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:1f6dbe7fd755d981c6492f6ad08775060e86e9ccb7d84f6725e4444cee14bf5c", size = 5148128, upload-time = "2026-07-16T19:35:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/78/e6/3ecfa5ad2023bc7e897ccd9f09e6d30d34448c9fddbdb2f7549f7964784e/awscrt-0.36.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:900eb2cf518a3f1c7e9c4ebc320f5a3fac891208992b9428da76e3f0fb0300a5", size = 3972156, upload-time = "2026-07-16T19:35:19.025Z" }, + { url = "https://files.pythonhosted.org/packages/0a/94/a5fc3f83e4178f20f4e75fbecfaefc1c8d5a1d848eeba100c46226bf966e/awscrt-0.36.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf8bafef584f9d5fead3a88f3b86e0aea957c85ecab5243d0c47858ea3d9b39a", size = 4264588, upload-time = "2026-07-16T19:35:20.437Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/0c3d1ea026a20be02a0586dd31c41018a98ec7e52701b1ff68c408e79d09/awscrt-0.36.0-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1608b45260678aeb4aabdf5f0c69799cb801304b50d065891f96a474e053f908", size = 3880158, upload-time = "2026-07-16T19:35:21.971Z" }, + { url = "https://files.pythonhosted.org/packages/cb/58/870c1a5adc6d0675e8a4cbb39ea7070ec2860a82b4fa71416bc18cc2f805/awscrt-0.36.0-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a950c3b4082a687f7e76accc01f937113c6febe13a790856381314323e6a8d03", size = 4121252, upload-time = "2026-07-16T19:35:23.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/a06708ee6caf8d0281a9a7c5f73ec36d5471dda37ccd0b630933c869fa4e/awscrt-0.36.0-cp311-abi3-win32.whl", hash = "sha256:b195103c3b87f02a2c2f278281a64e35dfe57d03d92017097adeb31332731459", size = 4167298, upload-time = "2026-07-16T19:35:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/d6/0a/3fa90ed5283aba9c41f3ea657a4bc71addf6eda0fe85d05aff338f159a53/awscrt-0.36.0-cp311-abi3-win_amd64.whl", hash = "sha256:d4b391736d15f44d452bef0372997c418af44c83d0a11953d0e18a5fd937ba0f", size = 4337077, upload-time = "2026-07-16T19:35:26.236Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/355a9c06805f14ac4e1ac1bdf81c8e50d1308dd8c1ea73ae8b3ff35a09ac/awscrt-0.36.0-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:0ef852c7bd977402f2c82959d9b6c68e39c74bca885d4578cec86e6a3b60c864", size = 5146650, upload-time = "2026-07-16T19:35:27.565Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/982bb2798550c469e1fe8ff3b368dbc458f82187a82c38ce6b61456a7a94/awscrt-0.36.0-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01a55a4de4d3d915714590bd50cd3cc430e8f5bce78a4c6b6308c6a7f513ca12", size = 3962528, upload-time = "2026-07-16T19:35:29.166Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/a607e1c70a56bc33008b4a2829334e1b319cf9056419784aaed24dbad9c9/awscrt-0.36.0-cp313-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dba4e0ec0aeec18ecf534081c02357794b89d8a7e12d83f6c970f9d90b1ff0a", size = 4258010, upload-time = "2026-07-16T19:35:30.606Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ed/57655a46f64a7a5d384248a16ad624d2aac076b8a0e759f3e820a30543d0/awscrt-0.36.0-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:63b726ee7165c5f13ffdd8c57402538a59d01dbc1eeec4eaef75790dbb4ce4d9", size = 3872506, upload-time = "2026-07-16T19:35:32.017Z" }, + { url = "https://files.pythonhosted.org/packages/06/c7/7963d182695a1a13597d8c1df36364595475eaba26af176a5d892e8199a6/awscrt-0.36.0-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:74e8419f3ab6770082a5a7af267508c72af10b7352bae17b284800ba8e6ec13b", size = 4116622, upload-time = "2026-07-16T19:35:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/2f/dbefbf49c797a4fc0698f9f5ef51ec6725cc46755e8ce8ecadad07efe64a/awscrt-0.36.0-cp313-abi3-win32.whl", hash = "sha256:ef8e655ffaf245a2d4a5c6ca9a1da12fe72cf43c70457dd07f6e0b162ffed164", size = 4163785, upload-time = "2026-07-16T19:35:35.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/b4/710ec9200b10bb3a39eb3308723fb7ee86622e57a2a8b18532e37c191b7a/awscrt-0.36.0-cp313-abi3-win_amd64.whl", hash = "sha256:e1329d9e6b4e1051bf094130fc40d9790cd6986b529bbe8a8bf65ae4fb559d30", size = 4333718, upload-time = "2026-07-16T19:35:36.431Z" }, + { url = "https://files.pythonhosted.org/packages/f9/15/d4754f15a54206ea2fe4bb8b2343dc86cf1ae163a413aeb23280feffe129/awscrt-0.36.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:6f0044e3fdc3acb7287dc8285fb0710370b94ffd5d69ee1e01a0f161b5ea0376", size = 5155749, upload-time = "2026-07-16T19:35:37.904Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/64590179bc367832edb16a56ff88d86a73f8bfe070311325851c404331c1/awscrt-0.36.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:93ece6a57527cb6124cda94345adc797bee99ae34bb013c7edcd4bbe09493778", size = 4013555, upload-time = "2026-07-16T19:35:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5f/eba58de79c2e63758805de1d355cbe68a17053cd460f1040c98f7baa9211/awscrt-0.36.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:6231b3940c261ac3c6e2128d9ea3fcaabdeec6f5737c19e4310f98b1c5ea2b8d", size = 4254426, upload-time = "2026-07-16T19:35:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a9/49839240e66c8373fe266c9d16218cbce06f31c6e706939e53976c197631/awscrt-0.36.0-cp313-cp313t-win32.whl", hash = "sha256:62d8938540dfa84e754621bcbf9dfdab19a39a4ae2d3a6f350f3d615b1ff4767", size = 4219945, upload-time = "2026-07-16T19:35:42.604Z" }, + { url = "https://files.pythonhosted.org/packages/71/c6/926f66a4f17d874d60fc60578fe6be58f5a91e64cd8836a94f87d1803bf7/awscrt-0.36.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3c822bb7c98c306484c70564d798400110928a0ea5c9c1b2091b74a5026b4561", size = 4380060, upload-time = "2026-07-16T19:35:44.211Z" }, + { url = "https://files.pythonhosted.org/packages/8f/43/98fa9bd741ce4e46e9701ee2247635aa5ccfb9fbcf0e5922ab14ff91306d/awscrt-0.36.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4c1a10d5f33a6c3fb1e39474242c6761b9f607c048e688b344acf95354053d65", size = 5155758, upload-time = "2026-07-16T19:35:45.687Z" }, + { url = "https://files.pythonhosted.org/packages/df/da/b21fff2240b8185afcced69912db6231ff62545e428bc70248c196f73f48/awscrt-0.36.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b76cb47f2ed5c0bf989541ee80ab8f35d9a392d30b5de1e24b17d655e2f63da5", size = 4094173, upload-time = "2026-07-16T19:35:47.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9c/b4ecc77494e5930f0bca50ba9b8c9f973ac477e0233c83126cfb81c85327/awscrt-0.36.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bfc4039bc8ee9924d9dfc1558d073305d5d1f0046e87d7773826901d0f0f0792", size = 4384153, upload-time = "2026-07-16T19:35:48.931Z" }, + { url = "https://files.pythonhosted.org/packages/bf/48/ed0f2b0cb2cb5b7fbb89f14574c7b4bb5f7584072054523c3d4424be693e/awscrt-0.36.0-cp314-cp314t-win32.whl", hash = "sha256:2c8ebe57a6d8a329b57b480357767da1ae3af49d243727e826c3317da09397a5", size = 4301556, upload-time = "2026-07-16T19:35:50.48Z" }, + { url = "https://files.pythonhosted.org/packages/85/a3/9f36da2b735b896c4eb5455d858c4bca80eeadcf6bbfdfafd189de46830f/awscrt-0.36.0-cp314-cp314t-win_amd64.whl", hash = "sha256:edc9814c5ddf4b49e9b4dc5f424b7172afc07a2f7b655338a25d6c87620d2b89", size = 4479687, upload-time = "2026-07-16T19:35:52.014Z" }, +] + [[package]] name = "boto3" version = "1.43.59" @@ -57,6 +89,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/10/c5999e72b020012f2e0ccccf2a15632329edd34cb95b02b1ccfb1712ec08/boto3-1.43.59-py3-none-any.whl", hash = "sha256:58b9635deebf075c1c3d76df78df08eb2979c2a74283194676783a0bff3b4557", size = 140024, upload-time = "2026-07-29T19:33:23.751Z" }, ] +[package.optional-dependencies] +crt = [ + { name = "botocore", extra = ["crt"] }, +] + [[package]] name = "boto3-stubs" version = "1.43.59" @@ -92,6 +129,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/cd/62d749f824b25c152144665f7c5eb8b5ca8be967a87e0c63577b7d4501ae/botocore-1.43.59-py3-none-any.whl", hash = "sha256:21393c35d23b19d7ba95cc4156b59f4013f80696d667997e1abd9d4e29651708", size = 15471171, upload-time = "2026-07-29T19:33:10.864Z" }, ] +[package.optional-dependencies] +crt = [ + { name = "awscrt" }, +] + [[package]] name = "botocore-stubs" version = "1.43.14" @@ -172,8 +214,9 @@ name = "hacksaws" version = "0.4.0" source = { editable = "." } dependencies = [ - { name = "boto3" }, + { name = "boto3", extra = ["crt"] }, { name = "pyyaml" }, + { name = "rich" }, ] [package.dev-dependencies] @@ -189,8 +232,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "boto3", specifier = ">=1.40,<2" }, + { name = "boto3", extras = ["crt"], specifier = ">=1.41,<2" }, { name = "pyyaml", specifier = ">=6.0,<7" }, + { name = "rich", specifier = ">=13.9,<15" }, ] [package.metadata.requires-dev] @@ -271,6 +315,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mslex" version = "1.3.0" @@ -474,6 +539,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + [[package]] name = "ruff" version = "0.16.0" From 4015061766b2e73b0107b33290f8292625002920 Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Sun, 2 Aug 2026 06:56:17 -0500 Subject: [PATCH 3/8] Add Safe Role Assumption - **Role Assumption** - Add an explicit workflow that constrains an existing AWS login into a role-selected destination so agents receive only intended permissions, with saved targets and clear guidance. - **Credential Safety** - Prevent crashes or concurrent changes from restoring or overwriting broader authentication through fingerprinted plans, compare-and-swap recovery, and source, cache, and ECR cleanup. --- CHEATSHEET.md | 40 + README.md | 17 +- docs/assume-role.md | 99 ++ docs/automation-and-json.md | 6 + docs/development-and-smoke-tests.md | 6 +- docs/login.md | 4 + docs/profiles-and-sessions.md | 5 + docs/security-model.md | 12 + hacksaws/_cli.py | 328 ++++ hacksaws/_sessions.py | 1266 +++++++++++++++- hacksaws/tests/scripts/live_iam_smoke.py | 147 +- hacksaws/tests/test_assume_cli.py | 460 ++++++ hacksaws/tests/test_assume_role.py | 1348 +++++++++++++++++ hacksaws/tests/test_live_iam_smoke_harness.py | 260 +++- 14 files changed, 3942 insertions(+), 56 deletions(-) create mode 100644 docs/assume-role.md create mode 100644 hacksaws/tests/test_assume_cli.py create mode 100644 hacksaws/tests/test_assume_role.py diff --git a/CHEATSHEET.md b/CHEATSHEET.md index 9d42e79..f7800b9 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -67,6 +67,46 @@ named `--boundary`/`--as`. character selects target `NAME`; choose a prefix that is convenient in your shell. `--target NAME` is always the unambiguous flag form. +## Assume from an existing session + +```shell +hacksaws assume SOURCE --role ROLE_OR_ARN \ + (--self | --to LOCATION:PROFILE | --to-profile PROFILE | \ + --to-directory PATH --to-profile PROFILE) [OPTIONS] +hacksaws assume SOURCE --boundary NAME (--self | --to ... | --to-profile ...) +hacksaws assume +TARGET [OPTIONS] +hacksaws assume --target TARGET [OPTIONS] +``` + +Common options: + +```text +-n, --name LOCATION source ~/.aws-LOCATION +--policy VALUE ARN, path, stored name, or remote policy name +--external-id VALUE role trust external ID +--account NAME_OR_ID assert target account +--session-name NAME CloudTrail-visible role session name +--region REGION credential resolution and installed region +--to-directory PATH explicit directory; requires --to-profile +--duration/--ttl, --htl/--mtl/--stl +--keep-source retain the live source after successful handoff +--keep-ecr retain tracked ECR authorization +--replace allow an existing unmanaged destination +--yes approve the secret-free plan noninteractively +``` + +The destination is mandatory. `--self` is explicit in-place replacement and +conflicts with `--keep-source`; spelling the same endpoint with `--to` emits an +extra warning. Managed sources are cleared by default. Unmanaged sources require +`--keep-source` and cannot be used in place. There is no assume `--force`. + +Saved targets reject source and destination overrides, including `--self`. +Bounded targets also reject role/policy/account overrides. An unbounded target +may add a saved `--boundary`/`--as`, but no direct role, policy, account, +external ID, session name, or region. Duration and lifecycle controls remain +available. Noninteractive and JSON executions require `--yes`. AWS requires a +minimum 900-second session; role chaining caps duration at 3,600 seconds. + ## Named resources All of account, boundary, and target support: diff --git a/README.md b/README.md index 1e0e579..3f99697 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ account-scoped, previewed, and recoverable where AWS permits it. ## Install and run -Python 3.12 or newer is required. Run the published CLI without installing it: +Python 3.13 or newer is required. Run the published CLI without installing it: ```shell uvx hacksaws --help @@ -99,6 +99,20 @@ Durations accept forms such as `15m`, `15minutes`, `1h`, `hour`, `600s`, and `600seconds`. Rigid aliases `--htl`, `--mtl`, and `--stl` accept floating-point hours, minutes, and seconds; sub-second results round to whole seconds. +If credentials are already logged in, constrain them without repeating the +authentication step: + +```shell +hacksaws assume admin --name horizon \ + --role AgentSession --policy CloudWatchReadOnlyAccess \ + --to default:agent +``` + +The destination is always explicit. The source is removed after a successful +handoff unless `--keep-source` is deliberate; use `--self` for an intentional +in-place replacement. See [Assume a role](docs/assume-role.md) for destination, +confirmation, ECR, and automation safeguards. + ## Inspect before acting The human views are compact tables. Add global `--json` for automation and @@ -222,6 +236,7 @@ models. - [Command cheat sheet](CHEATSHEET.md) - [Login pathways](docs/login.md) +- [Assume a role from an existing session](docs/assume-role.md) - [Profiles, status, and logout](docs/profiles-and-sessions.md) - [IAM policies](docs/iam-policies.md) - [IAM roles and trust](docs/iam-roles-and-trust.md) diff --git a/docs/assume-role.md b/docs/assume-role.md new file mode 100644 index 0000000..12b5627 --- /dev/null +++ b/docs/assume-role.md @@ -0,0 +1,99 @@ +# Assume a role from an existing session + +`hacksaws assume` starts from credentials that are already logged in, calls +`sts:AssumeRole`, writes the constrained credentials to an explicit destination, +and normally removes the live source session. It does not perform MFA or browser +authentication itself. + +```shell +hacksaws assume admin --name horizon \ + --role AgentSession \ + --policy CloudWatchReadOnlyAccess \ + --to default:agent +``` + +The source above is profile `admin` in `~/.aws-horizon`; the destination is +profile `agent` in `~/.aws`. Use `--to-profile agent` to write into the source +location, or a bounded target such as `hacksaws assume +prod-agent` to load the +source, destination, role, and optional policy together. + +## Destination safety + +A destination is mandatory. Choose exactly one: + +- `--to LOCATION:PROFILE` writes to a fully explicit endpoint. +- `--to-profile PROFILE` uses the source location. +- `--to-directory PATH --to-profile PROFILE` uses an explicit AWS directory. +- `--self` deliberately replaces the source profile in place. +- A saved target supplies its configured destination and rejects destination + overrides, including `--self`. + +`--to-directory` requires `--to-profile`; that pair conflicts with `--to` and +`--self`. `--self` also conflicts with `--keep-source`. The verbose +same-endpoint spelling, such as `--to horizon:admin`, is accepted, but its plan +includes an additional warning and requires the same exact approval. In-place +assumption obtains the new credentials before atomically installing them and +keeps only the pre-login backup needed for eventual logout; it does not persist +the broader authenticated credentials as a second live profile. + +Hacksaws replaces a managed destination only after checking that its managed +sections have not drifted. An existing unmanaged destination is refused unless +`--replace` is supplied. There is deliberately no general `--force` option for +assumption. + +## Source and ECR lifecycle + +After a successful write, Hacksaws removes a managed source session by default. +Use `--keep-source` only when both sessions are intentionally needed. An +unmanaged source cannot be safely removed, so it requires `--keep-source`; an +unmanaged source also cannot be used with `--self`. + +Tracked ECR authorization associated with replaced or removed sessions is logged +out after the credential transaction. `--keep-ecr` preserves it deliberately. +ECR cleanup failure is reported as tracked residue without rolling back +already-installed role credentials. + +## Role contract + +Choose one role source: + +- `--role NAME_OR_ARN` selects a concrete IAM role. +- `--boundary NAME` or `--as NAME` loads a saved role plus its optional policy, + external ID, and duration. +- A bounded target loads its saved boundary. + +An unbounded target may add one saved boundary, but no direct role, policy, +account, external ID, session name, or region. A bounded target is a secure +preset: its source, destination, role, policy, account, external ID, and session +name cannot be overridden. Lifecycle and duration options may still be selected +for the invocation. + +`--policy` accepts a policy ARN, local path, stored-policy name, or remote +policy name. It can only reduce the role session's permissions. `--external-id`, +`--session-name`, `--account`, and `--region` provide the corresponding role and +resolution assertions. + +Durations accept `--duration` / `--ttl` values such as `45m`, or rigid `--htl`, +`--mtl`, and `--stl` floating-point forms. AWS requires at least 900 seconds. +Role chaining caps a session at 3,600 seconds, and the role's +`MaxSessionDuration` may impose another ceiling. + +## Preview, confirmation, and automation + +Before calling `AssumeRole`, Hacksaws resolves the source identity, role, +account, partition, destination ownership, policy reference, effective duration, +and lifecycle actions into one prepared plan. Human mode displays that plan and +accepts only the exact answer `yes`. Execution uses the same prepared plan +rather than resolving configuration again. Immediately before mutation, Hacksaws +rechecks the source, destination, cache, and referenced configuration +fingerprints; a change aborts the command and requires a fresh preview. Use +`--yes` to approve the plan noninteractively; JSON mode and other noninteractive +input require that flag. + +```shell +hacksaws assume admin --role AgentSession --to default:agent --yes --json +``` + +Preview and result JSON never include access keys, secret keys, session tokens, +credential backups, or policy documents. Account or partition disagreement is a +hard failure before local credential mutation. diff --git a/docs/automation-and-json.md b/docs/automation-and-json.md index 3ceded0..5564ece 100644 --- a/docs/automation-and-json.md +++ b/docs/automation-and-json.md @@ -9,6 +9,7 @@ would prompt require explicit `--yes`; create collisions additionally require hacksaws --json iam policy create agent.yaml --profile admin --dry-run hacksaws iam list --profile admin --json hacksaws cleanup --all --profile admin --dry-run --json +hacksaws assume admin --role AgentSession --to default:agent --yes --json ``` Global color controls are `--color auto|always|never` and `--no-color`. @@ -23,3 +24,8 @@ create no journal and make no AWS or local mutation. They can therefore fail when credentials, account assertions, references, validation, or dependencies are invalid. Recovery `continue` and `rollback` resume an existing journal and do not offer dry-run mode. + +`hacksaws assume` always performs a secret-free preflight. JSON and other +noninteractive invocations require `--yes` before `AssumeRole` or local +mutation; the envelope contains endpoint, identity, role, account, partition, +and lifecycle metadata but never credentials, backups, or policy documents. diff --git a/docs/development-and-smoke-tests.md b/docs/development-and-smoke-tests.md index 1a095f3..5826e4e 100644 --- a/docs/development-and-smoke-tests.md +++ b/docs/development-and-smoke-tests.md @@ -14,7 +14,9 @@ layout follows Camber Ops conventions and works with `mpx --me check`. Live IAM smoke tests are explicit and never run in ordinary CI. They require an account/target guard, create a tagged role and customer-managed policy under `/hacksaws-test/`, exercise trust/inline/managed attachment/version behavior, -then run cleanup dry-run, cleanup, and absence verification. +assume the unique role with a restrictive local session policy into an isolated +temporary profile, verify and log out that profile without changing the guarded +source, then run cleanup dry-run, cleanup, and absence verification. Set all four guards explicitly before invoking the live marker: @@ -23,7 +25,7 @@ HACKSAWS_LIVE_AWS=1 \ HACKSAWS_LIVE_AWS_CLEANUP=1 \ HACKSAWS_LIVE_AWS_ACCOUNT_ID=123456789012 \ HACKSAWS_LIVE_AWS_TARGET=smoke-admin \ -uv run pytest -m live_aws +uv run python hacksaws/tests/scripts/live_iam_smoke.py ``` The account ID must exactly match the target's caller identity. The target must diff --git a/docs/login.md b/docs/login.md index d334105..10650c3 100644 --- a/docs/login.md +++ b/docs/login.md @@ -45,6 +45,10 @@ characters are the target name. ECR login deliberately uses the intermediate authenticated credentials before the final boundary credentials replace them. +To constrain credentials that are already logged in without repeating MFA or +browser authentication, use the standalone [`hacksaws assume`](assume-role.md) +workflow. + ## Destination aliases `.` and `default` mean `~/.aws` when used as locations and the `default` profile diff --git a/docs/profiles-and-sessions.md b/docs/profiles-and-sessions.md index e321c17..7870e81 100644 --- a/docs/profiles-and-sessions.md +++ b/docs/profiles-and-sessions.md @@ -28,3 +28,8 @@ hacksaws logout --all --except "horizon:prod*" --except "+hacw" `--except` requires `--all`. Patterns match canonical `location:profile` names and target aliases. Tracked ECR logins are removed unless `--keep-ecr` is used. + +`hacksaws assume` normally clears its managed source after installing the role +credentials at the destination. `--keep-source` deliberately retains both; +`--self` performs an in-place handoff and therefore cannot keep a second live +source. See [Assume a role](assume-role.md). diff --git a/docs/security-model.md b/docs/security-model.md index d379cd4..ba8a373 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -23,3 +23,15 @@ when ownership cannot be proven. Logout never preserves intermediate authenticated credentials. It edits only the managed profile section using fingerprints and leaves unrelated file data untouched. + +Standalone role assumption follows the same contract. The assumed credentials +are obtained before the local transaction begins; then the destination is +installed and the managed source is removed unless `--keep-source` was explicit. +In-place `--self` retains only the pre-login backup required by logout, not a +second copy of the broader authenticated session. Preview and JSON surfaces omit +credential values, backups, and policy documents. + +An assumption confirmation is bound to one immutable prepared plan. Hacksaws +does not re-resolve a target, boundary, role, policy, duration, or endpoint +after approval, and it aborts if source, destination, cache, or configuration +state changes before the credential transaction begins. diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index d67cdf3..33b90bf 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -188,6 +188,105 @@ def _logout_arguments(parser: argparse.ArgumentParser) -> None: _ecr_arguments(parser) +def _assume_arguments(parser: argparse.ArgumentParser) -> None: + """Register the explicit role-assumption workflow without login-only flags.""" + parser.add_argument( + "profile", + nargs="?", + metavar="SOURCE", + help=( + "Source AWS profile. A leading non-alphanumeric character selects a " + "saved target; +TARGET is the documented form." + ), + ) + parser.add_argument( + "-n", + "--name", + "--account-name", + dest="aws_account_name", + help="Source location name, selecting ~/.aws-NAME (default: ~/.aws).", + ) + parser.add_argument( + "--target", + help="Saved target supplying source and destination endpoints.", + ) + destination = parser.add_mutually_exclusive_group() + destination.add_argument( + "--self", + dest="self_destination", + action="store_true", + help="Explicitly replace the source endpoint with assumed-role credentials.", + ) + destination.add_argument( + "--to", + metavar="LOCATION:PROFILE", + help="Write assumed credentials to this logical location and profile.", + ) + parser.add_argument( + "--to-directory", + metavar="PATH", + help="Write to an explicit AWS directory; requires --to-profile.", + ) + parser.add_argument( + "--to-profile", + metavar="PROFILE", + help="Write to PROFILE in the source AWS location.", + ) + role = parser.add_mutually_exclusive_group() + role.add_argument("--role", help="Concrete IAM role name or ARN to assume.") + role.add_argument( + "--boundary", + "--as", + dest="boundary", + help="Saved boundary supplying the concrete role and optional policy.", + ) + parser.add_argument( + "--policy", + help="Optional session policy name, ARN, stored name, or local file.", + ) + parser.add_argument("--external-id", help="External ID supplied to AssumeRole.") + parser.add_argument( + "--account", help="Configured account name or ID asserted for the role target." + ) + parser.add_argument( + "--session-name", help="Assumed-role session name shown in AWS audit records." + ) + parser.add_argument( + "--region", help="AWS region used for credential resolution and console links." + ) + _duration_arguments(parser) + parser.add_argument( + "--keep-source", + action="store_true", + help="Leave live source credentials in place after writing the destination.", + ) + parser.add_argument( + "--keep-ecr", + action="store_true", + help="Preserve Hacksaws-tracked ECR authorization while clearing the source.", + ) + parser.add_argument( + "--replace", + action="store_true", + help="Allow replacement of a destination that already contains credentials.", + ) + parser.add_argument( + "--yes", + action="store_true", + help="Approve the displayed assumption plan without prompting.", + ) + parser.set_defaults( + action="assume", + directory="~/.aws", + force=False, + ecr=False, + podman=False, + ecr_region=[], + remote=False, + mfa_code=None, + ) + + def _credential_selector(parser: argparse.ArgumentParser) -> None: selector = parser.add_mutually_exclusive_group() selector.add_argument( @@ -355,6 +454,29 @@ def _create_parser() -> argparse.ArgumentParser: logout = types.add_parser("logout", help="Remove local Hacksaws login state.") _logout_arguments(logout) + assume = types.add_parser( + "assume", + help="Assume a role from existing temporary credentials.", + description=( + "Assume a concrete IAM role from an existing AWS profile, write the " + "result to an explicit destination, and optionally clear the source." + ), + epilog=( + "Examples:\n" + " hacksaws assume admin --name horizon --role AgentSession --to agent:default\n" + " hacksaws assume admin --role AgentSession --to-directory ./agent-aws --to-profile debug\n" + " hacksaws assume admin --boundary logs-read --to-profile agent\n" + " hacksaws assume +prod-agent\n" + " hacksaws assume debug --role AgentSession --self\n\n" + "Use --self only for deliberate in-place replacement. Writing the same " + "endpoint with --to is supported but emits an additional warning.\n" + "Saved targets own their source and destination and therefore reject " + "--self and other endpoint overrides. An unbounded target may add only " + "one saved --boundary/--as plus duration and lifecycle controls." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + _assume_arguments(assume) status = types.add_parser("status", help="Show Hacksaws-managed login sessions.") status.add_argument("--profile", help="Filter by destination profile.") status_location = status.add_mutually_exclusive_group() @@ -694,6 +816,210 @@ def _validate_login(namespace: argparse.Namespace) -> None: ) +def _validate_assume(namespace: argparse.Namespace) -> None: + """Validate assume-only grammar before AWS discovery or confirmation.""" + profile = getattr(namespace, "profile", None) + if profile in {".", "default"}: + namespace.profile = "default" + profile = "default" + if profile and not profile[0].isalnum(): + if namespace.target: + raise _configs.OperationalError("Specify a saved target only once.") + if len(profile) == 1: + raise _configs.OperationalError("A target shorthand requires a name.") + namespace.target = "+" + profile[1:] + namespace.profile = None + profile = None + if namespace.target and not namespace.target.startswith("+"): + namespace.target = "+" + namespace.target + if not (profile or namespace.target): + raise _configs.OperationalError( + "Assume requires a source profile or saved target." + ) + if namespace.self_destination and namespace.keep_source: + raise _configs.OperationalError("--self cannot be combined with --keep-source.") + if namespace.self_destination and (namespace.to_directory or namespace.to_profile): + raise _configs.OperationalError( + "--self is mutually exclusive with --to-directory/--to-profile." + ) + if namespace.to and (namespace.to_directory or namespace.to_profile): + raise _configs.OperationalError( + "--to is mutually exclusive with --to-directory/--to-profile." + ) + if namespace.to_directory and not namespace.to_profile: + raise _configs.OperationalError("--to-directory requires --to-profile.") + if namespace.to: + location, separator, destination_profile = namespace.to.partition(":") + if not separator or not location or not destination_profile: + raise _configs.OperationalError("--to must be LOCATION:PROFILE.") + data = _state.load_config() + target: dict[str, Any] | None = None + if namespace.target: + _, target = _state.get_resource(data, "target", namespace.target.lstrip("+")) + if namespace.self_destination: + raise _configs.OperationalError( + "A saved target owns its destination and cannot be combined with --self." + ) + if namespace.aws_account_name: + raise _configs.OperationalError( + "A saved target supplies its source location; omit --name." + ) + if namespace.to or namespace.to_directory or namespace.to_profile: + raise _configs.OperationalError( + "A saved target supplies its destination; use --self for an explicit " + "in-place assumption." + ) + saved_boundary = target.get("boundary") + if saved_boundary: + overrides = [ + option + for option, value in ( + ("--role", namespace.role), + ("--boundary/--as", namespace.boundary), + ("--policy", namespace.policy), + ("--account", namespace.account), + ("--external-id", namespace.external_id), + ("--session-name", namespace.session_name), + ) + if value + ] + if overrides: + raise _configs.OperationalError( + "A bounded target supplies its role contract and cannot be " + "combined with " + ", ".join(overrides) + "." + ) + else: + overrides = [ + option + for option, value in ( + ("--role", namespace.role), + ("--policy", namespace.policy), + ("--account", namespace.account), + ("--external-id", namespace.external_id), + ("--session-name", namespace.session_name), + ("--region", namespace.region), + ) + if value + ] + if overrides: + raise _configs.OperationalError( + "An unbounded target may add one saved --boundary/--as, not " + + ", ".join(overrides) + + "." + ) + if not ( + target.get("destination_location") + or target.get("destination_directory") + or target.get("destination_profile") + ): + raise _configs.OperationalError( + "Saved target has no destination; update it or use --self." + ) + elif not ( + namespace.self_destination + or namespace.to + or namespace.to_directory + or namespace.to_profile + ): + raise _configs.OperationalError( + "Assume requires --self, --to LOCATION:PROFILE, --to-profile PROFILE, " + "or a saved target destination." + ) + concrete_boundary = namespace.boundary or (target or {}).get("boundary") + if not (namespace.role or concrete_boundary): + raise _configs.OperationalError( + "Assume requires a concrete --role, saved --boundary/--as, or bounded " + "target." + ) + if namespace.boundary: + _state.get_resource(data, "boundary", namespace.boundary) + + +def _assume_preview_text(preview: dict[str, Any]) -> str: + """Render a secret-free assume plan in a stable, reviewable layout.""" + preferred = ( + ("source", "Source"), + ("destination", "Destination"), + ("role", "Role"), + ("policy", "Session policy"), + ("duration", "Duration"), + ("durationSeconds", "Duration (seconds)"), + ("account", "Account"), + ("partition", "Partition"), + ("keepSource", "Keep source"), + ("keepEcr", "Keep ECR"), + ("replace", "Replace destination"), + ) + lines = ["Assume role plan:"] + rendered: set[str] = set() + for key, label in preferred: + if key not in preview: + continue + value = preview[key] + text = ( + json.dumps(value, sort_keys=True) + if isinstance(value, (dict, list)) + else str(value) + ) + lines.append(f" {label}: {text}") + rendered.add(key) + for key, value in preview.items(): + if key in rendered or key == "warnings": + continue + text = ( + json.dumps(value, sort_keys=True) + if isinstance(value, (dict, list)) + else str(value) + ) + lines.append(f" {key}: {text}") + warnings = preview.get("warnings", []) + if isinstance(warnings, list): + lines.extend(f"WARNING: {warning}" for warning in warnings) + return "\n".join(lines) + + +def _run_assume(context: _configs.Context) -> _configs.Result: + """Preview, confirm, then execute one source-to-destination assumption.""" + _validate_assume(context.args) + plan = _sessions.prepare_assume_role(context) + preview = _sessions.assume_role_preview(plan) + rendered = _assume_preview_text(preview) + if not bool(context.args.yes): + if _configs.json_output_enabled() or not sys.stdin.isatty(): + return _configs.Result( + "ASSUME_CONFIRMATION_REQUIRED", + "Assume role requires --yes in non-interactive or JSON mode.", + _configs.EXIT_CANCELLED, + "stderr", + data={"preview": preview}, + kind="warning", + ) + prompt = f"{rendered}\nType exactly 'yes' to apply this plan:\n> " + if input(prompt).strip() != "yes": + return _configs.Result( + "ASSUME_CANCELLED", + "Assume role cancelled; no credentials were changed.", + _configs.EXIT_CANCELLED, + "stderr", + data={"preview": preview}, + kind="warning", + ) + context.args.yes = True + result = _sessions.assume_role(context, plan) + data = dict(result.data) if isinstance(result.data, dict) else {} + data.setdefault("preview", preview) + return _configs.Result( + result.code, + result.message, + result.exit_code, + result.stream, + data=data, + details=result.details, + repairs=result.repairs, + kind=result.kind, + ) + + def _run_mfa(context: _configs.Context) -> _configs.Result: """Execute MFA while preserving the legacy direct-profile behavior.""" action = cast("str | None", context.args.action) @@ -1851,6 +2177,8 @@ def _console_main_invocation(arguments: Sequence[str] | None = None) -> _configs result = _run_mfa(_configs.Context(args=namespace)) elif namespace.access_type in {"pk", "web"}: result = _run_browser(_configs.Context(args=namespace)) + elif namespace.access_type == "assume": + result = _run_assume(_configs.Context(args=namespace)) elif namespace.access_type in {"iam", "remote"}: result = _iam_cli.dispatch(namespace) elif namespace.access_type == "cleanup": diff --git a/hacksaws/_sessions.py b/hacksaws/_sessions.py index cc1a661..f67bc34 100644 --- a/hacksaws/_sessions.py +++ b/hacksaws/_sessions.py @@ -5,6 +5,7 @@ import base64 import configparser import copy +import dataclasses import fnmatch import getpass import importlib @@ -22,6 +23,7 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import Any +from typing import NoReturn from typing import cast import boto3 @@ -52,6 +54,19 @@ } +class AssumePlanChanged(_configs.OperationalError): + """Raised when local state no longer matches a confirmed AssumeRole preview.""" + + +@dataclasses.dataclass +class AssumeRolePlan: + """Opaque prepared AssumeRole operation; callers may expose only its preview.""" + + _data: dict[str, Any] = dataclasses.field(repr=False) + _arguments_fingerprint: str = dataclasses.field(repr=False) + _consumed: bool = dataclasses.field(default=False, repr=False) + + def is_expanded_login(args: Any) -> bool: """Return whether an invocation needs the v0.4 transaction path.""" return any( @@ -228,6 +243,9 @@ def recover_journal() -> None: raise _configs.OperationalError( f"An unreadable transaction journal remains at {path}; preserve it and restore affected AWS files manually: {error}" ) from error + if journal.get("kind") == "assume-role": + _recover_assume_journal(journal) + return if not journal.get("safe_to_rollback") or not isinstance( journal.get("files"), list ): @@ -470,9 +488,9 @@ def _role_details( account_id = source_account role_partition = partition if account_name: - data = _state.load_config() - _, account = _state.get_resource(data, "account", account_name) - account_id, role_partition = account["id"], account["partition"] + account_id, role_partition = _role_account_assertion( + str(account_name), partition + ) role = f"arn:{role_partition}:iam::{account_id}:role/{role}" if role: match = re.fullmatch( @@ -481,11 +499,12 @@ def _role_details( if not match: raise _configs.OperationalError(f"Invalid role ARN {role!r}.") if account_name: - data = _state.load_config() - _, selected = _state.get_resource(data, "account", account_name) + asserted_account, asserted_partition = _role_account_assertion( + str(account_name), partition + ) if ( - match.group(1) != selected["partition"] - or match.group(2) != selected["id"] + match.group(1) != asserted_partition + or match.group(2) != asserted_account ): raise _configs.OperationalError( "Explicit role ARN account/partition conflicts with --account." @@ -503,6 +522,15 @@ def _role_details( return role, policy, external_id, target.get("boundary_name") +def _role_account_assertion(value: str, caller_partition: str) -> tuple[str, str]: + """Resolve a configured account name or a raw ID in the caller partition.""" + if re.fullmatch(r"\d{12}", value): + return value, caller_partition + data = _state.load_config() + _, account = _state.get_resource(data, "account", value) + return str(account["id"]), str(account["partition"]) + + def _require_concrete_role(args: Any, role: str | None) -> None: """Fail before authentication when role-only operands have no concrete role.""" operands = { @@ -586,41 +614,30 @@ def _assume( target: dict[str, Any], external_id: str | None, boundary_name: str | None, + effective_duration: int | None = None, + resolved_policy: _policies.ResolvedPolicy | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: match = re.fullmatch(r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/.+", role) if not match: raise _configs.OperationalError(f"Invalid role ARN {role!r}.") - credentials = session.get_credentials() - chained = bool(credentials and credentials.token) - duration = _duration_for(args, target, chained=chained) - role_name = role.split("role/", 1)[-1] - try: - maximum = int( - session.client("iam").get_role(RoleName=role_name)["Role"][ - "MaxSessionDuration" - ] - ) - except (BotoCoreError, ClientError, KeyError, TypeError, ValueError): - maximum = None - if maximum is not None and duration > maximum: - raise _configs.OperationalError( - f"Requested boundary duration {duration} seconds exceeds role " - f"MaxSessionDuration {maximum} seconds." - ) + duration = effective_duration or _effective_assume_duration( + session, role, args=args, target=target + ) request: dict[str, Any] = { "RoleArn": role, "RoleSessionName": _session_name(role, boundary_name, args.session_name), "DurationSeconds": duration, } - resolved = None + resolved = resolved_policy if policy: - resolved = _policies.resolve( - policy, - account_id=match.group(2), - partition=match.group(1), - profile=source_profile, - session=session, - ) + if resolved is None: + resolved = _policies.resolve( + policy, + account_id=match.group(2), + partition=match.group(1), + profile=source_profile, + session=session, + ) if resolved.arn: request["PolicyArns"] = [{"arn": resolved.arn}] elif resolved.document: @@ -654,6 +671,30 @@ def _assume( return response["Credentials"], metadata +def _effective_assume_duration( + session: Any, role: str, *, args: Any, target: dict[str, Any] +) -> int: + """Resolve role-chaining and configured-role duration limits before preview.""" + credentials = session.get_credentials() + chained = bool(credentials and credentials.token) + duration = _duration_for(args, target, chained=chained) + role_name = role.split("role/", 1)[-1] + try: + maximum = int( + session.client("iam").get_role(RoleName=role_name)["Role"][ + "MaxSessionDuration" + ] + ) + except (BotoCoreError, ClientError, KeyError, TypeError, ValueError): + maximum = None + if maximum is not None and duration > maximum: + raise _configs.OperationalError( + f"Requested boundary duration {duration} seconds exceeds role " + f"MaxSessionDuration {maximum} seconds." + ) + return duration + + def _save_credentials(path: Path, profile: str, credentials: dict[str, Any]) -> None: parser = _read_ini(path) parser[profile] = { @@ -697,22 +738,43 @@ def _record( method: str, ecr: list[str] | None = None, ecr_engine: str | None = None, + previous_override: dict[str, Any] | None = None, + inherit_runtime_state: bool = True, + retain_file_backup: bool = True, ) -> None: sessions = _state.load_sessions() key = f"{destination.absolute()}::{profile}" - previous = sessions.get(key) - original_backup = ( - previous.get("backup") or journal["files"] if previous else journal["files"] + previous = previous_override or sessions.get(key) + destination_paths = { + (destination / "credentials").absolute(), + (destination / "config").absolute(), + } + destination_backup = [ + item + for item in journal["files"] + if Path(str(item.get("path", ""))).absolute() in destination_paths + ] + if previous: + original_backup = previous.get("backup") or ( + destination_backup if retain_file_backup else [] + ) + else: + original_backup = destination_backup if retain_file_backup else [] + previous_ecr = previous.get("ecr", []) if previous and inherit_runtime_state else [] + previous_cache = ( + previous.get("login_cache_files", []) + if previous and inherit_runtime_state + else [] ) - previous_ecr = previous.get("ecr", []) if previous else [] - previous_cache = previous.get("login_cache_files", []) if previous else [] current_cache = metadata.get("login_cache_files", []) if previous_cache or current_cache: metadata["login_cache_files"] = list( dict.fromkeys([*previous_cache, *current_cache]) ) previous_cache_directories = ( - previous.get("login_cache_directories", []) if previous else [] + previous.get("login_cache_directories", []) + if previous and inherit_runtime_state + else [] ) current_cache_directories = metadata.get("login_cache_directories", []) if previous_cache_directories or current_cache_directories: @@ -720,7 +782,9 @@ def _record( dict.fromkeys([*previous_cache_directories, *current_cache_directories]) ) previous_fingerprints = ( - previous.get("login_cache_fingerprints", {}) if previous else {} + previous.get("login_cache_fingerprints", {}) + if previous and inherit_runtime_state + else {} ) current_fingerprints = metadata.get("login_cache_fingerprints", {}) if previous_fingerprints or current_fingerprints: @@ -749,6 +813,30 @@ def _original_file(path: Path, profile: str) -> bytes | None: key = f"{path.parent.absolute()}::{profile}" session = _state.load_sessions().get(key) if session: + kind = "config" if path.name == "config" else "credentials" + section_item = session.get("section_backup", {}).get(kind) + if isinstance(section_item, dict) and isinstance( + section_item.get("original"), dict + ): + original = section_item["original"] + parser = _read_ini(path) + section = _section(profile, config=kind == "config") + if original.get("exists"): + values = original.get("values") + if not isinstance(values, dict): + raise _configs.OperationalError( + "Managed session original section values are invalid." + ) + parser[section] = { + str(name): str(value) for name, value in values.items() + } + else: + parser.remove_section(section) + import io + + stream = io.StringIO() + parser.write(stream) + return stream.getvalue().encode() for snapshot in session.get("backup", []): if Path(snapshot["path"]).absolute() == path.absolute(): return ( @@ -1229,6 +1317,1106 @@ def browser_login(context: _configs.Context) -> _configs.Result: ) +def _profile_exists(directory: Path, profile: str) -> bool: + """Return whether either AWS file already contains a profile section.""" + credentials = _read_ini(directory / "credentials") + config = _read_ini(directory / "config") + return profile in credentials or _section(profile, config=True) in config + + +def _legacy_source_backup( + directory: Path, profile: str +) -> tuple[Path, dict[str, str]] | None: + """Read the persistent credential tier behind a legacy MFA login.""" + path = directory / f"{profile}.store.credentials" + if not path.exists(): + return None + parser = _read_ini(path) + if profile not in parser: + raise _configs.OperationalError( + f"Legacy credential backup {path} has no profile {profile!r}." + ) + values = dict(parser[profile].items()) + if not {"aws_access_key_id", "aws_secret_access_key"} <= values.keys(): + raise _configs.OperationalError( + f"Legacy credential backup {path} has incomplete persistent credentials." + ) + return path, values + + +def _region_values(directory: Path, profile: str) -> dict[str, str]: + """Capture only non-secret regional settings from an authenticated source.""" + parser = _read_ini(directory / "config") + section = _section(profile, config=True) + if section not in parser: + return {} + return { + key: parser[section][key] + for key in ("region", "output") + if key in parser[section] + } + + +def _apply_region_values( + destination: Path, + profile: str, + values: dict[str, str], + explicit: str | None, +) -> None: + """Apply login-compatible region/output inheritance to one profile section.""" + parser = _read_ini(destination / "config") + section = _section(profile, config=True) + if section not in parser: + parser.add_section(section) + if explicit: + parser[section]["region"] = explicit + else: + for key, value in values.items(): + if key not in parser[section]: + parser[section][key] = value + parser[section].pop("login_session", None) + _write_ini(destination / "config", parser) + + +def _session_is_usable_source(session: dict[str, Any]) -> None: + """Reject managed records that no longer represent usable AWS credentials.""" + method = session.get("auth_method") + if method in {"ecr-only", "browser-cache-residue", "logout-residue"}: + raise _configs.OperationalError( + f"Managed source session is {method}; log in again before assuming a role." + ) + expires_at = session.get("expires_at") + if expires_at: + try: + expired = datetime.fromisoformat(str(expires_at)) <= datetime.now(UTC) + except (TypeError, ValueError) as error: + raise _configs.OperationalError( + "Managed source session has an invalid expiration timestamp." + ) from error + if expired: + raise _configs.OperationalError( + "Managed source session has expired; log in again before assuming a role." + ) + + +def _assume_preflight(context: _configs.Context) -> dict[str, Any]: + """Resolve and validate an already-authenticated AssumeRole handoff.""" + args = context.args + source, source_profile, destination, destination_profile = _paths(args) + if ( + getattr(args, "to_profile", None) + and not getattr(args, "to_directory", None) + and not getattr(args, "to", None) + ): + destination = source + destination_profile = _normalize_profile(args.to_profile) + source = source.absolute() + destination = destination.absolute() + source_key = f"{source}::{source_profile}" + destination_key = f"{destination}::{destination_profile}" + same_key = source_key == destination_key + explicit_self = bool(getattr(args, "self_destination", False)) + verbose_self = ( + bool(getattr(args, "to", None)) + or bool(getattr(args, "to_directory", None)) + or bool(getattr(args, "to_profile", None)) + ) + if same_key and not (explicit_self or verbose_self): + raise _configs.OperationalError( + "Source and destination are the same profile. Use --self or explicitly " + "repeat the destination with --to LOCATION:PROFILE." + ) + if explicit_self and not same_key: + raise _configs.OperationalError("--self must resolve to the source profile.") + if same_key and bool(getattr(args, "keep_source", False)): + raise _configs.OperationalError("--keep-source cannot be combined with --self.") + + sessions = _state.load_sessions() + source_record = sessions.get(source_key) + legacy = None if source_record else _legacy_source_backup(source, source_profile) + keep_source = bool(getattr(args, "keep_source", False)) + if source_record: + _session_is_usable_source(source_record) + elif legacy is None and not keep_source: + raise _configs.OperationalError( + "The source profile is not a Hacksaws-managed login. Retry with " + "--keep-source to leave the source untouched." + ) + force = bool(getattr(args, "force", False)) + source_plans: list[tuple[Path, str, dict[str, Any]]] = [] + source_cache: tuple[list[Path], list[Path], list[dict[str, str]]] = ([], [], []) + if source_record and not keep_source: + source_plans = _profile_section_plans( + source_record, source, source_profile, force=force + ) + source_cache = _tracked_login_cache_plan(source_record, source, force=force) + + destination_record = sessions.get(destination_key) + destination_cache: tuple[list[Path], list[Path], list[dict[str, str]]] = ( + [], + [], + [], + ) + if destination_record and not same_key: + _profile_section_plans( + destination_record, destination, destination_profile, force=False + ) + destination_cache = _tracked_login_cache_plan( + destination_record, destination, force=False + ) + destination_exists = _profile_exists(destination, destination_profile) + if ( + not same_key + and destination_record is None + and destination_exists + and not bool(getattr(args, "replace", False)) + ): + raise _configs.OperationalError( + f"Destination profile {destination_profile!r} already exists outside " + "Hacksaws management; retry with --replace after reviewing it." + ) + + if source_record: + configured = source_record.get("login_cache_directories", []) + source_login_cache = ( + Path(str(configured[0])).absolute() if configured else _native_login_cache() + ) + else: + source_login_cache = _native_login_cache() + with _aws_environment( + source / "config", source / "credentials", source_login_cache + ): + authenticated = boto3.Session(profile_name=source_profile) + source_account, partition, source_arn = _identity( + authenticated, label="authenticated assume-role source" + ) + target = _target_details(args, source_account, partition) + role, policy, external_id, boundary_name = _role_details( + args, target, source_account, partition + ) + if role is None: + raise _configs.OperationalError( + "hacksaws assume requires a concrete --role, --boundary/--as, or bounded target." + ) + return { + "source": source, + "source_profile": source_profile, + "source_key": source_key, + "source_record": source_record, + "source_plans": source_plans, + "source_cache": source_cache, + "legacy": legacy, + "destination": destination, + "destination_profile": destination_profile, + "destination_key": destination_key, + "destination_record": destination_record, + "destination_cache": destination_cache, + "same_key": same_key, + "explicit_self": explicit_self, + "keep_source": keep_source, + "keep_ecr": bool(getattr(args, "keep_ecr", False)), + "replace": bool(getattr(args, "replace", False)), + "destination_exists": destination_exists, + "authenticated": authenticated, + "source_account": source_account, + "source_partition": partition, + "source_arn": source_arn, + "target": target, + "role": role, + "policy": policy, + "external_id": external_id, + "boundary_name": boundary_name, + "region_values": _region_values(source, source_profile), + } + + +def _assume_public_plan(plan: dict[str, Any]) -> dict[str, Any]: + """Return a stable, secret-free preview of an AssumeRole handoff.""" + source_record = plan.get("source_record") or {} + warnings = [] + if plan["same_key"] and not plan["explicit_self"]: + warnings.append( + "The explicit destination resolves to the source profile; the source " + "credentials will be replaced in place." + ) + if plan["same_key"]: + destination_action = "replace-source-in-place" + elif plan.get("destination_record") is not None: + destination_action = "replace-managed" + elif plan["destination_exists"]: + destination_action = "replace-unmanaged" + else: + destination_action = "create" + return { + "source": { + "directory": str(plan["source"]), + "profile": plan["source_profile"], + "account": plan["source_account"], + "partition": plan["source_partition"], + "principal": plan["source_arn"], + "authMethod": source_record.get("auth_method", "unmanaged"), + "willLogout": not plan["keep_source"], + }, + "destination": { + "directory": str(plan["destination"]), + "profile": plan["destination_profile"], + "sameAsSource": plan["same_key"], + "replacesManaged": plan.get("destination_record") is not None, + }, + "role": plan["role"], + "policy": plan["policy"], + "boundary": plan["boundary_name"], + "target": plan["target"].get("target_name"), + "durationSeconds": plan["effective_duration"], + "keepSource": plan["keep_source"], + "keepEcr": plan["keep_ecr"], + "replace": plan["replace"], + "lifecycle": { + "source": "keep" if plan["keep_source"] else "logout", + "destination": destination_action, + "ecr": "keep" if plan["keep_ecr"] else "logout-after-commit", + "replaceApproved": plan["replace"], + }, + "warnings": warnings, + } + + +def _assume_arguments_fingerprint(args: Any) -> str: + """Bind execution to the security-relevant arguments used for its preview.""" + names = ( + "profile", + "directory", + "aws_account_name", + "target", + "to", + "to_directory", + "to_profile", + "self_destination", + "role", + "boundary", + "policy", + "external_id", + "account", + "session_name", + "region", + "duration", + "htl", + "mtl", + "stl", + "keep_source", + "keep_ecr", + "replace", + "force", + ) + encoded = json.dumps( + {name: getattr(args, name, None) for name in names}, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode() + return _state.digest(encoded) + + +def _owned_cache_state(plan: dict[str, Any]) -> dict[str, str | None]: + paths = [*plan["source_cache"][1], *plan["destination_cache"][1]] + result: dict[str, str | None] = {} + for path in paths: + absolute = path.absolute() + result[str(absolute)] = ( + _state.digest(absolute.read_bytes()) if absolute.exists() else None + ) + return result + + +def _file_fingerprint(path: Path) -> str | None: + return _state.digest(path.read_bytes()) if path.exists() else None + + +def _session_record_state(record: object) -> dict[str, Any]: + """Fingerprint one sessions.json key without embedding its record in a journal.""" + if not isinstance(record, dict): + return {"exists": False, "fingerprint": None} + encoded = json.dumps( + record, sort_keys=True, separators=(",", ":"), default=str + ).encode() + return {"exists": True, "fingerprint": _state.digest(encoded)} + + +def _session_final_state(record: dict[str, Any] | None) -> dict[str, Any]: + state = _session_record_state(record) + if record is not None: + state["values"] = copy.deepcopy(record) + return state + + +def _current_session_state(key: str) -> dict[str, Any]: + return _session_record_state(_state.load_sessions().get(key)) + + +def prepare_assume_role(context: _configs.Context) -> AssumeRolePlan: + """Freeze a secret-safe AssumeRole plan for preview and later execution.""" + data = _assume_preflight(context) + role = str(data["role"]) + match = re.fullmatch(r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/.+", role) + if match is None: + raise _configs.OperationalError(f"Invalid role ARN {role!r}.") + data["effective_duration"] = _effective_assume_duration( + data["authenticated"], role, args=context.args, target=data["target"] + ) + data["resolved_policy"] = ( + _policies.resolve( + data["policy"], + account_id=match.group(2), + partition=match.group(1), + profile=data["source_profile"], + session=data["authenticated"], + ) + if data["policy"] + else None + ) + data["hacksaws_config_expected"] = _file_fingerprint(_state.root() / "config.json") + data["policy_source_expected"] = None + resolved = data["resolved_policy"] + if resolved and resolved.origin == "local": + policy_path = Path(resolved.provenance).expanduser().absolute() + data["policy_source_expected"] = { + "path": str(policy_path), + "fingerprint": _file_fingerprint(policy_path), + } + elif resolved and resolved.origin == "stored": + policy_path = ( + _policies.stored_directory() / f"{resolved.identity}.yaml" + ).absolute() + data["policy_source_expected"] = { + "path": str(policy_path), + "fingerprint": _file_fingerprint(policy_path), + } + data["source_expected"] = { + "credentials": _section_state( + data["source"] / "credentials", data["source_profile"] + ), + "config": _section_state( + data["source"] / "config", + _section(data["source_profile"], config=True), + ), + } + data["destination_expected"] = { + "credentials": _section_state( + data["destination"] / "credentials", data["destination_profile"] + ), + "config": _section_state( + data["destination"] / "config", + _section(data["destination_profile"], config=True), + ), + } + sessions = _state.load_sessions() + source_session = _session_record_state(sessions.get(data["source_key"])) + destination_session = _session_record_state(sessions.get(data["destination_key"])) + if source_session != _session_record_state(data["source_record"]) or ( + destination_session != _session_record_state(data["destination_record"]) + ): + raise _configs.OperationalError( + "Managed session metadata changed during AssumeRole preparation; retry." + ) + data["source_session_expected"] = source_session + data["destination_session_expected"] = destination_session + data["cache_expected"] = _owned_cache_state(data) + return AssumeRolePlan(data, _assume_arguments_fingerprint(context.args)) + + +def assume_role_preview(plan: AssumeRolePlan) -> dict[str, Any]: + """Return the public, secret-free view of one frozen AssumeRole plan.""" + if not isinstance(plan, AssumeRolePlan): + raise TypeError("assume_role_preview requires prepare_assume_role output") + return _assume_public_plan(plan._data) + + +def _cleanup_assume_ecr( + owners: dict[str, tuple[str, list[str]]], +) -> list[dict[str, str]]: + """Remove post-commit ECR state and retain precise local residue on failure.""" + failures: list[dict[str, str]] = [] + operations: dict[tuple[str, str], set[str]] = {} + for key, (engine, registries) in owners.items(): + for registry in registries: + operations.setdefault((engine, registry), set()).add(key) + for (engine_name, registry), keys in operations.items(): + engine = cast("_configs.ContainerEngine", engine_name) + try: + _ecr._run_container_engine(engine, [engine, "logout", registry]) + except _configs.OperationalError as error: + failures.append({"registry": registry, "message": str(error)}) + continue + sessions = _state.load_sessions() + for key in keys: + session = sessions.get(key) + if not session: + continue + remaining = [value for value in session.get("ecr", []) if value != registry] + if remaining: + session["ecr"] = remaining + elif session.get("auth_method") == "ecr-only": + sessions.pop(key, None) + else: + session["ecr"] = [] + _state.save_sessions(sessions) + return failures + + +def _assume_original_section( + data: dict[str, Any], *, source: bool, kind: str +) -> dict[str, Any]: + """Return only the authorized persistent section used after logout.""" + record = data["source_record"] if source else data["destination_record"] + if record: + item = record.get("section_backup", {}).get(kind) + if not isinstance(item, dict) or not isinstance(item.get("original"), dict): + raise _configs.OperationalError( + "Managed AssumeRole endpoint has no safe original section state." + ) + return copy.deepcopy(item["original"]) + directory = cast("Path", data["source"] if source else data["destination"]) + profile = str(data["source_profile"] if source else data["destination_profile"]) + if source and data["legacy"] and kind == "credentials": + return {"exists": True, "values": dict(data["legacy"][1])} + parser = _read_ini(directory / kind) + section = _section(profile, config=kind == "config") + values = _section_values(parser, section) + return {"exists": values is not None, "values": values or {}} + + +def _write_section(path: Path, section: str, state: dict[str, Any]) -> None: + parser = _read_ini(path) + if state.get("exists"): + values = state.get("values") + if not isinstance(values, dict): + raise _configs.OperationalError("AssumeRole journal section is invalid.") + parser[section] = {str(key): str(value) for key, value in values.items()} + else: + parser.remove_section(section) + _write_ini(path, parser) + + +def _planned_destination_config(data: dict[str, Any], args: Any) -> dict[str, str]: + destination = cast("Path", data["destination"]) + profile = str(data["destination_profile"]) + parser = _read_ini(destination / "config") + section = _section(profile, config=True) + values = dict(parser[section].items()) if section in parser else {} + if getattr(args, "region", None): + values["region"] = str(args.region) + else: + for key, value in data["region_values"].items(): + values.setdefault(key, value) + values.pop("login_session", None) + return values + + +def _revalidate_assume_plan( + context: _configs.Context, prepared: AssumeRolePlan +) -> None: + data = prepared._data + changed = prepared._consumed or ( + prepared._arguments_fingerprint != _assume_arguments_fingerprint(context.args) + ) + for prefix, directory_key, profile_key in ( + ("source", "source", "source_profile"), + ("destination", "destination", "destination_profile"), + ): + directory = cast("Path", data[directory_key]) + profile = str(data[profile_key]) + current = { + "credentials": _section_state(directory / "credentials", profile), + "config": _section_state( + directory / "config", _section(profile, config=True) + ), + } + changed = changed or current != data[f"{prefix}_expected"] + changed = changed or _owned_cache_state(data) != data["cache_expected"] + changed = ( + changed + or _file_fingerprint(_state.root() / "config.json") + != data["hacksaws_config_expected"] + ) + policy_source = data.get("policy_source_expected") + if policy_source: + changed = ( + changed + or _file_fingerprint(Path(policy_source["path"])) + != policy_source["fingerprint"] + ) + sessions = _state.load_sessions() + changed = ( + changed + or _session_record_state(sessions.get(data["source_key"])) + != data["source_session_expected"] + ) + changed = ( + changed + or _session_record_state(sessions.get(data["destination_key"])) + != data["destination_session_expected"] + ) + if changed: + raise AssumePlanChanged( + "AssumeRole plan changed after preview; no local credential changes were " + "made. Review a fresh preview and retry." + ) + + +def _write_assume_journal(journal: dict[str, Any]) -> None: + _state.atomic_write( + _journal_path(), (json.dumps(journal, indent=2, default=str) + "\n").encode() + ) + + +def _build_assume_journal( + data: dict[str, Any], + args: Any, + credentials: dict[str, Any], + metadata: dict[str, Any], +) -> dict[str, Any]: + source = cast("Path", data["source"]) + destination = cast("Path", data["destination"]) + source_profile = str(data["source_profile"]) + destination_profile = str(data["destination_profile"]) + credential_values = { + "aws_access_key_id": credentials["AccessKeyId"], + "aws_secret_access_key": credentials["SecretAccessKey"], + "aws_session_token": credentials["SessionToken"], + } + config_values = _planned_destination_config(data, args) + source_original = ( + { + kind: _assume_original_section(data, source=True, kind=kind) + for kind in ("credentials", "config") + } + if not data["keep_source"] + else {} + ) + destination_original = ( + copy.deepcopy(source_original) + if data["same_key"] + else { + kind: _assume_original_section(data, source=False, kind=kind) + for kind in ("credentials", "config") + } + ) + runtime_record = ( + data["source_record"] if data["same_key"] else data["destination_record"] + ) or {} + inherited_ecr = list(runtime_record.get("ecr", [])) + inherited_engine = str(runtime_record.get("ecr_engine") or "docker") + metadata.update( + source_account=data["source_account"], + source_partition=data["source_partition"], + source_profile=source_profile, + source_destination=str(source), + source_auth_method=(data["source_record"] or {}).get( + "auth_method", "legacy-mfa" if data["legacy"] else "unmanaged" + ), + source_logged_out=not data["keep_source"], + target=data["target"].get("target_name"), + ) + section_backup = { + "credentials": { + "path": str((destination / "credentials").absolute()), + "section": destination_profile, + "original": destination_original["credentials"], + "installed": { + "exists": True, + "fingerprint": _section_fingerprint(credential_values), + }, + }, + "config": { + "path": str((destination / "config").absolute()), + "section": _section(destination_profile, config=True), + "original": destination_original["config"], + "installed": { + "exists": True, + "fingerprint": _section_fingerprint(config_values), + }, + }, + } + previous_backup = ( + data["destination_record"].get("backup", []) + if data["destination_record"] + else [] + ) + destination_session = { + **metadata, + "destination": str(destination), + "profile": destination_profile, + "auth_method": "assume-role", + "started_at": _state.iso_now(), + "backup": previous_backup, + "section_backup": section_backup, + "ecr": inherited_ecr, + "ecr_engine": inherited_engine if inherited_ecr else None, + } + cache = [ + {"path": path, "fingerprint": fingerprint} + for path, fingerprint in data["cache_expected"].items() + ] + source_record = data["source_record"] or {} + source_runtime = { + "started_at": source_record.get("started_at"), + "ecr": list(source_record.get("ecr", [])), + "ecr_engine": source_record.get("ecr_engine"), + } + if data["same_key"]: + source_session_final = _session_final_state(destination_session) + elif data["keep_source"]: + source_session_final = copy.deepcopy(data["source_session_expected"]) + elif source_runtime["ecr"]: + source_session_final = _session_final_state( + { + "destination": str(source), + "profile": source_profile, + "auth_method": "ecr-only", + "started_at": source_runtime["started_at"], + "backup": [], + "section_backup": {}, + "ecr": source_runtime["ecr"], + "ecr_engine": source_runtime["ecr_engine"], + } + ) + else: + source_session_final = _session_final_state(None) + legacy_backup = None + if data["legacy"]: + legacy_path = Path(data["legacy"][0]).absolute() + legacy_backup = { + "path": str(legacy_path), + "fingerprint": _file_fingerprint(legacy_path), + } + return { + "schema_version": 2, + "kind": "assume-role", + "phase": "prepared", + "source": { + "directory": str(source), + "profile": source_profile, + "key": data["source_key"], + "same_key": data["same_key"], + "keep": data["keep_source"], + "original": source_original, + "expected": data["source_expected"], + "session_expected": data["source_session_expected"], + "session_final": source_session_final, + "legacy_backup": legacy_backup, + "runtime": source_runtime, + }, + "destination": { + "directory": str(destination), + "profile": destination_profile, + "key": data["destination_key"], + "original": destination_original, + "expected": data["destination_expected"], + "session_expected": data["destination_session_expected"], + "final": { + "credentials": section_backup["credentials"]["installed"], + "config": section_backup["config"]["installed"], + "config_values": config_values, + }, + "session": destination_session, + "session_final": _session_final_state(destination_session), + }, + "cache": cache, + "ecr_owners": {}, + } + + +def _original_section_state(original: dict[str, Any]) -> dict[str, Any]: + exists = bool(original.get("exists")) + values = original.get("values", {}) + return { + "exists": exists, + "fingerprint": _section_fingerprint(values if exists else None), + } + + +def _assume_recovery_error(label: str) -> NoReturn: + raise _configs.OperationalError( + f"AssumeRole recovery stopped because {label} changed outside the prepared " + "transaction. The recovery journal was retained for manual review." + ) + + +def _require_assume_state( + label: str, current: dict[str, Any], *allowed: dict[str, Any] +) -> None: + identity = (current.get("exists"), current.get("fingerprint")) + if not any( + identity == (state.get("exists"), state.get("fingerprint")) for state in allowed + ): + _assume_recovery_error(label) + + +def _validate_assume_cache(journal: dict[str, Any], *, allow_missing: bool) -> None: + for item in journal.get("cache", []): + path = Path(str(item["path"])).absolute() + if not path.exists(): + if allow_missing: + continue + _assume_recovery_error(f"browser login cache {path}") + try: + current = _state.digest(path.read_bytes()) + except OSError: + _assume_recovery_error(f"browser login cache {path}") + if current != item.get("fingerprint"): + _assume_recovery_error(f"browser login cache {path}") + + +def _validate_assume_legacy(journal: dict[str, Any], *, allow_missing: bool) -> None: + item = journal["source"].get("legacy_backup") + if not item: + return + path = Path(str(item["path"])).absolute() + if not path.exists(): + if allow_missing: + return + _assume_recovery_error(f"legacy credential backup {path}") + if _file_fingerprint(path) != item.get("fingerprint"): + _assume_recovery_error(f"legacy credential backup {path}") + + +def _validate_assume_recovery(journal: dict[str, Any], *, roll_forward: bool) -> None: + source = journal["source"] + destination = journal["destination"] + destination_directory = Path(destination["directory"]) + destination_profile = str(destination["profile"]) + destination_credentials = _section_state( + destination_directory / "credentials", destination_profile + ) + _require_assume_state( + "destination credential section", + destination_credentials, + destination["final"]["credentials"] + if roll_forward + else destination["expected"]["credentials"], + ) + destination_config = _section_state( + destination_directory / "config", + _section(destination_profile, config=True), + ) + if roll_forward: + _require_assume_state( + "destination config section", + destination_config, + destination["expected"]["config"], + destination["final"]["config"], + ) + else: + _require_assume_state( + "destination config section", + destination_config, + destination["expected"]["config"], + ) + + sessions = _state.load_sessions() + destination_session = _session_record_state(sessions.get(destination["key"])) + source_session = _session_record_state(sessions.get(source["key"])) + if roll_forward: + _require_assume_state( + "destination session metadata", + destination_session, + destination["session_expected"], + destination["session_final"], + ) + _require_assume_state( + "source session metadata", + source_session, + source["session_expected"], + source["session_final"], + ) + else: + _require_assume_state( + "destination session metadata", + destination_session, + destination["session_expected"], + ) + _require_assume_state( + "source session metadata", + source_session, + source["session_expected"], + ) + + if not source["same_key"]: + source_directory = Path(source["directory"]) + source_profile = str(source["profile"]) + for kind in ("credentials", "config"): + current = _section_state( + source_directory / kind, + _section(source_profile, config=kind == "config"), + ) + allowed = [source["expected"][kind]] + if roll_forward and not source["keep"]: + allowed.append(_original_section_state(source["original"][kind])) + _require_assume_state(f"source {kind} section", current, *allowed) + _validate_assume_cache(journal, allow_missing=roll_forward) + _validate_assume_legacy(journal, allow_missing=roll_forward) + + +def _remove_assume_cache( + journal: dict[str, Any], *, strict: bool = False +) -> list[dict[str, str]]: + residue = [] + for item in journal.get("cache", []): + path = Path(str(item["path"])).absolute() + if not path.exists(): + continue + try: + current = _state.digest(path.read_bytes()) + except OSError as error: + if strict: + _assume_recovery_error(f"browser login cache {path}") + residue.append({"path": str(path), "reason": f"unreadable: {error}"}) + continue + if current != item.get("fingerprint"): + if strict: + _assume_recovery_error(f"browser login cache {path}") + residue.append({"path": str(path), "reason": "fingerprint changed"}) + continue + try: + path.unlink() + except OSError as error: + if strict: + raise _configs.OperationalError( + f"Unable to remove owned browser login cache {path}; the " + "AssumeRole recovery journal was retained: {error}" + ) from error + residue.append({"path": str(path), "reason": f"remove failed: {error}"}) + return residue + + +def _write_section_cas( + path: Path, + section: str, + *, + expected: dict[str, Any], + final: dict[str, Any], + values: dict[str, Any], + label: str, +) -> None: + current = _section_state(path, section) + if current == final: + return + _require_assume_state(label, current, expected) + _write_section(path, section, values) + _require_assume_state(label, _section_state(path, section), final) + + +def _write_session_cas( + key: str, + *, + expected: dict[str, Any], + final: dict[str, Any], + label: str, +) -> None: + sessions = _state.load_sessions() + current = _session_record_state(sessions.get(key)) + if (current.get("exists"), current.get("fingerprint")) == ( + final.get("exists"), + final.get("fingerprint"), + ): + return + _require_assume_state(label, current, expected) + if final.get("exists"): + values = final.get("values") + if not isinstance(values, dict): + raise _configs.OperationalError( + "AssumeRole journal final session metadata is invalid." + ) + sessions[key] = copy.deepcopy(values) + else: + sessions.pop(key, None) + _state.save_sessions(sessions) + _require_assume_state(label, _current_session_state(key), final) + + +def _install_assume_destination(journal: dict[str, Any]) -> None: + _validate_assume_recovery(journal, roll_forward=True) + destination = journal["destination"] + directory = Path(destination["directory"]) + profile = str(destination["profile"]) + _write_section_cas( + directory / "config", + _section(profile, config=True), + expected=destination["expected"]["config"], + final=destination["final"]["config"], + values={"exists": True, "values": destination["final"]["config_values"]}, + label="destination config section", + ) + _require_assume_state( + "destination credential section", + _section_state(directory / "credentials", profile), + destination["final"]["credentials"], + ) + _write_session_cas( + destination["key"], + expected=destination["session_expected"], + final=destination["session_final"], + label="destination session metadata", + ) + + +def _finish_assume_source(journal: dict[str, Any]) -> None: + _validate_assume_recovery(journal, roll_forward=True) + source = journal["source"] + same_key = bool(source["same_key"]) + if not source["keep"] and not same_key: + directory = Path(source["directory"]) + profile = str(source["profile"]) + for kind in ("credentials", "config"): + final_values = source["original"][kind] + _write_section_cas( + directory / kind, + _section(profile, config=kind == "config"), + expected=source["expected"][kind], + final=_original_section_state(final_values), + values=final_values, + label=f"source {kind} section", + ) + legacy = source.get("legacy_backup") + if legacy and not source["keep"]: + path = Path(str(legacy["path"])).absolute() + if path.exists(): + if _file_fingerprint(path) != legacy.get("fingerprint"): + _assume_recovery_error(f"legacy credential backup {path}") + path.unlink() + _remove_assume_cache(journal, strict=True) + if not same_key: + _write_session_cas( + source["key"], + expected=source["session_expected"], + final=source["session_final"], + label="source session metadata", + ) + + +def _recover_assume_journal(journal: dict[str, Any]) -> None: + """Recover by finishing restriction/logout, never restoring expanded source auth.""" + if journal.get("schema_version") != 2: + raise _configs.OperationalError("Unsupported AssumeRole transaction journal.") + destination = journal["destination"] + directory = Path(destination["directory"]) + profile = str(destination["profile"]) + credentials = _section_state(directory / "credentials", profile) + installed = credentials == destination["final"]["credentials"] + prepared = journal.get("phase") == "prepared" + if not installed and not ( + prepared and credentials == destination["expected"]["credentials"] + ): + _assume_recovery_error("destination credential section") + _validate_assume_recovery(journal, roll_forward=installed) + if installed: + _install_assume_destination(journal) + _finish_assume_source(journal) + _commit() + + +def assume_role( + context: _configs.Context, prepared: AssumeRolePlan | None = None +) -> _configs.Result: + """Execute one prepared AssumeRole plan with secret-free roll-forward recovery.""" + plan = prepared or prepare_assume_role(context) + if not isinstance(plan, AssumeRolePlan): + raise TypeError("assume_role requires prepare_assume_role output") + data = plan._data + if plan._consumed: + raise AssumePlanChanged( + "AssumeRole plan has already been consumed; prepare again." + ) + if plan._arguments_fingerprint != _assume_arguments_fingerprint(context.args): + raise AssumePlanChanged( + "AssumeRole plan changed after preview; no local credential changes were " + "made. Review a fresh preview and retry." + ) + plan._consumed = True + credentials, metadata = _assume( + data["authenticated"], + data["role"], + policy=data["policy"], + source_profile=data["source_profile"], + args=context.args, + target=data["target"], + external_id=data["external_id"], + boundary_name=data["boundary_name"], + effective_duration=data["effective_duration"], + resolved_policy=data["resolved_policy"], + ) + plan._consumed = False + _revalidate_assume_plan(context, plan) + plan._consumed = True + journal = _build_assume_journal(data, context.args, credentials, metadata) + _write_assume_journal(journal) + try: + destination = cast("Path", data["destination"]) + credential_values = { + "aws_access_key_id": credentials["AccessKeyId"], + "aws_secret_access_key": credentials["SecretAccessKey"], + "aws_session_token": credentials["SessionToken"], + } + _write_section_cas( + destination / "credentials", + str(data["destination_profile"]), + expected=journal["destination"]["expected"]["credentials"], + final=journal["destination"]["final"]["credentials"], + values={"exists": True, "values": credential_values}, + label="destination credential section", + ) + _install_assume_destination(journal) + journal["phase"] = "destination-installed" + _write_assume_journal(journal) + _finish_assume_source(journal) + journal["phase"] = "source-removed" + _write_assume_journal(journal) + _commit() + except Exception: + persisted = json.loads(_journal_path().read_text(encoding="utf-8")) + _recover_assume_journal(persisted) + raise + + ecr_owners: dict[str, tuple[str, list[str]]] = {} + source_runtime = journal["source"]["runtime"] + if source_runtime.get("ecr") and not data["same_key"]: + ecr_owners[data["source_key"]] = ( + str(source_runtime.get("ecr_engine") or "docker"), + list(source_runtime["ecr"]), + ) + destination_session = journal["destination"]["session"] + if destination_session.get("ecr"): + ecr_owners[data["destination_key"]] = ( + str(destination_session.get("ecr_engine") or "docker"), + list(destination_session["ecr"]), + ) + failures = [] + if ecr_owners and not bool(getattr(context.args, "keep_ecr", False)): + failures = _cleanup_assume_ecr(ecr_owners) + public = _assume_public_plan(data) + public.update( + targetAccount=metadata["target_account"], + expiresAt=metadata["expires_at"], + policyProvenance=metadata.get("policy_provenance"), + ecrResidue=failures, + ) + if failures: + return _configs.Result( + "ASSUME_ROLE_ECR_RESIDUE", + "Role credentials were installed and the local credential handoff " + "completed, but one or more ECR logouts failed; tracked residue remains.", + 1, + "stderr", + public, + kind="warning", + ) + return _configs.Result( + "ASSUME_ROLE", + f"Assumed {data['role']} into profile {data['destination_profile']}.", + data=public, + ) + + def _location_for_directory(directory: Path) -> str | None: absolute = directory.expanduser().absolute() default = (Path.home() / ".aws").absolute() diff --git a/hacksaws/tests/scripts/live_iam_smoke.py b/hacksaws/tests/scripts/live_iam_smoke.py index 8895114..9f9c8cb 100644 --- a/hacksaws/tests/scripts/live_iam_smoke.py +++ b/hacksaws/tests/scripts/live_iam_smoke.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import sys @@ -18,10 +19,13 @@ from collections.abc import Callable from pathlib import Path +import boto3 from botocore.exceptions import ClientError from hacksaws import _cli from hacksaws import _iam_cli +from hacksaws import _sessions +from hacksaws import _state IAM_PATH = "/hacksaws-test/" OPT_IN = "HACKSAWS_LIVE_AWS" @@ -51,7 +55,58 @@ def _absent(call: Callable[..., object], **kwargs: str) -> bool: return False -def main() -> int: +def _guarded_source(target: str) -> tuple[Path, str]: + """Resolve only the source endpoint of the already-guarded saved target.""" + data = _state.load_config() + _name, configured = _state.get_resource(data, "target", target.lstrip("+")) + source = ( + Path(str(configured["source_directory"])).expanduser().absolute() + if configured.get("source_directory") + else _state.aws_directory(str(configured.get("source_location", "default"))) + ) + profile = str(configured.get("source_profile", "default")) + return source, "default" if profile in {".", "default"} else profile + + +def _fingerprint(path: Path) -> tuple[bool, str | None]: + """Return a secret-free file fingerprint for source-preservation checks.""" + if not path.exists(): + return False, None + return True, hashlib.sha256(path.read_bytes()).hexdigest() + + +def _session_fingerprint(directory: Path, profile: str) -> str | None: + """Fingerprint only the selected source's managed lifecycle record.""" + record = _state.load_sessions().get(f"{directory.absolute()}::{profile}") + if record is None: + return None + encoded = json.dumps(record, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _verify_assumed_identity( + destination: Path, profile: str, expected: str, partition: str, role_name: str +) -> None: + """Verify the isolated destination is the unique smoke role in the guard account.""" + with _sessions._aws_environment( + destination / "config", + destination / "credentials", + destination / "login" / "cache", + ): + identity = ( + boto3.Session(profile_name=profile).client("sts").get_caller_identity() + ) + expected_prefix = f"arn:{partition}:sts::{expected}:assumed-role/{role_name}/" + if identity.get("Account") != expected or not str( + identity.get("Arn", "") + ).startswith(expected_prefix): + raise RuntimeError( + "Assumed-role smoke identity mismatch: expected account " + f"{expected} and role {role_name}." + ) + + +def main() -> int: # noqa: PLR0915 """Create, exercise, clean, and verify one uniquely tagged IAM fixture set.""" expected = os.environ.get(ACCOUNT, "") target = os.environ.get(TARGET, "") @@ -92,6 +147,7 @@ def main() -> int: policy_arn = ( f"arn:{context.partition}:iam::{expected}:policy{IAM_PATH}{policy_name}" ) + role_arn = f"arn:{context.partition}:iam::{expected}:role{IAM_PATH}{role_name}" smoke_tags = [ {"Key": "hacksaws:smoke", "Value": "true"}, {"Key": "hacksaws:run-id", "Value": run_id}, @@ -100,6 +156,8 @@ def main() -> int: with tempfile.TemporaryDirectory(prefix="hacksaws-live-smoke-") as temporary: directory = Path(temporary) + assumed_directory = directory / "aws-assumed" + assumed_profile = "hacksaws-smoke-assumed" policy_file = directory / "managed-policy.json" updated_policy_file = directory / "managed-policy-updated.json" inline_file = directory / "inline-policy.json" @@ -131,6 +189,15 @@ def main() -> int: updated_policy_file.write_text(json.dumps(updated_document), encoding="utf-8") inline_file.write_text(json.dumps(base_document), encoding="utf-8") + source_directory, source_profile = _guarded_source(target) + source_paths = ( + source_directory / "credentials", + source_directory / "config", + ) + source_before = {path: _fingerprint(path) for path in source_paths} + source_session_before = _session_fingerprint(source_directory, source_profile) + assume_attempted = False + try: _run( [ @@ -197,6 +264,55 @@ def main() -> int: "--yes", ] ) + assume_attempted = True + _run( + [ + "assume", + source_profile, + "--directory", + str(source_directory), + "--to-directory", + str(assumed_directory), + "--to-profile", + assumed_profile, + "--role", + role_arn, + "--policy", + str(inline_file), + "--duration", + "15m", + "--keep-source", + "--yes", + ] + ) + _verify_assumed_identity( + assumed_directory, + assumed_profile, + expected, + context.partition, + role_name, + ) + _run( + [ + "logout", + assumed_profile, + "--directory", + str(assumed_directory), + ] + ) + assume_attempted = False + source_after = {path: _fingerprint(path) for path in source_paths} + source_session_after = _session_fingerprint( + source_directory, source_profile + ) + if ( + source_after != source_before + or source_session_after != source_session_before + ): + raise RuntimeError( + "Assume-role smoke changed its guarded source despite " + "--keep-source." + ) cleanup_args = [ "cleanup", "--smoke-run", @@ -208,6 +324,21 @@ def main() -> int: _run([*cleanup_args, "--yes"]) cleaned = True finally: + if assume_attempted: + try: + _run( + [ + "logout", + assumed_profile, + "--directory", + str(assumed_directory), + ] + ) + except Exception as error: # noqa: BLE001 + sys.stderr.write( + "Emergency assumed-profile logout failed; inspect the " + f"temporary destination: {error}\n" + ) if not cleaned: try: _run( @@ -220,18 +351,18 @@ def main() -> int: "--yes", ] ) + cleaned = True except Exception as error: # noqa: BLE001 sys.stderr.write( "Emergency cleanup failed; run the printed recovery command: " f"{error}\n" ) - - if not _absent(context.iam.get_role, RoleName=role_name) or not _absent( - context.iam.get_policy, PolicyArn=policy_arn - ): - raise RuntimeError( - "Live IAM smoke cleanup did not leave both resources absent." - ) + role_absent = _absent(context.iam.get_role, RoleName=role_name) + policy_absent = _absent(context.iam.get_policy, PolicyArn=policy_arn) + if not role_absent or not policy_absent: + raise RuntimeError( + "Live IAM smoke cleanup did not leave both resources absent." + ) sys.stdout.write( f"Verified lifecycle and absence for account {expected}, target {target}, " f"and smoke run {run_id}.\n" diff --git a/hacksaws/tests/test_assume_cli.py b/hacksaws/tests/test_assume_cli.py new file mode 100644 index 0000000..65f907b --- /dev/null +++ b/hacksaws/tests/test_assume_cli.py @@ -0,0 +1,460 @@ +"""Command-surface coverage for explicit post-login role assumption.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _state + + +class _Terminal: + """Minimal interactive stream used by exact-confirmation tests.""" + + @staticmethod + def isatty() -> bool: + return True + + +def _direct_arguments(*extra: str) -> argparse.Namespace: + return _cli._create_parser().parse_args( + [ + "assume", + "admin", + "--name", + "horizon", + "--role", + "AgentSession", + "--to", + "default:agent", + *extra, + ] + ) + + +def _seed_presets(home: Path) -> None: + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + data["boundaries"]["Read"] = { + "role_arn": "arn:aws:iam::123456789012:role/AgentSession", + "account": "Prod", + "verified": False, + } + data["targets"]["Bounded"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_location": "horizon", + "destination_location": "default", + "destination_profile": "agent", + "boundary": "Read", + } + data["targets"]["Open"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_directory": str((home / "aws-source").absolute()), + "destination_location": "default", + "destination_profile": "agent", + } + _state.save_config(data) + + +def test_assume_parser_exposes_explicit_grammar_and_teaching_help( + capsys: pytest.CaptureFixture[str], +) -> None: + direct = _direct_arguments( + "--policy", + "CloudWatchReadOnlyAccess", + "--ttl", + "45m", + "--keep-ecr", + "--replace", + "--yes", + ) + assert direct.access_type == "assume" + assert direct.profile == "admin" + assert direct.aws_account_name == "horizon" + assert direct.role == "AgentSession" + assert direct.to == "default:agent" + assert direct.duration == "45m" + assert direct.keep_ecr + assert direct.replace + assert direct.yes + + boundary = _cli._create_parser().parse_args( + ["assume", "admin", "--as", "Read", "--to-profile", "agent"] + ) + assert boundary.profile == "admin" + assert boundary.boundary == "Read" + assert boundary.to_profile == "agent" + directory = _cli._create_parser().parse_args( + [ + "assume", + "admin", + "--role", + "AgentSession", + "--to-directory", + "./agent-aws", + "--to-profile", + "debug", + ] + ) + assert directory.to_directory == "./agent-aws" + assert directory.to_profile == "debug" + + result = _cli.console_main(["assume", "--help"]) + help_text = capsys.readouterr().out + assert result.code == "HELP" + assert "existing AWS profile" in help_text + assert "--self" in help_text + assert "same endpoint" in help_text + assert "--force" not in help_text + + +def test_assume_validation_normalizes_shorthand_and_rejects_unsafe_combinations( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + _seed_presets(tmp_path) + + target = _cli._create_parser().parse_args(["assume", "!Bounded"]) + _cli._validate_assume(target) + assert target.target == "+Bounded" + assert target.profile is None + + open_target = _cli._create_parser().parse_args( + [ + "assume", + "--target", + "Open", + "--boundary", + "Read", + "--ttl", + "45m", + "--keep-source", + "--keep-ecr", + "--replace", + "--yes", + ] + ) + _cli._validate_assume(open_target) + assert open_target.target == "+Open" + assert open_target.duration == "45m" + assert open_target.keep_source + default_source = _cli._create_parser().parse_args( + ["assume", ".", "--role", "AgentSession", "--self"] + ) + _cli._validate_assume(default_source) + assert default_source.profile == "default" + + invalid = ( + ["assume", "--role", "AgentSession", "--self"], + ["assume", "+", "--self"], + ["assume", "+Open", "--target", "Bounded", "--self"], + ["assume", "admin", "--role", "AgentSession"], + ["assume", "admin", "--role", "AgentSession", "--self", "--keep-source"], + ["assume", "admin", "--role", "AgentSession", "--to", "missing-colon"], + [ + "assume", + "admin", + "--role", + "AgentSession", + "--to-directory", + str(tmp_path / "agent"), + ], + [ + "assume", + "admin", + "--role", + "AgentSession", + "--to", + "default:agent", + "--to-profile", + "other", + ], + [ + "assume", + "admin", + "--role", + "AgentSession", + "--self", + "--to-profile", + "other", + ], + ["assume", "+Bounded", "--policy", "Other"], + ["assume", "+Bounded", "--name", "horizon"], + ["assume", "+Bounded", "--to-profile", "other"], + ["assume", "+Bounded", "--self"], + ["assume", "+Open", "--role", "AgentSession"], + ["assume", "+Open", "--policy", "Other"], + ["assume", "+Open", "--account", "Prod"], + ["assume", "+Open", "--external-id", "secret"], + ["assume", "+Open", "--session-name", "agent"], + ["assume", "+Open", "--region", "us-west-2"], + ["assume", "+Open", "--self", "--boundary", "Read"], + ["assume", "+Open"], + ["assume", "admin", "--boundary", "Missing", "--self"], + ) + for arguments in invalid: + with pytest.raises(_configs.OperationalError): + _cli._validate_assume(_cli._create_parser().parse_args(arguments)) + + +def test_assume_noninteractive_requires_yes_without_executing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + executed: list[bool] = [] + preview = { + "source": "horizon:admin", + "destination": "default:agent", + "role": "arn:aws:iam::123456789012:role/AgentSession", + "account": "123456789012", + "partition": "aws", + } + prepared = object() + monkeypatch.setattr(_cli._sessions, "prepare_assume_role", lambda _ctx: prepared) + monkeypatch.setattr( + _cli._sessions, + "assume_role_preview", + lambda plan: preview if plan is prepared else pytest.fail("plan changed"), + ) + monkeypatch.setattr( + _cli._sessions, + "assume_role", + lambda _ctx, _plan: executed.append(True), + ) + + result = _cli._run_assume(_configs.Context(args=_direct_arguments())) + + assert result.code == "ASSUME_CONFIRMATION_REQUIRED" + assert result.exit_code == _configs.EXIT_CANCELLED + assert result.data == {"preview": preview} + assert executed == [] + + +def test_assume_exact_confirmation_and_yes_dispatch( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + preview = { + "source": "horizon:admin", + "destination": "default:agent", + "role": "AgentSession", + "warnings": ["The source and destination identify the same endpoint."], + } + prepared = object() + executed: list[bool] = [] + monkeypatch.setattr(_cli._sessions, "prepare_assume_role", lambda _ctx: prepared) + monkeypatch.setattr( + _cli._sessions, + "assume_role_preview", + lambda plan: preview if plan is prepared else pytest.fail("plan changed"), + ) + monkeypatch.setattr(sys, "stdin", _Terminal()) + monkeypatch.setattr("builtins.input", lambda _prompt: "y") + monkeypatch.setattr( + _cli._sessions, + "assume_role", + lambda _ctx, _plan: executed.append(True), + ) + declined = _cli._run_assume(_configs.Context(args=_direct_arguments())) + assert declined.code == "ASSUME_CANCELLED" + assert executed == [] + assert "WARNING" in _cli._assume_preview_text(preview) + + monkeypatch.setattr("builtins.input", lambda _prompt: "yes") + + def execute(context: _configs.Context, plan: object) -> _configs.Result: + assert plan is prepared + executed.append(context.args.yes) + return _configs.Result( + "ASSUME_ROLE", + "Assumed AgentSession into default:agent.", + data={"destination": "default:agent"}, + ) + + monkeypatch.setattr(_cli._sessions, "assume_role", execute) + accepted = _cli._run_assume(_configs.Context(args=_direct_arguments())) + assert accepted.code == "ASSUME_ROLE" + assert accepted.data == {"destination": "default:agent", "preview": preview} + assert executed == [True] + + +def test_assume_json_is_secret_free_and_requires_yes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + preview = { + "source": "horizon:admin", + "destination": "default:agent", + "role": "AgentSession", + "account": "123456789012", + "partition": "aws", + } + prepared = object() + monkeypatch.setattr(_cli._sessions, "recover_journal", lambda: None) + monkeypatch.setattr(_cli._sessions, "prepare_assume_role", lambda _ctx: prepared) + monkeypatch.setattr( + _cli._sessions, + "assume_role_preview", + lambda plan: preview if plan is prepared else pytest.fail("plan changed"), + ) + monkeypatch.setattr( + _cli._sessions, + "assume_role", + lambda _ctx, _plan: pytest.fail("AssumeRole must not execute without --yes."), + ) + + result = _cli.console_main( + [ + "assume", + "admin", + "--name", + "horizon", + "--role", + "AgentSession", + "--to", + "default:agent", + "--json", + ] + ) + envelope = json.loads(capsys.readouterr().err) + + assert result.code == "ASSUME_CONFIRMATION_REQUIRED" + assert envelope["error"]["data"] == {"preview": preview} + rendered = json.dumps(envelope) + assert repr(prepared) not in rendered + assert "AccessKeyId" not in rendered + assert "SecretAccessKey" not in rendered + assert "SessionToken" not in rendered + + +def test_assume_confirmation_executes_only_the_prepared_plan_and_surfaces_drift( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + _seed_presets(tmp_path) + prepared = object() + prepares: list[object] = [] + preview = { + "source": {"profile": "admin"}, + "destination": {"profile": "agent"}, + "role": "arn:aws:iam::123456789012:role/AgentSession", + "durationSeconds": 2700, + } + + def prepare(_context: _configs.Context) -> object: + prepares.append(prepared) + return prepared + + def confirm(_prompt: str) -> str: + data = _state.load_config() + data["boundaries"]["Read"]["role_arn"] = ( + "arn:aws:iam::123456789012:role/ChangedAfterPreview" + ) + _state.save_config(data) + return "yes" + + def execute(_context: _configs.Context, plan: object) -> _configs.Result: + assert plan is prepared + message = ( + "AssumeRole plan changed after preview; no local credential changes " + "were made. Review a fresh preview and retry." + ) + raise _cli._sessions.AssumePlanChanged(message) + + monkeypatch.setattr(_cli._sessions, "recover_journal", lambda: None) + monkeypatch.setattr(_cli._sessions, "prepare_assume_role", prepare) + monkeypatch.setattr( + _cli._sessions, + "assume_role_preview", + lambda plan: preview if plan is prepared else pytest.fail("plan changed"), + ) + monkeypatch.setattr(_cli._sessions, "assume_role", execute) + monkeypatch.setattr(sys, "stdin", _Terminal()) + monkeypatch.setattr("builtins.input", confirm) + + result = _cli.console_main(["assume", "+Bounded"]) + + assert result.code == "OPERATIONAL_ERROR" + assert "Review a fresh preview" in result.message + assert prepares == [prepared] + assert "Duration (seconds): 2700" in _cli._assume_preview_text(preview) + assert capsys.readouterr().err + + +def test_assume_engine_revalidation_rejects_config_mutation_after_preview( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + _state.save_config(_state.default_config()) + args = _direct_arguments() + context = _configs.Context(args=args) + source = tmp_path / "source" + destination = tmp_path / "destination" + data: dict[str, Any] = { + "source": source, + "source_profile": "admin", + "destination": destination, + "destination_profile": "agent", + "source_cache": ([], [], []), + "destination_cache": ([], [], []), + "cache_expected": {}, + "hacksaws_config_expected": _cli._sessions._file_fingerprint( + _state.root() / "config.json" + ), + "policy_source_expected": None, + "source_expected": { + "credentials": _cli._sessions._section_state( + source / "credentials", "admin" + ), + "config": _cli._sessions._section_state(source / "config", "profile admin"), + }, + "destination_expected": { + "credentials": _cli._sessions._section_state( + destination / "credentials", "agent" + ), + "config": _cli._sessions._section_state( + destination / "config", "profile agent" + ), + }, + } + prepared = _cli._sessions.AssumeRolePlan( + data, _cli._sessions._assume_arguments_fingerprint(args) + ) + changed = _state.load_config() + changed["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + _state.save_config(changed) + + with pytest.raises(_cli._sessions.AssumePlanChanged, match="fresh preview"): + _cli._sessions._revalidate_assume_plan(context, prepared) + + +def test_assume_rejects_general_force_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + result = _cli.console_main( + [ + "assume", + "admin", + "--role", + "AgentSession", + "--self", + "--force", + ] + ) + assert result.code == "ARGUMENT_ERROR" + assert result.exit_code == _configs.EXIT_USAGE + assert "unrecognized arguments: --force" in capsys.readouterr().err diff --git a/hacksaws/tests/test_assume_role.py b/hacksaws/tests/test_assume_role.py new file mode 100644 index 0000000..39352f5 --- /dev/null +++ b/hacksaws/tests/test_assume_role.py @@ -0,0 +1,1348 @@ +"""Transactional coverage for handoff from an already-authenticated profile.""" + +from __future__ import annotations + +import argparse +import json +from contextlib import AbstractContextManager +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from hacksaws import _configs +from hacksaws import _sessions +from hacksaws import _state + +ACCOUNT = "123456789012" +OTHER_ACCOUNT = "210987654321" +ROLE = f"arn:aws:iam::{ACCOUNT}:role/AgentSession" + + +def _args(source: Path, **overrides: object) -> argparse.Namespace: + values: dict[str, object] = { + "profile": "admin", + "directory": str(source), + "aws_account_name": None, + "target": None, + "to": None, + "to_directory": None, + "to_profile": None, + "self_destination": False, + "role": ROLE, + "boundary": None, + "policy": None, + "external_id": None, + "account": None, + "session_name": None, + "region": None, + "duration": None, + "htl": None, + "mtl": None, + "stl": None, + "keep_source": False, + "keep_ecr": False, + "replace": False, + "force": False, + "yes": True, + "podman": False, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def _ini(path: Path, sections: dict[str, dict[str, str]]) -> None: + parser = _sessions._read_ini(path) + parser.read_dict(sections) + _sessions._write_ini(path, parser) + + +def _home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + _state.save_config(_state.default_config()) + return home + + +def _managed_source(directory: Path, profile: str = "admin") -> None: + _ini( + directory / "credentials", + { + profile: { + "aws_access_key_id": "ORIGINAL", + "aws_secret_access_key": "original-secret", + } + }, + ) + _ini(directory / "config", {f"profile {profile}": {"region": "us-west-2"}}) + journal = _sessions._begin( + [directory / "credentials", directory / "config", _state.sessions_path()] + ) + _sessions._save_credentials( + directory / "credentials", + profile, + { + "AccessKeyId": "AUTHENTICATED", + "SecretAccessKey": "authenticated-secret", + "SessionToken": "authenticated-token", + }, + ) + _sessions._record( + directory, + profile, + { + "source_account": ACCOUNT, + "target_account": ACCOUNT, + "role": None, + "boundary": None, + "policy": None, + "policy_provenance": None, + "expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + }, + journal, + method="mfa", + ) + _sessions._commit() + + +def _managed_browser_source_with_absent_originals( + directory: Path, profile: str = "admin" +) -> Path: + credentials = directory / "credentials" + config = directory / "config" + cache = directory / "login" / "cache" / "browser.json" + journal = _sessions._begin([credentials, config, _state.sessions_path()]) + _ini( + config, + { + f"profile {profile}": { + "login_session": "browser-session", + "region": "us-west-2", + } + }, + ) + cache.parent.mkdir(parents=True) + cache.write_text("browser-auth-token", encoding="utf-8") + _sessions._record( + directory, + profile, + { + "source_account": ACCOUNT, + "target_account": ACCOUNT, + "role": None, + "boundary": None, + "policy": None, + "policy_provenance": "AWS-native login_session", + "expires_at": None, + "login_cache_files": [str(cache.absolute())], + "login_cache_directories": [str(cache.parent.absolute())], + "login_cache_fingerprints": { + str(cache.absolute()): _state.digest(cache.read_bytes()) + }, + }, + journal, + method="browser-native", + ) + _sessions._commit() + return cache + + +def _final() -> tuple[dict[str, object], dict[str, object]]: + return ( + { + "AccessKeyId": "BOUNDARY", + "SecretAccessKey": "boundary-secret", + "SessionToken": "boundary-token", + }, + { + "target_account": ACCOUNT, + "role": ROLE, + "boundary": None, + "policy": None, + "policy_provenance": None, + "expires_at": (datetime.now(UTC) + timedelta(minutes=15)).isoformat(), + }, + ) + + +def _identity_patches() -> tuple[ + AbstractContextManager[object], AbstractContextManager[object] +]: + session = MagicMock(region_name="us-west-2") + session.client.return_value.get_role.return_value = { + "Role": {"MaxSessionDuration": 3600} + } + return ( + patch("hacksaws._sessions.boto3.Session", return_value=session), + patch( + "hacksaws._sessions._identity", + return_value=( + ACCOUNT, + "aws", + f"arn:aws:sts::{ACCOUNT}:assumed-role/Admin/live", + ), + ), + ) + + +def _preview(args: argparse.Namespace) -> dict[str, Any]: + context = _configs.Context(args) + return _sessions.assume_role_preview(_sessions.prepare_assume_role(context)) + + +def _crash_journal( + args: argparse.Namespace, +) -> tuple[_configs.Context, dict[str, Any]]: + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + credentials, metadata = _final() + journal = _sessions._build_assume_journal( + prepared._data, args, credentials, metadata + ) + _sessions._write_assume_journal(journal) + return context, journal + + +def _install_crash_destination(journal: dict[str, Any]) -> None: + destination = journal["destination"] + credentials, _metadata = _final() + _sessions._save_credentials( + Path(destination["directory"]) / "credentials", + str(destination["profile"]), + credentials, + ) + _sessions._install_assume_destination(journal) + journal["phase"] = "destination-installed" + _sessions._write_assume_journal(journal) + + +def test_assume_moves_managed_source_to_distinct_destination_without_secret_retention( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + args = _args( + source, + to_directory=str(destination), + to_profile="debug", + ) + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + ): + result = _sessions.assume_role(_configs.Context(args)) + + assert result.code == "ASSUME_ROLE" + source_credentials = _sessions._read_ini(source / "credentials") + assert source_credentials["admin"]["aws_access_key_id"] == "ORIGINAL" + destination_credentials = _sessions._read_ini(destination / "credentials") + assert destination_credentials["debug"]["aws_access_key_id"] == "BOUNDARY" + sessions = _state.load_sessions() + assert set(sessions) == {f"{destination.absolute()}::debug"} + assert sessions[f"{destination.absolute()}::debug"]["source_logged_out"] is True + persisted = _state.sessions_path().read_text(encoding="utf-8") + assert "authenticated-secret" not in persisted + assert "authenticated-token" not in persisted + assert not _sessions._journal_path().exists() + + +def test_unmanaged_source_requires_keep_source_and_destination_requires_replace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _ini( + source / "credentials", + {"admin": {"aws_access_key_id": "STATIC", "aws_secret_access_key": "secret"}}, + ) + _ini(destination / "config", {"profile debug": {"region": "us-east-1"}}) + args = _args(source, to_directory=str(destination), to_profile="debug") + with pytest.raises(_configs.OperationalError, match="--keep-source"): + _preview(args) + + args.keep_source = True + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + pytest.raises(_configs.OperationalError, match="--replace"), + ): + _preview(args) + + args.replace = True + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + ): + _sessions.assume_role(_configs.Context(args)) + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("STATIC") + assert ( + _sessions._read_ini(destination / "credentials")["debug"]["aws_access_key_id"] + == "BOUNDARY" + ) + + +def test_distinct_profile_in_same_aws_files_does_not_back_up_source_session_secrets( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + _managed_source(source) + args = _args(source, to_profile="debug") + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + ): + assert _sessions.assume_role(_configs.Context(args)).code == "ASSUME_ROLE" + parser = _sessions._read_ini(source / "credentials") + assert parser["admin"]["aws_access_key_id"] == "ORIGINAL" + assert parser["debug"]["aws_access_key_id"] == "BOUNDARY" + saved = _state.load_sessions()[f"{source.absolute()}::debug"] + assert saved["backup"] == [] + persisted = _state.sessions_path().read_text(encoding="utf-8") + assert "authenticated-secret" not in persisted + assert "authenticated-token" not in persisted + + +def test_self_assume_preserves_original_chain_and_never_backs_up_authenticated_tier( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + _managed_source(source) + args = _args(source, self_destination=True) + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + ): + _sessions.assume_role(_configs.Context(args)) + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("BOUNDARY") + persisted = _state.sessions_path().read_text(encoding="utf-8") + assert "authenticated-secret" not in persisted + assert "authenticated-token" not in persisted + logout_args = argparse.Namespace( + **{ + **vars(args), + "target": None, + "to": None, + "self_destination": False, + "except_profiles": [], + } + ) + assert _sessions.logout(_configs.Context(logout_args)) + restored = _sessions._read_ini(source / "credentials") + assert restored["admin"]["aws_access_key_id"] == "ORIGINAL" + + +def test_legacy_mfa_self_assume_promotes_persistent_backup_into_managed_chain( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + _ini( + source / "credentials", + { + "admin": { + "aws_access_key_id": "LEGACY-AUTH", + "aws_secret_access_key": "legacy-auth-secret", + "aws_session_token": "legacy-auth-token", + } + }, + ) + _ini(source / "config", {"profile admin": {"region": "us-east-2"}}) + _ini( + source / "admin.store.credentials", + { + "admin": { + "aws_access_key_id": "PERSISTENT", + "aws_secret_access_key": "persistent-secret", + } + }, + ) + args = _args(source, self_destination=True) + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + ): + assert _sessions.assume_role(_configs.Context(args)).code == "ASSUME_ROLE" + assert not (source / "admin.store.credentials").exists() + persisted = _state.sessions_path().read_text(encoding="utf-8") + assert "legacy-auth-secret" not in persisted + assert "legacy-auth-token" not in persisted + logout_args = argparse.Namespace( + **{ + **vars(args), + "self_destination": False, + "to": None, + "except_profiles": [], + } + ) + assert _sessions.logout(_configs.Context(logout_args)) + restored = _sessions._read_ini(source / "credentials") + assert restored["admin"]["aws_access_key_id"] == "PERSISTENT" + + +def test_assume_rolls_forward_after_destination_install_phase_write_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + args = _args(source, to_directory=str(destination), to_profile="debug") + original_write_journal = _sessions._write_assume_journal + writes = 0 + + def fail_second_journal_write(journal: dict[str, Any]) -> None: + nonlocal writes + writes += 1 + if writes == 2: + message = "injected phase write failure" + raise _configs.OperationalError(message) + original_write_journal(journal) + + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + patch( + "hacksaws._sessions._write_assume_journal", + side_effect=fail_second_journal_write, + ), + pytest.raises(_configs.OperationalError, match="injected phase write failure"), + ): + _sessions.assume_role(_configs.Context(args)) + source_credentials = _sessions._read_ini(source / "credentials") + assert source_credentials["admin"]["aws_access_key_id"] == "ORIGINAL" + destination_credentials = _sessions._read_ini(destination / "credentials") + assert destination_credentials["debug"]["aws_access_key_id"] == "BOUNDARY" + assert f"{destination.absolute()}::debug" in _state.load_sessions() + assert not _sessions._journal_path().exists() + + +def test_source_drift_fails_closed_only_when_source_will_be_changed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + parser = _sessions._read_ini(source / "credentials") + parser["admin"]["aws_access_key_id"] = "DRIFTED" + _sessions._write_ini(source / "credentials", parser) + args = _args(source, to_directory=str(destination), to_profile="debug") + with pytest.raises(_configs.OperationalError, match="changed after login"): + _preview(args) + + args.keep_source = True + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + ): + assert _sessions.assume_role(_configs.Context(args)).code == "ASSUME_ROLE" + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("DRIFTED") + + +def test_ecr_failure_happens_after_commit_and_leaves_ecr_only_source_residue( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + source_key = f"{source.absolute()}::admin" + sessions = _state.load_sessions() + sessions[source_key].update(ecr=["registry.example"], ecr_engine="docker") + _state.save_sessions(sessions) + args = _args(source, to_directory=str(destination), to_profile="debug") + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + patch( + "hacksaws._ecr._run_container_engine", + side_effect=_configs.OperationalError("docker unavailable"), + ), + ): + result = _sessions.assume_role(_configs.Context(args)) + assert result.code == "ASSUME_ROLE_ECR_RESIDUE" + assert result.exit_code == 1 + sessions = _state.load_sessions() + assert sessions[source_key]["auth_method"] == "ecr-only" + assert sessions[source_key]["ecr"] == ["registry.example"] + assert f"{destination.absolute()}::debug" in sessions + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("ORIGINAL") + assert not _sessions._journal_path().exists() + + +def test_preview_is_secret_free_and_does_not_call_assume_role( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + args = _args(source, to_directory=str(destination), to_profile="debug") + before = _state.sessions_path().read_bytes() + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch, patch("hacksaws._sessions._assume") as assume: + preview = _preview(args) + assume.assert_not_called() + encoded = json.dumps(preview) + assert "authenticated-token" not in encoded + assert "authenticated-secret" not in encoded + assert _state.sessions_path().read_bytes() == before + + +def test_prepared_target_rejects_boundary_config_change_after_sts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + config = _state.load_config() + config["accounts"]["Prod"] = {"id": ACCOUNT, "partition": "aws"} + config["boundaries"]["Read"] = { + "role_arn": ROLE, + "account": "Prod", + "verified": False, + } + config["targets"]["Agent"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_directory": str(source), + "destination_profile": "debug", + "destination_directory": str(destination), + "boundary": "Read", + } + _state.save_config(config) + args = _args(source, profile=None, target="+Agent", role=None) + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + + def change_boundary( + *_args: object, **_kwargs: object + ) -> tuple[dict[str, object], dict[str, object]]: + changed = _state.load_config() + changed["boundaries"]["Read"]["role_arn"] = ( + f"arn:aws:iam::{ACCOUNT}:role/ChangedAfterPreview" + ) + _state.save_config(changed) + return _final() + + with ( + patch("hacksaws._sessions._assume", side_effect=change_boundary), + pytest.raises(_sessions.AssumePlanChanged, match="fresh preview"), + ): + _sessions.assume_role(context, prepared) + assert not (destination / "credentials").exists() + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("AUTHENTICATED") + assert not _sessions._journal_path().exists() + + +def test_prepared_local_policy_rejects_file_change_after_sts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + policy = tmp_path / "agent-policy.json" + policy.write_text( + json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "logs:Get*", "Resource": "*"} + ], + } + ), + encoding="utf-8", + ) + args = _args( + source, + policy=str(policy), + to_directory=str(destination), + to_profile="debug", + ) + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + + def change_policy( + *_args: object, **_kwargs: object + ) -> tuple[dict[str, object], dict[str, object]]: + policy.write_text( + policy.read_text(encoding="utf-8").replace("logs:Get*", "logs:Delete*"), + encoding="utf-8", + ) + return _final() + + with ( + patch("hacksaws._sessions._assume", side_effect=change_policy), + pytest.raises(_sessions.AssumePlanChanged, match="fresh preview"), + ): + _sessions.assume_role(context, prepared) + assert not (destination / "credentials").exists() + assert not _sessions._journal_path().exists() + + +@pytest.mark.parametrize("drift", ["source", "destination"]) +def test_post_sts_profile_drift_fails_without_clobbering_concurrent_change( + drift: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + args = _args(source, to_directory=str(destination), to_profile="debug") + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + + changed_directory = source if drift == "source" else destination + changed_profile = "admin" if drift == "source" else "debug" + + def concurrent_change( + *_args: object, **_kwargs: object + ) -> tuple[dict[str, object], dict[str, object]]: + _ini( + changed_directory / "credentials", + { + changed_profile: { + "aws_access_key_id": "CONCURRENT", + "aws_secret_access_key": "external-secret", + } + }, + ) + return _final() + + with ( + patch("hacksaws._sessions._assume", side_effect=concurrent_change), + pytest.raises(_sessions.AssumePlanChanged, match="fresh preview"), + ): + _sessions.assume_role(context, prepared) + assert _sessions._read_ini(changed_directory / "credentials")[changed_profile][ + "aws_access_key_id" + ] == ("CONCURRENT") + assert not _sessions._journal_path().exists() + + +@pytest.mark.parametrize("drift", ["source", "destination"]) +def test_post_sts_session_metadata_drift_fails_before_journal_creation( + drift: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + args = _args(source, to_directory=str(destination), to_profile="debug") + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + source_key = f"{source.absolute()}::admin" + destination_key = f"{destination.absolute()}::debug" + + def concurrent_session_change( + *_args: object, **_kwargs: object + ) -> tuple[dict[str, object], dict[str, object]]: + sessions = _state.load_sessions() + if drift == "source": + sessions[source_key]["ecr"] = ["changed-after-preview.example"] + else: + sessions[destination_key] = { + "destination": str(destination), + "profile": "debug", + "auth_method": "external-test", + "ecr": ["appeared-after-preview.example"], + } + _state.save_sessions(sessions) + return _final() + + with ( + patch("hacksaws._sessions._assume", side_effect=concurrent_session_change), + pytest.raises(_sessions.AssumePlanChanged, match="fresh preview"), + ): + _sessions.assume_role(context, prepared) + sessions = _state.load_sessions() + changed_key = source_key if drift == "source" else destination_key + assert sessions[changed_key]["ecr"] + assert not _sessions._journal_path().exists() + + +def test_prepared_recovery_rejects_destination_that_appears_after_crash( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + _context, journal = _crash_journal( + _args(source, to_directory=str(destination), to_profile="debug") + ) + _ini( + destination / "credentials", + { + "debug": { + "aws_access_key_id": "EXTERNAL", + "aws_secret_access_key": "external-secret", + } + }, + ) + + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._recover_assume_journal(journal) + assert _sessions._read_ini(destination / "credentials")["debug"][ + "aws_access_key_id" + ] == ("EXTERNAL") + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("AUTHENTICATED") + assert _sessions._journal_path().exists() + + +@pytest.mark.parametrize( + "drift", + ["credentials", "config", "destination-session", "source-session"], +) +def test_installed_recovery_rejects_post_crash_state_drift_before_source_logout( + drift: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + _context, journal = _crash_journal( + _args(source, to_directory=str(destination), to_profile="debug") + ) + _install_crash_destination(journal) + source_key = f"{source.absolute()}::admin" + destination_key = f"{destination.absolute()}::debug" + if drift == "credentials": + _ini( + destination / "credentials", + { + "debug": { + "aws_access_key_id": "DRIFTED", + "aws_secret_access_key": "drifted-secret", + } + }, + ) + elif drift == "config": + _ini(destination / "config", {"profile debug": {"region": "eu-west-1"}}) + else: + sessions = _state.load_sessions() + key = destination_key if drift == "destination-session" else source_key + sessions[key]["ecr"] = ["metadata-drift.example"] + _state.save_sessions(sessions) + + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._recover_assume_journal(journal) + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("AUTHENTICATED") + assert _sessions._journal_path().exists() + if drift == "credentials": + assert _sessions._read_ini(destination / "credentials")["debug"][ + "aws_access_key_id" + ] == ("DRIFTED") + elif drift == "config": + assert _sessions._read_ini(destination / "config")["profile debug"][ + "region" + ] == ("eu-west-1") + else: + key = destination_key if drift == "destination-session" else source_key + assert _state.load_sessions()[key]["ecr"] == ["metadata-drift.example"] + + +def test_installed_recovery_rejects_browser_cache_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + cache = source / "login" / "cache" / "browser.json" + cache.parent.mkdir(parents=True) + cache.write_text("owned-browser-token", encoding="utf-8") + source_key = f"{source.absolute()}::admin" + sessions = _state.load_sessions() + sessions[source_key].update( + auth_method="browser-native", + login_cache_directories=[str(cache.parent.absolute())], + login_cache_files=[str(cache.absolute())], + login_cache_fingerprints={ + str(cache.absolute()): _state.digest(cache.read_bytes()) + }, + ) + _state.save_sessions(sessions) + _context, journal = _crash_journal( + _args(source, to_directory=str(destination), to_profile="debug") + ) + _install_crash_destination(journal) + cache.write_text("externally-changed-token", encoding="utf-8") + + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._recover_assume_journal(journal) + assert cache.read_text(encoding="utf-8") == "externally-changed-token" + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("AUTHENTICATED") + assert _sessions._journal_path().exists() + + +def test_installed_recovery_retries_owned_cache_removal_without_restoring_auth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + cache = source / "login" / "cache" / "browser.json" + cache.parent.mkdir(parents=True) + cache.write_text("owned-browser-token", encoding="utf-8") + source_key = f"{source.absolute()}::admin" + sessions = _state.load_sessions() + sessions[source_key].update( + auth_method="browser-native", + login_cache_directories=[str(cache.parent.absolute())], + login_cache_files=[str(cache.absolute())], + login_cache_fingerprints={ + str(cache.absolute()): _state.digest(cache.read_bytes()) + }, + ) + _state.save_sessions(sessions) + _context, journal = _crash_journal( + _args(source, to_directory=str(destination), to_profile="debug") + ) + _install_crash_destination(journal) + original_unlink = Path.unlink + + def fail_cache_unlink(path: Path, *args: object, **kwargs: object) -> None: + if path == cache: + message = "cache is locked" + raise OSError(message) + original_unlink(path, *args, **kwargs) # type: ignore[arg-type] + + with ( + patch.object(Path, "unlink", fail_cache_unlink), + pytest.raises(_configs.OperationalError, match="journal was retained"), + ): + _sessions._recover_assume_journal(journal) + assert cache.exists() + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("ORIGINAL") + assert _sessions._journal_path().exists() + + _sessions._recover_assume_journal(journal) + assert not cache.exists() + assert not _sessions._journal_path().exists() + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("ORIGINAL") + + +def test_source_removed_recovery_is_idempotent_but_rejects_later_session_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + args = _args(source, to_directory=str(destination), to_profile="debug") + _context, journal = _crash_journal(args) + _install_crash_destination(journal) + _sessions._finish_assume_source(journal) + journal["phase"] = "source-removed" + _sessions._write_assume_journal(journal) + destination_key = f"{destination.absolute()}::debug" + sessions = _state.load_sessions() + sessions[destination_key]["ecr"] = ["changed-after-source-logout.example"] + _state.save_sessions(sessions) + + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._recover_assume_journal(journal) + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("ORIGINAL") + assert _state.load_sessions()[destination_key]["ecr"] == [ + "changed-after-source-logout.example" + ] + assert _sessions._journal_path().exists() + + sessions = _state.load_sessions() + sessions[destination_key] = journal["destination"]["session"] + _state.save_sessions(sessions) + _sessions._recover_assume_journal(journal) + assert not _sessions._journal_path().exists() + assert _sessions._read_ini(destination / "credentials")["debug"][ + "aws_access_key_id" + ] == ("BOUNDARY") + + +def test_prepared_duration_is_reused_by_preview_execution_and_sts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + args = _args( + source, + duration="15m", + to_directory=str(destination), + to_profile="debug", + ) + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + assert _sessions.assume_role_preview(prepared)["durationSeconds"] == 900 + with patch("hacksaws._sessions._assume", return_value=_final()) as assume: + result = _sessions.assume_role(context, prepared) + assert assume.call_args.kwargs["effective_duration"] == 900 + assert isinstance(result.data, dict) + assert result.data["durationSeconds"] == 900 + + +def test_assume_journal_never_contains_authenticated_or_browser_cache_secrets( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + _managed_source(source) + cache = source / "login" / "cache" / "browser.json" + cache.parent.mkdir(parents=True) + cache.write_text("browser-login-token-bytes", encoding="utf-8") + source_key = f"{source.absolute()}::admin" + sessions = _state.load_sessions() + sessions[source_key].update( + auth_method="browser-native", + login_cache_directories=[str(cache.parent.absolute())], + login_cache_files=[str(cache.absolute())], + login_cache_fingerprints={ + str(cache.absolute()): _state.digest(cache.read_bytes()) + }, + ) + _state.save_sessions(sessions) + args = _args(source, to_directory=str(destination), to_profile="debug") + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + captured = "" + + def fail_destination_write(*_args: object, **_kwargs: object) -> None: + nonlocal captured + captured = _sessions._journal_path().read_text(encoding="utf-8") + message = "simulated destination write failure" + raise RuntimeError(message) + + with ( + patch("hacksaws._sessions._assume", return_value=_final()), + patch("hacksaws._sessions._write_section_cas", fail_destination_write), + pytest.raises(RuntimeError, match="simulated destination write failure"), + ): + _sessions.assume_role(context, prepared) + for secret in ( + "AUTHENTICATED", + "authenticated-secret", + "authenticated-token", + "browser-login-token-bytes", + ): + assert secret not in captured + assert cache.exists() + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("AUTHENTICATED") + assert not _sessions._journal_path().exists() + + +def test_self_crash_recovery_rolls_forward_without_resurrecting_broad_credentials( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + _managed_source(source) + args = _args(source, self_destination=True) + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + original_write = _sessions._write_assume_journal + writes = 0 + + def crash_after_install(journal: dict[str, Any]) -> None: + nonlocal writes + writes += 1 + if writes == 2: + message = "simulated phase crash" + raise RuntimeError(message) + original_write(journal) + + with ( + patch("hacksaws._sessions._assume", return_value=_final()), + patch( + "hacksaws._sessions._write_assume_journal", + side_effect=crash_after_install, + ), + patch( + "hacksaws._sessions._recover_assume_journal", + side_effect=RuntimeError("process stopped"), + ), + pytest.raises(RuntimeError, match="process stopped"), + ): + _sessions.assume_role(context, prepared) + persisted = _sessions._journal_path().read_text(encoding="utf-8") + assert "authenticated-secret" not in persisted + assert "authenticated-token" not in persisted + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("BOUNDARY") + + _sessions.recover_journal() + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("BOUNDARY") + assert not _sessions._journal_path().exists() + + logout_args = argparse.Namespace( + **{ + **vars(args), + "self_destination": False, + "to": None, + "except_profiles": [], + } + ) + assert _sessions.logout(_configs.Context(logout_args)) + assert _sessions._read_ini(source / "credentials")["admin"][ + "aws_access_key_id" + ] == ("ORIGINAL") + + +def test_distinct_recovery_cleans_browser_source_with_absent_original_sections( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-browser" + destination = home / ".aws-agent" + cache = _managed_browser_source_with_absent_originals(source) + args = _args(source, to_directory=str(destination), to_profile="debug") + _context, journal = _crash_journal(args) + _install_crash_destination(journal) + + _sessions.recover_journal() + + assert not _sessions._section_state(source / "credentials", "admin")["exists"] + assert not _sessions._section_state(source / "config", "profile admin")["exists"] + assert not cache.exists() + assert not _sessions._journal_path().exists() + sessions = _state.load_sessions() + assert f"{source.absolute()}::admin" not in sessions + assert sessions[f"{destination.absolute()}::debug"]["auth_method"] == "assume-role" + + logout_args = _args(destination, profile="debug") + logout_args.except_profiles = [] + assert _sessions.logout(_configs.Context(logout_args)) + assert not _sessions._section_state(destination / "credentials", "debug")["exists"] + assert not _sessions._section_state(destination / "config", "profile debug")[ + "exists" + ] + + +def test_self_recovery_replaces_browser_auth_then_logout_restores_absence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-browser" + cache = _managed_browser_source_with_absent_originals(source) + args = _args(source, self_destination=True) + _context, journal = _crash_journal(args) + _install_crash_destination(journal) + + _sessions.recover_journal() + + credentials = _sessions._read_ini(source / "credentials") + assert credentials["admin"]["aws_access_key_id"] == "BOUNDARY" + config = _sessions._read_ini(source / "config") + assert "login_session" not in config["profile admin"] + assert not cache.exists() + assert not _sessions._journal_path().exists() + session = _state.load_sessions()[f"{source.absolute()}::admin"] + assert session["auth_method"] == "assume-role" + assert "login_cache_files" not in session + + logout_args = argparse.Namespace( + **{ + **vars(args), + "self_destination": False, + "to": None, + "except_profiles": [], + } + ) + assert _sessions.logout(_configs.Context(logout_args)) + assert not _sessions._section_state(source / "credentials", "admin")["exists"] + assert not _sessions._section_state(source / "config", "profile admin")["exists"] + assert f"{source.absolute()}::admin" not in _state.load_sessions() + + +def test_prepared_plan_contract_rejects_wrong_reused_and_changed_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + _managed_source(source) + args = _args(source, self_destination=True) + context = _configs.Context(args) + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + prepared = _sessions.prepare_assume_role(context) + with pytest.raises(TypeError, match="prepare_assume_role output"): + _sessions.assume_role_preview(object()) # type: ignore[arg-type] + with pytest.raises(TypeError, match="prepare_assume_role output"): + _sessions.assume_role(context, object()) # type: ignore[arg-type] + + prepared._consumed = True + with pytest.raises(_sessions.AssumePlanChanged, match="already been consumed"): + _sessions.assume_role(context, prepared) + prepared._consumed = False + args.role = f"arn:aws:iam::{ACCOUNT}:role/ChangedAfterPreview" + with pytest.raises(_sessions.AssumePlanChanged, match="fresh preview"): + _sessions.assume_role(context, prepared) + assert not _sessions._journal_path().exists() + + +def test_assume_recovery_rejects_invalid_records_and_reports_cache_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + with pytest.raises(_configs.OperationalError, match="Unsupported AssumeRole"): + _sessions._recover_assume_journal({"schema_version": 1}) + with pytest.raises(_configs.OperationalError, match="section is invalid"): + _sessions._write_section( + tmp_path / "credentials", + "admin", + {"exists": True, "values": "not-a-section"}, + ) + with pytest.raises(_configs.OperationalError, match="no safe original"): + _sessions._assume_original_section( + {"source_record": {"section_backup": {}}, "destination_record": None}, + source=True, + kind="credentials", + ) + missing_cache = tmp_path / "missing-owned.cache" + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._validate_assume_cache( + {"cache": [{"path": str(missing_cache), "fingerprint": "expected"}]}, + allow_missing=False, + ) + with pytest.raises(_configs.OperationalError, match="final session metadata"): + _sessions._write_session_cas( + "missing::profile", + expected={"exists": False, "fingerprint": None}, + final={"exists": True, "fingerprint": "invalid-without-values"}, + label="test session metadata", + ) + legacy = tmp_path / "admin.store.credentials" + legacy_journal = { + "source": { + "legacy_backup": { + "path": str(legacy), + "fingerprint": _state.digest(b"original"), + } + } + } + _sessions._validate_assume_legacy(legacy_journal, allow_missing=True) + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._validate_assume_legacy(legacy_journal, allow_missing=False) + legacy.write_bytes(b"changed") + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._validate_assume_legacy(legacy_journal, allow_missing=True) + + changed = tmp_path / "changed.cache" + changed.write_bytes(b"changed") + locked = tmp_path / "locked.cache" + locked.write_bytes(b"owned") + original_unlink = Path.unlink + + def fail_locked(path: Path, *args: object, **kwargs: object) -> None: + if path == locked: + message = "locked by another process" + raise OSError(message) + original_unlink(path, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(Path, "unlink", fail_locked) + residue = _sessions._remove_assume_cache( + { + "cache": [ + {"path": str(changed), "fingerprint": _state.digest(b"original")}, + {"path": str(locked), "fingerprint": _state.digest(b"owned")}, + {"path": str(tmp_path / "missing.cache"), "fingerprint": None}, + ] + } + ) + assert {item["reason"] for item in residue} == { + "fingerprint changed", + "remove failed: locked by another process", + } + assert changed.exists() + assert locked.exists() + + +@pytest.mark.parametrize( + "session", + [ + {"auth_method": "ecr-only"}, + {"auth_method": "mfa", "expires_at": "not-a-time"}, + { + "auth_method": "mfa", + "expires_at": (datetime.now(UTC) - timedelta(seconds=1)).isoformat(), + }, + ], +) +def test_assume_source_state_guards_reject_residue_invalid_and_expired_sessions( + session: dict[str, object], +) -> None: + with pytest.raises(_configs.OperationalError): + _sessions._session_is_usable_source(session) + + +def test_raw_account_id_uses_caller_partition_and_asserts_direct_role_arn() -> None: + args = _args(Path(), role="AgentSession", account=OTHER_ACCOUNT) + role, *_ = _sessions._role_details(args, {}, ACCOUNT, "aws-cn") + assert role == f"arn:aws-cn:iam::{OTHER_ACCOUNT}:role/AgentSession" + + args.role = f"arn:aws-cn:iam::{OTHER_ACCOUNT}:role/AgentSession" + role, *_ = _sessions._role_details(args, {}, ACCOUNT, "aws-cn") + assert role == args.role + + args.role = f"arn:aws-cn:iam::{ACCOUNT}:role/AgentSession" + with pytest.raises(_configs.OperationalError, match="conflicts with --account"): + _sessions._role_details(args, {}, ACCOUNT, "aws-cn") + + +def test_configured_account_name_keeps_its_configured_partition( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + data = _state.default_config() + data["accounts"]["China"] = {"id": OTHER_ACCOUNT, "partition": "aws-cn"} + _state.save_config(data) + args = _args(Path(), role="AgentSession", account="China") + role, *_ = _sessions._role_details(args, {}, ACCOUNT, "aws") + assert role == f"arn:aws-cn:iam::{OTHER_ACCOUNT}:role/AgentSession" + + +def test_legacy_backup_validation_errors_are_operational_errors( + tmp_path: Path, +) -> None: + _ini(tmp_path / "admin.store.credentials", {"other": {"value": "x"}}) + with pytest.raises(_configs.OperationalError, match="has no profile"): + _sessions._legacy_source_backup(tmp_path, "admin") + _ini(tmp_path / "admin.store.credentials", {"admin": {"aws_access_key_id": "x"}}) + with pytest.raises(_configs.OperationalError, match="incomplete"): + _sessions._legacy_source_backup(tmp_path, "admin") + + +def test_region_helpers_cover_absent_config_and_explicit_override( + tmp_path: Path, +) -> None: + assert _sessions._region_values(tmp_path, "admin") == {} + _sessions._apply_region_values( + tmp_path, "debug", {"region": "us-east-1", "output": "json"}, "us-west-1" + ) + config = _sessions._read_ini(tmp_path / "config") + assert config["profile debug"]["region"] == "us-west-1" + assert "output" not in config["profile debug"] + + +def test_verbose_self_preview_warns_and_requires_explicit_endpoint( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + _managed_source(source) + implicit = _args(source) + with pytest.raises(_configs.OperationalError, match="Use --self"): + _preview(implicit) + + verbose = _args(source, to_profile="admin") + session_patch, identity_patch = _identity_patches() + with session_patch, identity_patch: + preview = _preview(verbose) + assert preview["warnings"] + assert preview["lifecycle"]["destination"] == "replace-source-in-place" + + +def test_cleanup_assume_ecr_updates_active_and_residual_owners( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + _state.save_sessions( + { + "residual": { + "auth_method": "ecr-only", + "ecr": ["one.example"], + }, + "active": { + "auth_method": "assume-role", + "ecr": ["one.example", "two.example"], + }, + } + ) + owners = { + "residual": ("docker", ["one.example"]), + "active": ("docker", ["one.example", "two.example"]), + "missing": ("docker", ["one.example"]), + } + with patch("hacksaws._ecr._run_container_engine") as engine: + assert _sessions._cleanup_assume_ecr(owners) == [] + assert engine.call_count == 2 + sessions = _state.load_sessions() + assert "residual" not in sessions + assert sessions["active"]["ecr"] == [] diff --git a/hacksaws/tests/test_live_iam_smoke_harness.py b/hacksaws/tests/test_live_iam_smoke_harness.py index 1dc2d13..ffa781e 100644 --- a/hacksaws/tests/test_live_iam_smoke_harness.py +++ b/hacksaws/tests/test_live_iam_smoke_harness.py @@ -3,30 +3,118 @@ from __future__ import annotations import runpy +from contextlib import nullcontext from pathlib import Path from types import SimpleNamespace +from unittest.mock import MagicMock import pytest -def test_live_smoke_refuses_without_both_required_environment_guards( +@pytest.mark.parametrize( + "missing", + [ + "HACKSAWS_LIVE_AWS", + "HACKSAWS_LIVE_AWS_ACCOUNT_ID", + "HACKSAWS_LIVE_AWS_CLEANUP", + "HACKSAWS_LIVE_AWS_TARGET", + ], +) +def test_live_smoke_refuses_without_every_required_environment_guard( + missing: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.delenv("HACKSAWS_LIVE_AWS", raising=False) - monkeypatch.delenv("HACKSAWS_LIVE_AWS_ACCOUNT_ID", raising=False) + monkeypatch.setenv("HACKSAWS_LIVE_AWS", "1") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_ACCOUNT_ID", "123456789012") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_CLEANUP", "1") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_TARGET", "smoke") + monkeypatch.delenv(missing) script = Path(__file__).parent / "scripts" / "live_iam_smoke.py" namespace = runpy.run_path(str(script)) with pytest.raises(SystemExit, match="Refusing live AWS smoke test"): namespace["main"]() +def test_live_smoke_refuses_malformed_or_mismatched_account_guard( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for key, value in { + "HACKSAWS_LIVE_AWS": "1", + "HACKSAWS_LIVE_AWS_ACCOUNT_ID": "invalid", + "HACKSAWS_LIVE_AWS_CLEANUP": "1", + "HACKSAWS_LIVE_AWS_TARGET": "smoke", + }.items(): + monkeypatch.setenv(key, value) + namespace = runpy.run_path( + str(Path(__file__).parent / "scripts" / "live_iam_smoke.py") + ) + with pytest.raises(SystemExit, match="12-digit"): + namespace["main"]() + + monkeypatch.setenv("HACKSAWS_LIVE_AWS_ACCOUNT_ID", "123456789012") + monkeypatch.setattr( + namespace["_iam_cli"].IamCommandContext, + "create", + lambda _args: SimpleNamespace(account_id="210987654321"), + ) + with pytest.raises(SystemExit, match="Refusing account"): + namespace["main"]() + + +def test_live_smoke_resolves_guarded_source_and_secret_free_fingerprints( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + namespace = runpy.run_path( + str(Path(__file__).parent / "scripts" / "live_iam_smoke.py") + ) + configured = {"source_location": "horizon", "source_profile": "."} + monkeypatch.setattr(namespace["_state"], "load_config", dict) + monkeypatch.setattr( + namespace["_state"], + "get_resource", + lambda _data, _kind, name: (name, configured), + ) + monkeypatch.setattr( + namespace["_state"], "aws_directory", lambda _name: tmp_path / "logical" + ) + assert namespace["_guarded_source"]("+smoke") == ( + tmp_path / "logical", + "default", + ) + + configured.clear() + configured.update( + source_directory=str(tmp_path / "explicit"), source_profile="admin" + ) + assert namespace["_guarded_source"]("smoke") == ( + (tmp_path / "explicit").absolute(), + "admin", + ) + missing = tmp_path / "missing" + assert namespace["_fingerprint"](missing) == (False, None) + monkeypatch.setattr( + namespace["_state"], + "load_sessions", + lambda: { + f"{tmp_path.absolute()}::admin": { + "profile": "admin", + "destination": str(tmp_path), + } + }, + ) + assert namespace["_session_fingerprint"](tmp_path, "admin") is not None + + def test_live_smoke_executes_account_scoped_lifecycle_and_valid_cleanup_selector( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: monkeypatch.setenv("HACKSAWS_LIVE_AWS", "1") monkeypatch.setenv("HACKSAWS_LIVE_AWS_ACCOUNT_ID", "123456789012") monkeypatch.setenv("HACKSAWS_LIVE_AWS_CLEANUP", "1") monkeypatch.setenv("HACKSAWS_LIVE_AWS_TARGET", "smoke") + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "hacksaws")) script = Path(__file__).parent / "scripts" / "live_iam_smoke.py" namespace = runpy.run_path(str(script)) commands: list[list[str]] = [] @@ -53,6 +141,23 @@ def run(arguments: list[str]) -> None: commands.append(arguments) monkeypatch.setitem(namespace["main"].__globals__, "_run", run) + source = tmp_path / "source" + source.mkdir() + (source / "credentials").write_text("source-credentials", encoding="utf-8") + (source / "config").write_text("source-config", encoding="utf-8") + source_before = { + path: path.read_bytes() for path in (source / "credentials", source / "config") + } + monkeypatch.setitem( + namespace["main"].__globals__, + "_guarded_source", + lambda _target: (source, "admin"), + ) + monkeypatch.setitem( + namespace["main"].__globals__, + "_verify_assumed_identity", + lambda *_args: None, + ) monkeypatch.setattr( namespace["_iam_cli"].IamCommandContext, "create", @@ -63,10 +168,153 @@ def run(arguments: list[str]) -> None: assert namespace["main"]() == 0 output = capsys.readouterr().out cleanup_commands = [command for command in commands if command[0] == "cleanup"] - assert len(commands) == 7 + assume = next(command for command in commands if command[0] == "assume") + logout = next(command for command in commands if command[0] == "logout") + assert len(commands) == 9 assert len(cleanup_commands) == 2 assert cleanup_commands[0][1:3] == ["--smoke-run", cleanup_commands[1][2]] assert "--dry-run" in cleanup_commands[0] assert "--yes" in cleanup_commands[1] - assert all("--target" in command and "smoke" in command for command in commands) + guarded_commands = [ + command for command in commands if command[0] in {"iam", "cleanup"} + ] + assert all( + "--target" in command and "smoke" in command for command in guarded_commands + ) + assert assume[1:4] == ["admin", "--directory", str(source)] + assert "--to-directory" in assume + assert "--to-profile" in assume + assert "--keep-source" in assume + assert "--duration" in assume + assert assume[assume.index("--duration") + 1] == "15m" + assert assume[assume.index("--role") + 1].startswith( + "arn:aws:iam::123456789012:role/hacksaws-test/HacksawsSmokeRole" + ) + assert assume[assume.index("--policy") + 1].endswith("inline-policy.json") + destination = assume[assume.index("--to-directory") + 1] + profile = assume[assume.index("--to-profile") + 1] + assert logout == ["logout", profile, "--directory", destination] + assert { + path: path.read_bytes() for path in (source / "credentials", source / "config") + } == source_before assert "Verified lifecycle and absence" in output + + +def test_live_smoke_failure_logs_out_destination_and_still_cleans_iam( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_LIVE_AWS", "1") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_ACCOUNT_ID", "123456789012") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_CLEANUP", "1") + monkeypatch.setenv("HACKSAWS_LIVE_AWS_TARGET", "smoke") + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "hacksaws")) + namespace = runpy.run_path( + str(Path(__file__).parent / "scripts" / "live_iam_smoke.py") + ) + commands: list[list[str]] = [] + source = tmp_path / "source" + source.mkdir() + (source / "credentials").write_text("source-credentials", encoding="utf-8") + (source / "config").write_text("source-config", encoding="utf-8") + + class Iam: + def tag_role(self, **_kwargs: object) -> None: + return None + + def tag_policy(self, **_kwargs: object) -> None: + return None + + def get_role(self, **_kwargs: object) -> None: + raise namespace["ClientError"]( + {"Error": {"Code": "NoSuchEntity", "Message": "gone"}}, "GetRole" + ) + + def get_policy(self, **_kwargs: object) -> None: + raise namespace["ClientError"]( + {"Error": {"Code": "NoSuchEntity", "Message": "gone"}}, + "GetPolicy", + ) + + def run(arguments: list[str]) -> None: + commands.append(arguments) + + monkeypatch.setitem(namespace["main"].__globals__, "_run", run) + monkeypatch.setitem( + namespace["main"].__globals__, + "_guarded_source", + lambda _target: (source, "admin"), + ) + monkeypatch.setitem( + namespace["main"].__globals__, + "_verify_assumed_identity", + lambda *_args: (_ for _ in ()).throw(RuntimeError("identity mismatch")), + ) + monkeypatch.setattr( + namespace["_iam_cli"].IamCommandContext, + "create", + lambda _args: SimpleNamespace( + account_id="123456789012", partition="aws", iam=Iam() + ), + ) + + with pytest.raises(RuntimeError, match="identity mismatch"): + namespace["main"]() + + assume_index = next( + i for i, command in enumerate(commands) if command[0] == "assume" + ) + logout_index = next( + i for i, command in enumerate(commands) if command[0] == "logout" + ) + emergency = commands[-1] + assert assume_index < logout_index + assert emergency[0:1] == ["cleanup"] + assert "--cascade" in emergency + assert "--yes" in emergency + assert any(value.startswith("HacksawsSmokeRole") for value in emergency) + assert (source / "credentials").read_text(encoding="utf-8") == "source-credentials" + assert (source / "config").read_text(encoding="utf-8") == "source-config" + + +def test_live_smoke_assumed_identity_verification_is_exact_and_isolated( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + namespace = runpy.run_path( + str(Path(__file__).parent / "scripts" / "live_iam_smoke.py") + ) + destination = tmp_path / "destination" + sts = MagicMock() + sts.get_caller_identity.return_value = { + "Account": "123456789012", + "Arn": ("arn:aws:sts::123456789012:assumed-role/HacksawsSmokeRoleabc/session"), + } + session = MagicMock() + session.client.return_value = sts + environment = MagicMock(return_value=nullcontext()) + monkeypatch.setattr(namespace["boto3"], "Session", MagicMock(return_value=session)) + monkeypatch.setattr(namespace["_sessions"], "_aws_environment", environment) + + namespace["_verify_assumed_identity"]( + destination, + "assumed", + "123456789012", + "aws", + "HacksawsSmokeRoleabc", + ) + environment.assert_called_once_with( + destination / "config", + destination / "credentials", + destination / "login" / "cache", + ) + + sts.get_caller_identity.return_value["Arn"] = ( + "arn:aws:sts::123456789012:assumed-role/Other/session" + ) + with pytest.raises(RuntimeError, match="identity mismatch"): + namespace["_verify_assumed_identity"]( + destination, + "assumed", + "123456789012", + "aws", + "HacksawsSmokeRoleabc", + ) From 14eb57f5d9b3c1ebca79236dfa79d755a7f3db36 Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Sun, 2 Aug 2026 08:22:48 -0500 Subject: [PATCH 4/8] Strengthen Session Status - **Status Experience** - Present compact, terminal-safe lifecycle summaries with honest role, boundary, policy, TTL, and verification context so operators can understand active AWS access without reading raw metadata. - **Session Integrity** - Preserve non-secret policy provenance and validate cached scope, expiry, identity, and public JSON fields so stale or hostile state fails closed without leaking credentials. --- CHEATSHEET.md | 16 + docs/profiles-and-sessions.md | 33 ++ hacksaws/_cli.py | 291 ++++++++++++-- hacksaws/_policies.py | 22 +- hacksaws/_sessions.py | 428 +++++++++++++++++++- hacksaws/tests/test_local_lifecycle.py | 24 +- hacksaws/tests/test_output_foundation.py | 453 ++++++++++++++++++++++ hacksaws/tests/test_sessions_coverage.py | 473 +++++++++++++++++++++++ 8 files changed, 1682 insertions(+), 58 deletions(-) diff --git a/CHEATSHEET.md b/CHEATSHEET.md index f7800b9..0a45988 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -185,6 +185,22 @@ hacksaws config export [ARCHIVE.zip] hacksaws config import ARCHIVE.zip [--replace] [--yes] ``` +Human `status` output is a compact, dynamic table. `LOCATION` is hidden when all +rows use the default location; `TTL` is hidden when no displayed session has a +meaningful expiry; and `VERIFY` is hidden unless `--verify` returns a useful STS +result. After one blank line, a single filtered-row summary reports state counts +in stable order, for example `State: 1 🟢active | 1 🔴expired`. Auth and scope +meanings/examples live in `hacksaws status --help`. Scopes use friendly labels: +`Role (@Preset) → Policy` shows the actual IAM role, an optional Hacksaws +boundary preset, and its restrictive session policy. TTL is populated only for +active/expiring rows with a positive meaningful expiry; all other rows are +blank, and the column disappears when every row is blank. `verified` means STS +returned the recorded account/partition/role; `mismatch` and `error` are +distinct. Untrusted names and AWS diagnostics cannot inject ANSI or terminal +controls. Use `--json` for stable raw lifecycle fields such as +`remaining_seconds` and IAM references; human symbols and key text never enter +JSON. + Durations: `45m`, `1.5hours`, `90sec`; `--htl 1.5`, `--mtl 90`, and `--stl 5400` are equivalent duration forms. Boundary sessions require at least 900 seconds; role chaining caps them at 3,600 seconds. `cache set max-age 0s` disables cache diff --git a/docs/profiles-and-sessions.md b/docs/profiles-and-sessions.md index 7870e81..916827d 100644 --- a/docs/profiles-and-sessions.md +++ b/docs/profiles-and-sessions.md @@ -17,6 +17,39 @@ hacksaws status --verify hacksaws profile list --verify ``` +The human `status` view is intentionally compact and changes only to fit the +filtered sessions being shown: + +- `LOCATION` is omitted when every result is in the default AWS location. In a + mixed result, `default` is written explicitly. +- `STATE` is one symbol. After one blank line, a single stable-order summary + counts only the filtered rows, for example `State: 1 🟢active | 1 🔴expired`. +- `AUTH` uses `web`, `web→role`, `mfa`, `mfa→role`, `role`, `legacy`, or + `unknown`. `hacksaws status --help` explains each complete login path. +- `SCOPE` uses friendly role and policy labels instead of IAM ARNs. A saved + Hacksaws boundary preset is shown after the actual role as `Role (@Preset)`; + `@Preset` is local configuration, not an IAM permissions boundary or a second + role. `Role → Policy` means the session policy restricts the role: effective + access is the intersection, never the union, of their permissions. +- `TTL` appears only when at least one displayed active or expiring session has + a positive, meaningful expiry. It uses `<1m`, nearest-minute values below two + hours, and nearest-hour values from two hours onward. Expired, invalid, + residue, missing, drifted, legacy, and unknown rows stay blank. +- `VERIFY` appears only when `--verify` produced a useful STS result. `verified` + means the returned account, partition, and expected role matched the recorded + session; `mismatch` and `error` remain distinct. A mismatch detail names the + actual account or role safely inside the mismatch cell. Sessions for which + verification does not apply have a blank cell rather than a dash. + +Profile, location, role, boundary, policy, and AWS diagnostic text is untrusted +terminal input. Hacksaws removes ANSI escapes and control characters before +measuring or rendering the table; `--no-color`, `NO_COLOR`, `TERM=dumb`, and +redirected output cannot be bypassed by stored names or AWS responses. + +These are presentation rules only. `hacksaws status --json` retains the stable, +secret-free lifecycle data, including raw state names, IAM references, and +`remaining_seconds`; it never includes table symbols or key text. + Logout uses profile-section compare-and-swap. Unrelated file sections survive; drifted managed sections are skipped unless `--force` is explicit. diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index 33b90bf..16b7801 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -10,6 +10,9 @@ import os import re import sys +import unicodedata +from decimal import ROUND_HALF_UP +from decimal import Decimal from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -18,6 +21,7 @@ import boto3 from botocore.exceptions import BotoCoreError from botocore.exceptions import ClientError +from rich.text import Text from hacksaws import _aws from hacksaws import _configs @@ -477,7 +481,30 @@ def _create_parser() -> argparse.ArgumentParser: formatter_class=argparse.RawDescriptionHelpFormatter, ) _assume_arguments(assume) - status = types.add_parser("status", help="Show Hacksaws-managed login sessions.") + status = types.add_parser( + "status", + help="Show Hacksaws-managed login sessions.", + description="Show a compact, secret-free view of managed login sessions.", + epilog=( + "AUTH values:\n" + " web AWS browser/passkey login\n" + " web→role browser/passkey login followed by an assumed role\n" + " mfa MFA-authenticated session\n" + " mfa→role MFA authentication followed by an assumed role\n" + " role direct assumed-role handoff\n" + " legacy legacy MFA tracking\n" + " unknown unclassified login metadata\n\n" + "SCOPE examples:\n" + " AgentSession IAM role\n" + " AgentSession (@Guardrail) role plus Hacksaws boundary preset\n" + " AgentSession (@Guardrail) → ReadLogs role restricted by a session policy\n\n" + "Examples:\n" + " hacksaws status\n" + " hacksaws status --verify\n" + " hacksaws status --profile agent --json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) status.add_argument("--profile", help="Filter by destination profile.") status_location = status.add_mutually_exclusive_group() status_location.add_argument("--location", help="Filter by logical AWS location.") @@ -487,7 +514,9 @@ def _create_parser() -> argparse.ArgumentParser: action="store_true", help="Opt in to STS verification for each eligible session.", ) - status.add_argument("--json", action="store_true") + status.add_argument( + "--json", action="store_true", help="Emit stable raw lifecycle JSON." + ) profile = types.add_parser("profile", help="Inspect local AWS profiles safely.") profile_actions = profile.add_subparsers(dest="profile_action") @@ -1175,54 +1204,248 @@ def _json_or_text(value: object, use_json: bool) -> str: return str(value) -def _text_table(columns: list[str], rows: list[list[object]]) -> str: +_TERMINAL_STRING_CONTROL = re.compile( + r"(?:\x1b\]|\x9d).*?(?:\x07|\x1b\\|\x9c|$)|" + r"(?:\x1b[P_^X]|[\x90\x98\x9e\x9f]).*?(?:\x1b\\|\x9c|$)", + re.DOTALL, +) + + +def _safe_terminal_text(value: object) -> str: + """Return single-line printable text with terminal controls removed.""" + decoded = Text.from_ansi(_TERMINAL_STRING_CONTROL.sub("", str(value))).plain + safe = [] + for character in decoded: + if character in "\r\n\t": + safe.append(" ") + elif unicodedata.category(character) not in {"Cc", "Cf", "Cs"}: + safe.append(character) + return "".join(safe) + + +def _text_table(columns: Sequence[str], rows: Sequence[Sequence[object]]) -> str: if not rows: return "(none)" + rendered_columns = [_safe_terminal_text(column) for column in columns] rendered = [ - [str(value) if value is not None else "-" for value in row] for row in rows + [_safe_terminal_text(value) if value is not None else "-" for value in row] + for row in rows ] + + def display_width(value: str) -> int: + return Text(value).cell_len + widths = [ - max(len(column), *(len(row[index]) for row in rendered)) - for index, column in enumerate(columns) + max(display_width(column), *(display_width(row[index]) for row in rendered)) + for index, column in enumerate(rendered_columns) ] + + def pad(value: str, width: int) -> str: + return value + " " * (width - display_width(value)) + header = " ".join( - column.ljust(widths[index]) for index, column in enumerate(columns) - ) + pad(column, widths[index]) for index, column in enumerate(rendered_columns) + ).rstrip() divider = " ".join("-" * width for width in widths) body = [ - " ".join(value.ljust(widths[index]) for index, value in enumerate(row)) + " ".join(pad(value, widths[index]) for index, value in enumerate(row)).rstrip() for row in rendered ] return "\n".join([header, divider, *body]) +_STATUS_STATES = { + "active": ("🟢", "active"), + "expiring": ("🟡", "expiring"), + "expired": ("🔴", "expired"), + "drifted": ("⚠️", "drifted/legacy-unverified"), + "legacy-unverified": ("⚠️", "drifted/legacy-unverified"), + "missing": ("❌", "missing/invalid"), + "invalid": ("❌", "missing/invalid"), + "logout-residue": ("🧹", "logout/ECR residue"), + "ecr-only": ("🧹", "logout/ECR residue"), +} +_UNKNOWN_STATUS_STATE = ("❔", "unknown/inconclusive") +_STATUS_AUTH = { + "browser-native": ("web", "AWS browser/passkey login"), + "browser-boundary": ("web→role", "browser/passkey login, then role"), + "assume-role": ("role", "assumed-role handoff"), + "legacy-mfa": ("legacy", "legacy MFA tracking"), + "browser-cache-residue": ("web", "AWS browser/passkey login"), +} +_UNKNOWN_STATUS_AUTH = ("unknown", "unclassified login") +_STATUS_VERIFICATIONS = { + "verified": "verified", + "mismatch": "mismatch", + "error": "error", +} +_IAM_SCOPE_ARN = re.compile(r"arn:[^:\s]+:iam::(?:aws|\d{12}):(?:role|policy)/([^\s]+)") +_SECONDS_PER_MINUTE = 60 +_SECONDS_PER_HOUR = 60 * _SECONDS_PER_MINUTE + + +def _status_state(item: dict[str, Any]) -> tuple[str, str]: + return _STATUS_STATES.get(str(item.get("state")), _UNKNOWN_STATUS_STATE) + + +def _status_auth(item: dict[str, Any]) -> tuple[str, str]: + method = str(item.get("auth_method") or "") + if method == "mfa": + return ( + ("mfa→role", "MFA login, then role") + if item.get("role") + else ("mfa", "MFA login") + ) + return _STATUS_AUTH.get(method, _UNKNOWN_STATUS_AUTH) + + +def _short_scope(value: object) -> str: + if not value: + return "" + return _IAM_SCOPE_ARN.sub(lambda match: match.group(1), str(value)) + + +def _status_scope(item: dict[str, Any]) -> str: + effective = item.get("effective_scope") + if isinstance(effective, dict): + kind = str(effective.get("kind") or "unknown") + role = effective.get("role_label") + boundary = effective.get("boundary_label") + policy = effective.get("policy_label") + if kind != "role-session": + return { + "ecr-only": "ECR only", + "logout-residue": "logout residue", + "account-login": "account login", + "mfa-session": "MFA session", + "legacy-unknown": "unknown (legacy)", + "unknown": "unknown session", + }.get(kind, "unknown session") + if not role: + role = "role session" + if boundary: + role = f"{role} (@{boundary})" + else: + role = item.get("role") + boundary = item.get("boundary") + if boundary: + role = f"{_short_scope(role) or 'role session'} (@{_short_scope(boundary)})" + policy = item.get("policy") + label = _short_scope(role) + restriction = _short_scope(policy) + if label and restriction: + return f"{label} → {restriction}" + return label or restriction + + +def _status_ttl(item: dict[str, Any]) -> str: + state = str(item.get("state") or "") + if state not in {"active", "expiring"}: + return "" + raw = item.get("remaining_seconds") + if raw is None: + return "" + try: + seconds = Decimal(str(raw)) + except ArithmeticError: + return "" + if seconds <= 0: + return "" + if seconds < _SECONDS_PER_MINUTE: + return "<1m" + if seconds < 2 * _SECONDS_PER_HOUR: + minutes = (seconds / _SECONDS_PER_MINUTE).quantize( + Decimal(1), rounding=ROUND_HALF_UP + ) + return f"{minutes}m" + hours = (seconds / _SECONDS_PER_HOUR).quantize(Decimal(1), rounding=ROUND_HALF_UP) + return f"{hours}h" + + +def _status_verification(item: dict[str, Any]) -> str: + verification = item.get("verification") + if not isinstance(verification, dict): + return "" + status = verification.get("status") + rendered = _STATUS_VERIFICATIONS.get(str(status), "") + if rendered != "mismatch": + return rendered + if verification.get("expected_role") is not None: + return f"mismatch (role {verification.get('actual_role') or 'unknown'})" + return f"mismatch ({verification.get('actual_account') or 'unknown'})" + + +_STATUS_SUMMARY_ORDER = [ + ("active", "🟢", "active"), + ("expiring", "🟡", "expiring"), + ("expired", "🔴", "expired"), + ("drifted", "⚠️", "drifted"), + ("legacy-unverified", "⚠️", "legacy-unverified"), + ("missing", "❌", "missing"), + ("invalid", "❌", "invalid"), + ("logout-residue", "🧹", "logout-residue"), + ("ecr-only", "🧹", "ECR-only"), +] + + +def _status_summary(sessions: Sequence[dict[str, Any]]) -> str: + counts: dict[str, int] = {} + unknown = 0 + known = {state for state, _symbol, _label in _STATUS_SUMMARY_ORDER} + for item in sessions: + state = str(item.get("state") or "") + if state in known: + counts[state] = counts.get(state, 0) + 1 + else: + unknown += 1 + entries = [ + f"{counts[state]} {symbol}{label}" + for state, symbol, label in _STATUS_SUMMARY_ORDER + if counts.get(state) + ] + if unknown: + entries.append(f"{unknown} ❔unknown/inconclusive") + return _safe_terminal_text(f"State: {' | '.join(entries)}") + + def _status_text(report: dict[str, Any]) -> str: - rows = [ - [ - item.get("location") or item.get("destination"), - item.get("profile", "default"), - item.get("state"), - item.get("auth_method"), - item.get("target_account") or item.get("source_account"), - item.get("boundary") or item.get("role"), - item.get("remaining_seconds"), - (item.get("verification") or {}).get("status"), + sessions = report["sessions"] + if not sessions: + return "(none)" + show_location = not all(item.get("location") == "default" for item in sessions) + ttls = [_status_ttl(item) for item in sessions] + show_ttl = any(ttls) + verifications = [_status_verification(item) for item in sessions] + show_verification = any(verifications) + states = [_status_state(item) for item in sessions] + auth = [_status_auth(item) for item in sessions] + + columns = ["PROFILE", "STATE", "AUTH", "ACCOUNT", "SCOPE"] + if show_location: + columns.insert(0, "LOCATION") + if show_ttl: + columns.append("TTL") + if show_verification: + columns.append("VERIFY") + + rows = [] + for index, item in enumerate(sessions): + row = [ + str(item.get("profile") or "default"), + states[index][0], + auth[index][0], + str(item.get("target_account") or item.get("source_account") or ""), + _status_scope(item), ] - for item in report["sessions"] - ] - return _text_table( - [ - "LOCATION", - "PROFILE", - "STATE", - "AUTH", - "ACCOUNT", - "SCOPE", - "REMAINING", - "VERIFY", - ], - rows, - ) + if show_location: + row.insert(0, str(item.get("location") or item.get("destination") or "")) + if show_ttl: + row.append(ttls[index]) + if show_verification: + row.append(verifications[index]) + rows.append(row) + + return f"{_text_table(columns, rows)}\n\n{_status_summary(sessions)}" def _profile_list_text(report: dict[str, Any], *, wide: bool = False) -> str: diff --git a/hacksaws/_policies.py b/hacksaws/_policies.py index a957fe3..28d8a8d 100644 --- a/hacksaws/_policies.py +++ b/hacksaws/_policies.py @@ -36,6 +36,7 @@ class ResolvedPolicy: arn: str | None = None document: str | None = None cached: bool = False + source_arn: str | None = None def _validate_document(value: object) -> dict[str, Any]: @@ -398,7 +399,13 @@ def resolve( "A customer-managed session policy must belong to the target role account." ) if policy_account != "aws": - return ResolvedPolicy(value, "remote-customer", "explicit ARN", arn=value) + return ResolvedPolicy( + value, + "remote-customer", + "explicit ARN", + arn=value, + source_arn=value, + ) return _fetch_aws_managed( value, account_id=account_id, @@ -511,6 +518,7 @@ def _fetch_aws_managed( f"cached policy ({cached[1]:.0f}s old)", document=compact, cached=True, + source_arn=arn, ) try: client = (session or boto3.Session(profile_name=profile)).client("iam") @@ -530,7 +538,9 @@ def _fetch_aws_managed( identity, document, origin="aws-managed", resolver="arn", source_identity=arn ) enforce_inline_limit(compact) - return ResolvedPolicy(identity, "aws-managed", arn, document=compact) + return ResolvedPolicy( + identity, "aws-managed", arn, document=compact, source_arn=arn + ) def _resolve_remote_name( @@ -574,6 +584,7 @@ def _resolve_remote_name( f"cached policy ({cached[1]:.0f}s old)", arn=cached_arn, cached=True, + source_arn=cached_arn, ) resolution_session = session or boto3.Session(profile_name=profile) try: @@ -612,6 +623,7 @@ def _resolve_remote_name( "remote-customer", f"unverified constructed ARN after list failure: {error}", arn=arn, + source_arn=arn, ) if local and aws: raise OperationalError( @@ -636,7 +648,11 @@ def _resolve_remote_name( f"Unable to inspect customer-managed policy {arn}: {error}" ) from error return ResolvedPolicy( - identity, "remote-customer", "verified remote name", arn=arn + identity, + "remote-customer", + "verified remote name", + arn=arn, + source_arn=arn, ) return _fetch_aws_managed( arn, diff --git a/hacksaws/_sessions.py b/hacksaws/_sessions.py index f67bc34..d00e946 100644 --- a/hacksaws/_sessions.py +++ b/hacksaws/_sessions.py @@ -10,6 +10,7 @@ import getpass import importlib import json +import math import os import re import shutil @@ -604,6 +605,21 @@ def _duration_for(args: Any, target: dict[str, Any], *, chained: bool) -> int: return duration +def _policy_display_name( + reference: str, origin: str, source_arn: str | None = None +) -> str: + """Return a compact, non-secret policy label without embedding an ARN.""" + arn_match = _policies.POLICY_ARN.fullmatch(source_arn or reference) + if arn_match: + return arn_match.group(3) + if origin == "local": + return Path(reference).name or "session policy" + value = reference.strip() + if not value or value.casefold().startswith("arn:"): + return "session policy" + return value + + def _assume( session: Any, role: str, @@ -661,11 +677,22 @@ def _assume( f"Boundary identity mismatch: expected {match.group(1)}:{match.group(2)}, got {final_partition}:{account}." ) metadata = { + "session_schema_version": 2, "target_account": account, + "target_partition": final_partition, "role": role, "boundary": boundary_name, "policy": resolved.identity if resolved else None, "policy_provenance": resolved.provenance if resolved else None, + "policy_reference": policy if resolved else None, + "policy_origin": resolved.origin if resolved else None, + "policy_arn": resolved.source_arn if resolved else None, + "policy_cached": resolved.cached if resolved else False, + "policy_display": ( + _policy_display_name(policy, resolved.origin, resolved.source_arn) + if resolved and policy + else None + ), "expires_at": response["Credentials"]["Expiration"].astimezone(UTC).isoformat(), } return response["Credentials"], metadata @@ -998,6 +1025,7 @@ def mfa_login(context: _configs.Context) -> _configs.Result: args.region, ) metadata["source_account"] = source_account + metadata["source_partition"] = partition metadata["target"] = target.get("target_name") _record( destination_dir, @@ -1185,7 +1213,9 @@ def browser_login(context: _configs.Context) -> _configs.Result: destination_profile, { "source_account": account, + "source_partition": partition, "target_account": account, + "target_partition": partition, "target": target.get("target_name"), "role": None, "boundary": None, @@ -1290,7 +1320,11 @@ def browser_login(context: _configs.Context) -> _configs.Result: if section in config: config[section].pop("login_session", None) _write_ini(destination_dir / "config", config) - metadata.update(source_account=source_account, target=target.get("target_name")) + metadata.update( + source_account=source_account, + source_partition=partition, + target=target.get("target_name"), + ) _record( destination_dir, destination_profile, @@ -2449,30 +2483,297 @@ def _managed_section_state(session: dict[str, Any]) -> str | None: return "missing" if missing else None -def _public_session(session: dict[str, Any], *, now: datetime) -> dict[str, Any]: - hidden = { - "backup", - "section_backup", - "login_cache_files", - "login_cache_directories", - "login_cache_fingerprints", +def _role_display_name(value: object) -> str | None: + """Shorten a role ARN while preserving its full IAM path.""" + if not isinstance(value, str) or not value.strip(): + return None + role = value.strip() + match = re.fullmatch(r"arn:(?:aws|aws-us-gov|aws-cn):iam::\d{12}:role/(.+)", role) + if match: + return match.group(1) + if role.casefold().startswith("arn:"): + return None + return role + + +def _cached_policy_source( # noqa: PLR0911 + identity: str, *, target_account: object +) -> dict[str, Any] | None: + """Recover display-only policy metadata from a validated local cache entry.""" + try: + entry = _policies.cache_show(identity) + except _configs.OperationalError: + return None + values = ( + entry.get("origin"), + entry.get("resolver"), + entry.get("source_identity"), + ) + if not all(isinstance(value, str) and value for value in values): + return None + origin, resolver, source = ( + str(values[0]), + str(values[1]), + str(values[2]), + ) + display: str | None = None + arn: str | None = None + if origin == "local" and resolver == "file": + expected = "local-" + _state.digest(source.casefold().encode())[:24] + if identity != expected: + return None + display = Path(source).name + elif origin == "stored" and resolver == "stored": + if identity != f"stored-{source.casefold()}": + return None + display = source + elif origin in {"aws-managed", "remote-customer"}: + match = _policies.POLICY_ARN.fullmatch(source) + if not match: + return None + partition, account, resource = match.groups() + target = str(target_account or "") + if origin == "aws-managed": + if resolver != "arn" or account != "aws" or not target: + return None + expected = _policies._cache_identity( + source, account=target, partition=partition + ) + else: + if resolver != "name" or account == "aws" or account != target: + return None + expected = _policies._cache_identity( + f"name:{resource.rsplit('/', 1)[-1]}", + account=account, + partition=partition, + ) + if identity != expected: + return None + display = resource + arn = source + if not display: + return None + return { + "origin": origin, + "reference": source, + "arn": arn, + "cached": True, + "display": display, + } + + +def _policy_scope(session: dict[str, Any]) -> dict[str, Any]: + """Project persisted policy metadata without resolving anything over the network.""" + policy = session.get("policy") + if not isinstance(policy, str) or not policy: + unknown_policy = bool(policy) + return { + "label": "session policy" if unknown_policy else None, + "known": False if unknown_policy else "policy" in session, + "source": None, + } + persisted_display = session.get("policy_display") + origin = ( + session["policy_origin"] + if isinstance(session.get("policy_origin"), str) + else "unknown" + ) + reference = ( + session["policy_reference"] + if isinstance(session.get("policy_reference"), str) + else None + ) + source_arn = ( + session["policy_arn"] if isinstance(session.get("policy_arn"), str) else None + ) + if isinstance(persisted_display, str) and persisted_display.strip(): + label = _policy_display_name( + persisted_display, + origin, + source_arn, + ) + return { + "label": label, + "known": label != "session policy", + "source": { + "origin": origin, + "reference": reference, + "arn": source_arn, + "cached": bool(session.get("policy_cached", False)), + }, + } + policy_text = policy + provenance = session.get("policy_provenance") + match = _policies.POLICY_ARN.fullmatch(policy_text) + if match: + return { + "label": match.group(3), + "known": True, + "source": { + "origin": "aws-managed" + if match.group(2) == "aws" + else "remote-customer", + "reference": policy_text, + "arn": policy_text, + "cached": False, + }, + } + if ( + isinstance(provenance, str) + and provenance.startswith("stored policy ") + and provenance.removeprefix("stored policy ") == policy_text + ): + return { + "label": policy_text, + "known": True, + "source": { + "origin": "stored", + "reference": policy_text, + "arn": None, + "cached": False, + }, + } + cached = _cached_policy_source( + policy_text, target_account=session.get("target_account") + ) + if cached: + return { + "label": cached.pop("display"), + "known": True, + "source": cached, + } + return { + "label": "session policy", + "known": False, + "source": None, + } + + +def _effective_scope(session: dict[str, Any]) -> dict[str, Any]: + """Describe the credential restriction inputs without claiming IAM evaluation.""" + method = session.get("auth_method") + policy = _policy_scope(session) + boundary = _role_display_name(session.get("boundary")) + role = _role_display_name(session.get("role")) + if method == "ecr-only": + kind = "ecr-only" + elif method in {"browser-cache-residue", "logout-residue"}: + kind = "logout-residue" + elif role or method in {"browser-boundary", "assume-role"}: + kind = "role-session" + elif method == "browser-native": + kind = "account-login" + elif method == "mfa": + kind = "mfa-session" + elif not session.get("section_backup"): + kind = "legacy-unknown" + else: + kind = "unknown" + return { + "kind": kind, + "role_label": role, + "boundary_label": boundary, + "policy_label": policy["label"], + "policy_source": policy["source"], + "policy_known": policy["known"], } - public = {key: value for key, value in session.items() if key not in hidden} - destination = Path(str(public.get("destination", Path.home() / ".aws"))).absolute() + + +_PUBLIC_SESSION_STRING_FIELDS = { + "source_account", + "source_partition", + "target_account", + "target_partition", + "role", + "boundary", + "policy", + "policy_provenance", + "policy_reference", + "policy_origin", + "policy_arn", + "policy_display", + "expires_at", + "target", + "profile", + "auth_method", + "started_at", + "ecr_engine", + "source_profile", + "source_destination", + "source_auth_method", +} +_PUBLIC_SESSION_BOOL_FIELDS = { + "cache_cleanup_incomplete", + "policy_cached", + "source_logged_out", +} +_PUBLIC_SESSION_INT_FIELDS = {"session_schema_version"} + + +def _public_session_fields(session: dict[str, Any]) -> dict[str, Any]: + """Copy only the documented scalar/list session status contract.""" + public: dict[str, Any] = {} + for key in _PUBLIC_SESSION_STRING_FIELDS: + if key in session and (session[key] is None or isinstance(session[key], str)): + public[key] = session[key] + for key in _PUBLIC_SESSION_BOOL_FIELDS: + if key in session and isinstance(session[key], bool): + public[key] = session[key] + for key in _PUBLIC_SESSION_INT_FIELDS: + value = session.get(key) + if isinstance(value, int) and not isinstance(value, bool): + public[key] = value + ecr = session.get("ecr") + if isinstance(ecr, list) and all(isinstance(item, str) for item in ecr): + public["ecr"] = list(ecr) + residue = session.get("login_cache_residue") + if isinstance(residue, list): + public["login_cache_residue"] = [ + {"path": item["path"], "reason": item["reason"]} + for item in residue + if isinstance(item, dict) + and isinstance(item.get("path"), str) + and isinstance(item.get("reason"), str) + ] + return public + + +def _public_session(session: dict[str, Any], *, now: datetime) -> dict[str, Any]: + public = _public_session_fields(session) + raw_destination = session.get("destination") + destination = ( + Path(raw_destination).absolute() + if isinstance(raw_destination, str) + else (Path.home() / ".aws").absolute() + ) public["destination"] = str(destination) public["location"] = _location_for_directory(destination) public["managed"] = True + effective_scope = _effective_scope(session) + public["effective_scope"] = effective_scope drift = _managed_section_state(session) expiry = public.get("expires_at") remaining: int | None = None + expiry_invalid = False if expiry: try: - remaining = max( - 0, int((datetime.fromisoformat(str(expiry)) - now).total_seconds()) - ) - except ValueError: + parsed_expiry = datetime.fromisoformat(str(expiry)) + if parsed_expiry.tzinfo is not None: + remaining = max(0, math.ceil((parsed_expiry - now).total_seconds())) + else: + expiry_invalid = True + except (TypeError, ValueError): remaining = None + expiry_invalid = True public["remaining_seconds"] = remaining + if expiry_invalid: + public["warnings"] = [ + { + "code": "INVALID_EXPIRY", + "source": "expires_at", + "message": "Session expiry metadata is invalid.", + } + ] if public.get("auth_method") in {"browser-cache-residue", "logout-residue"}: state = "logout-residue" elif public.get("auth_method") == "ecr-only": @@ -2481,6 +2782,8 @@ def _public_session(session: dict[str, Any], *, now: datetime) -> dict[str, Any] state = drift elif not session.get("section_backup"): state = "legacy-unverified" + elif expiry_invalid: + state = "invalid" elif remaining == 0 and expiry: state = "expired" elif remaining is not None and remaining <= 900: @@ -2500,9 +2803,67 @@ def status() -> list[dict[str, Any]]: ) +def _verification_expectations( + item: dict[str, Any], +) -> tuple[str, str, str | None] | None: + """Return internally consistent expected account, partition, and role name.""" + account = item.get("target_account") or item.get("source_account") + if not isinstance(account, str) or not re.fullmatch(r"\d{12}", account): + return None + partition_value = item.get("target_partition") or item.get("source_partition") + partition = ( + partition_value + if isinstance(partition_value, str) and partition_value in _state.PARTITIONS + else None + ) + role_name: str | None = None + role = item.get("role") + if isinstance(role, str): + match = re.fullmatch( + r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/(.+)", role + ) + if match: + if match.group(2) != account or ( + partition is not None and match.group(1) != partition + ): + return None + partition = match.group(1) + role_name = match.group(3).rsplit("/", 1)[-1] + if partition is None: + return None + return account, partition, role_name + + +def _safe_caller_arn(arn: str, *, account: str, partition: str) -> str | None: + """Return an identity ARN only when it matches the verified account envelope.""" + if re.fullmatch( + rf"arn:{re.escape(partition)}:(?:iam|sts)::" + rf"{re.escape(account)}:[A-Za-z0-9+=,.@_:/-]+", + arn, + ): + return arn + return None + + def _verify_status(item: dict[str, Any]) -> dict[str, Any]: - if item.get("state") in {"ecr-only", "missing", "drifted"}: + if item.get("state") in { + "ecr-only", + "logout-residue", + "missing", + "drifted", + "invalid", + }: return {"status": "skipped", "reason": f"local state is {item['state']}"} + expected = _verification_expectations(item) + if expected is None: + return { + "status": "error", + "message": ( + "Session metadata does not contain a consistent expected AWS account " + "and partition." + ), + } + expected_account, expected_partition, expected_role = expected directory = Path(str(item["destination"])) profile = str(item.get("profile", "default")) try: @@ -2514,6 +2875,43 @@ def _verify_status(item: dict[str, Any]) -> dict[str, Any]: ) except _configs.OperationalError as error: return {"status": "error", "message": str(error)} + actual_arn = _safe_caller_arn(arn, account=account, partition=partition) + if actual_arn is None: + return { + "status": "error", + "message": "AWS returned an invalid caller ARN during status verification.", + "actual_account": account, + "actual_partition": partition, + } + mismatches = [] + if account != expected_account: + mismatches.append("account") + if partition != expected_partition: + mismatches.append("partition") + actual_role: str | None = None + if expected_role is not None: + assumed = re.fullmatch( + rf"arn:{re.escape(partition)}:sts::{re.escape(account)}:" + r"assumed-role/([^/]+)/[^/]+", + actual_arn, + ) + actual_role = assumed.group(1) if assumed else None + if actual_role != expected_role: + mismatches.append("role") + if mismatches: + mismatch: dict[str, Any] = { + "status": "mismatch", + "reason": f"{', '.join(mismatches)} mismatch", + "expected_account": expected_account, + "actual_account": account, + "expected_partition": expected_partition, + "actual_partition": partition, + "actual_arn": actual_arn, + } + if expected_role is not None: + mismatch["expected_role"] = expected_role + mismatch["actual_role"] = actual_role + return mismatch return { "status": "verified", "account": account, diff --git a/hacksaws/tests/test_local_lifecycle.py b/hacksaws/tests/test_local_lifecycle.py index 49aede9..728ec81 100644 --- a/hacksaws/tests/test_local_lifecycle.py +++ b/hacksaws/tests/test_local_lifecycle.py @@ -356,9 +356,9 @@ def test_status_classifies_conservative_local_states_and_filters( "expires_at": (now + timedelta(minutes=5)).isoformat(), "section_backup": stable, }, - "active": { + "invalid": { "destination": str(destination), - "profile": "active", + "profile": "invalid", "expires_at": "not-a-date", "section_backup": stable, }, @@ -372,10 +372,10 @@ def test_status_classifies_conservative_local_states_and_filters( "drifted": "drifted", "expired": "expired", "expiring": "expiring", - "active": "active", + "invalid": "invalid", } assert all(item["location"] == "horizon" for item in states.values()) - assert _sessions.status_report(profile="active")["counts"] == {"active": 1} + assert _sessions.status_report(profile="invalid")["counts"] == {"invalid": 1} assert _sessions.status_report(location="horizon")["sessions"] assert _sessions.status_report(directory=destination)["sessions"] @@ -397,7 +397,13 @@ def test_status_verification_skips_unsafe_state_and_reports_success_or_error( ), ): verified = _sessions._verify_status( - {"state": "active", "destination": str(destination), "profile": "dev"} + { + "state": "active", + "destination": str(destination), + "profile": "dev", + "target_account": "123456789012", + "target_partition": "aws", + } ) assert verified["status"] == "verified" with ( @@ -408,7 +414,13 @@ def test_status_verification_skips_unsafe_state_and_reports_success_or_error( ), ): failed = _sessions._verify_status( - {"state": "active", "destination": str(destination), "profile": "dev"} + { + "state": "active", + "destination": str(destination), + "profile": "dev", + "target_account": "123456789012", + "target_partition": "aws", + } ) assert failed == {"status": "error", "message": "expired"} diff --git a/hacksaws/tests/test_output_foundation.py b/hacksaws/tests/test_output_foundation.py index 9d6f0eb..45fcaed 100644 --- a/hacksaws/tests/test_output_foundation.py +++ b/hacksaws/tests/test_output_foundation.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest +from rich.text import Text from hacksaws import _cli from hacksaws import _configs @@ -33,6 +34,9 @@ def test_color_policy_handles_windows_style_non_tty_no_color_and_json() -> None: assert not _output.color_enabled( automatic, stream=object(), environ={"NO_COLOR": "1"} ) + assert not _output.color_enabled( + automatic, stream=_Terminal(), environ={"TERM": "dumb"} + ) assert _output.color_enabled( _output.OutputOptions(color="always"), stream=_NotATerminal(), environ={} ) @@ -75,6 +79,455 @@ def test_global_json_wraps_argument_errors( assert rendered["code"] == "ARGUMENT_ERROR" +def test_status_help_teaches_compact_auth_and_scope_semantics( + capsys: pytest.CaptureFixture[str], +) -> None: + result = _cli.console_main(["status", "--help"]) + rendered = capsys.readouterr().out + assert result.exit_code == 0 + assert "AUTH values:" in rendered + assert "web→role" in rendered + assert "SCOPE examples:" in rendered + assert "AgentSession (@Guardrail) → ReadLogs" in rendered + assert "Hacksaws boundary preset" in rendered + + +def test_status_text_golden_is_compact_and_self_explaining() -> None: + rendered = _cli._status_text( + { + "sessions": [ + { + "location": "default", + "profile": "debug", + "state": "active", + "auth_method": "browser-boundary", + "target_account": "123456789012", + "expires_at": "future", + "remaining_seconds": 3583, + "effective_scope": { + "kind": "role-session", + "role_label": "TerraformUnlimited", + "boundary": None, + "policy_label": "CloudWatchReadOnlyAccess", + }, + } + ] + } + ) + assert rendered == ( + "PROFILE STATE AUTH ACCOUNT SCOPE" + " TTL\n" + "------- ----- -------- ------------ " + "--------------------------------------------- ---\n" + "debug 🟢 web→role 123456789012 " + "TerraformUnlimited → CloudWatchReadOnlyAccess 60m\n" + "\n" + "State: 1 🟢active" + ) + assert "LOCATION" not in rendered + assert "arn:" not in rendered + assert "\x1b" not in rendered + assert max(Text.from_ansi(line).cell_len for line in rendered.splitlines()) <= 100 + + +def test_status_text_empty_and_stable_mixed_state_counts() -> None: + assert _cli._status_text({"sessions": []}) == "(none)" + sessions = [ + {"state": "future", "profile": "unknown"}, + {"state": "expired", "profile": "old"}, + {"state": "active", "profile": "one"}, + {"state": "missing", "profile": "gone"}, + {"state": "active", "profile": "two"}, + ] + rendered = _cli._status_text({"sessions": sessions}) + assert rendered.endswith( + "State: 2 🟢active | 1 🔴expired | 1 ❌missing | 1 ❔unknown/inconclusive" + ) + assert rendered.count("\n\nState:") == 1 + assert "Auth " not in rendered + assert "Scope " not in rendered + + +def test_status_text_recomputes_optional_columns_and_state_counts() -> None: + report = { + "sessions": [ + { + "location": "default", + "destination": "ignored", + "profile": "dev", + "state": "expiring", + "auth_method": "mfa", + "role": "arn:aws:iam::123456789012:role/team/Agent", + "source_account": "123456789012", + "expires_at": "future", + "remaining_seconds": 900, + "effective_scope": { + "kind": "role-session", + "role_label": "team/Agent", + "boundary": None, + "policy_label": None, + }, + "verification": {"status": "verified"}, + }, + { + "location": "horizon", + "profile": "admin", + "state": "drifted", + "auth_method": "legacy-mfa", + "source_account": "210987654321", + "expires_at": "future", + "remaining_seconds": 7200, + "effective_scope": { + "kind": "legacy-unknown", + "role_label": None, + "boundary": None, + "policy_label": None, + }, + "verification": {"status": "skipped"}, + }, + ] + } + rendered = _cli._status_text(report) + lines = rendered.splitlines() + assert "LOCATION" in lines[0] + assert "TTL" in lines[0] + assert "VERIFY" in lines[0] + assert "default" in lines[2] + assert "15m" in lines[2] + assert "verified" in lines[2] + assert "horizon" in lines[3] + assert "unknown (legacy)" in lines[3] + assert "skipped" not in rendered + assert rendered.endswith("State: 1 🟡expiring | 1 ⚠️drifted") + assert "Auth " not in rendered + assert "Scope " not in rendered + assert all(" - " not in line for line in lines[2:4]) + + filtered = _cli._status_text({"sessions": [report["sessions"][1]]}) + assert "LOCATION" in filtered + assert "TTL" not in filtered + assert "VERIFY" not in filtered + assert "🟡" not in filtered + assert "mfa→role" not in filtered + assert filtered.endswith("State: 1 ⚠️drifted") + + +@pytest.mark.parametrize( + ("state", "seconds", "expected"), + [ + ("active", None, ""), + ("active", "not-a-duration", ""), + ("active", 0.25, "<1m"), + ("active", 59.99, "<1m"), + ("active", 60, "1m"), + ("expiring", 89, "1m"), + ("expiring", 90, "2m"), + ("expiring", 900, "15m"), + ("active", 3583, "60m"), + ("active", 7199, "120m"), + ("active", 7200, "2h"), + ("active", 8999, "2h"), + ("active", 9000, "3h"), + ("active", 0, ""), + ("expiring", -1, ""), + ("expired", 0, ""), + ("expired", 3583, ""), + ("invalid", 3583, ""), + ("missing", 3583, ""), + ("drifted", 3583, ""), + ("legacy-unverified", 3583, ""), + ("logout-residue", 3583, ""), + ("ecr-only", 3583, ""), + ], +) +def test_status_ttl_boundaries_are_deterministic( + state: str, seconds: float | None, expected: str +) -> None: + assert _cli._status_ttl({"state": state, "remaining_seconds": seconds}) == expected + + +def test_status_ttl_column_requires_a_positive_active_or_expiring_value() -> None: + sessions = [ + { + "location": "default", + "profile": state, + "state": state, + "expires_at": "present", + "remaining_seconds": 3600, + } + for state in ( + "expired", + "invalid", + "logout-residue", + "ecr-only", + "missing", + "drifted", + "legacy-unverified", + "future", + ) + ] + sessions.append( + { + "location": "default", + "profile": "zero", + "state": "active", + "expires_at": "present", + "remaining_seconds": 0, + } + ) + rendered = _cli._status_text({"sessions": sessions}) + assert "TTL" not in rendered.splitlines()[0] + assert rendered.endswith( + "State: 1 🟢active | 1 🔴expired | 1 ⚠️drifted | " + "1 ⚠️legacy-unverified | 1 ❌missing | 1 ❌invalid | " + "1 🧹logout-residue | 1 🧹ECR-only | 1 ❔unknown/inconclusive" + ) + + +@pytest.mark.parametrize( + ("method", "role", "expected"), + [ + ("browser-native", None, "web"), + ("browser-boundary", "role", "web→role"), + ("mfa", None, "mfa"), + ("mfa", "role", "mfa→role"), + ("assume-role", "role", "role"), + ("legacy-mfa", None, "legacy"), + ("new-method", None, "unknown"), + ], +) +def test_status_auth_mapping_is_stable( + method: str, role: str | None, expected: str +) -> None: + assert _cli._status_auth({"auth_method": method, "role": role})[0] == expected + + +@pytest.mark.parametrize( + ("state", "expected"), + [ + ("active", "🟢"), + ("expiring", "🟡"), + ("expired", "🔴"), + ("drifted", "⚠️"), + ("legacy-unverified", "⚠️"), + ("missing", "❌"), + ("invalid", "❌"), + ("logout-residue", "🧹"), + ("ecr-only", "🧹"), + ("future-state", "❔"), + ], +) +def test_status_state_mapping_is_stable(state: str, expected: str) -> None: + assert _cli._status_state({"state": state})[0] == expected + + +@pytest.mark.parametrize( + ("kind", "expected"), + [ + ("account-login", "account login"), + ("mfa-session", "MFA session"), + ("ecr-only", "ECR only"), + ("logout-residue", "logout residue"), + ("legacy-unknown", "unknown (legacy)"), + ("unknown", "unknown session"), + ("future-kind", "unknown session"), + ], +) +def test_status_scope_non_role_kinds_are_honest(kind: str, expected: str) -> None: + assert ( + _cli._status_scope( + { + "effective_scope": { + "kind": kind, + "role_label": None, + "policy_label": None, + } + } + ) + == expected + ) + + +def test_status_scope_has_honest_malformed_role_fallback() -> None: + assert ( + _cli._status_scope( + { + "effective_scope": { + "kind": "role-session", + "role_label": None, + "policy_label": "session policy", + } + } + ) + == "role session → session policy" + ) + + +def test_status_scope_distinguishes_iam_role_from_hacksaws_boundary() -> None: + item = { + "effective_scope": { + "kind": "role-session", + "role_label": "AgentSession", + "boundary_label": "Guardrail", + "policy_label": "ReadLogs", + } + } + assert _cli._status_scope(item) == "AgentSession (@Guardrail) → ReadLogs" + rendered = _cli._status_text( + { + "sessions": [ + { + **item, + "location": "default", + "profile": "agent", + "state": "active", + "auth_method": "assume-role", + "target_account": "123456789012", + } + ] + } + ) + assert "AgentSession (@Guardrail) → ReadLogs" in rendered + assert "@name = Hacksaws boundary preset" not in rendered + assert "→ = restrictive session policy" not in rendered + assert rendered.endswith("State: 1 🟢active") + + +def test_status_scope_supports_legacy_records_without_leaking_arns() -> None: + assert ( + _cli._status_scope( + { + "boundary": None, + "role": "arn:aws:iam::123456789012:role/team/Agent", + "policy": "arn:aws:iam::aws:policy/ReadOnlyAccess", + } + ) + == "team/Agent → ReadOnlyAccess" + ) + + +def test_status_text_sanitizes_hostile_untrusted_cells_before_layout() -> None: + escape = "\x1b" + rendered = _cli._status_text( + { + "sessions": [ + { + "location": f"{escape}]0;owned\x07horizon\rnext", + "profile": f"{escape}[31mprod{escape}[0m\nforged\tcell\u202e", + "state": "active", + "auth_method": "assume-role", + "target_account": "123456789012\x00", + "effective_scope": { + "kind": "role-session", + "role_label": "界e\u0301Agent\x00", + "boundary_label": f"Guard{escape}[2J", + "policy_label": ( + f"Read{escape}]8;;https://invalid.example\x07Logs" + f"{escape}]8;;\x07\nInjected" + ), + }, + } + ] + } + ) + assert "\x1b" not in rendered + assert "\x00" not in rendered + assert "\u202e" not in rendered + assert "https://invalid.example" not in rendered + assert "horizon next" in rendered + assert "prod forged cell" in rendered + assert "界e\u0301Agent (@Guard) → ReadLogs Injected" in rendered + assert len(rendered.splitlines()) == 5 + assert _cli._safe_terminal_text("safe\x1b]0;unterminated") == "safe" + assert _cli._safe_terminal_text("safe\x9d0;owned\x9ctext") == "safetext" + + +def test_status_verification_uses_only_meaningful_results() -> None: + assert _cli._status_verification({"verification": {"status": "error"}}) == "error" + assert ( + _cli._status_verification({"verification": {"status": "mismatch"}}) + == "mismatch (unknown)" + ) + assert _cli._status_verification({"verification": {"status": "skipped"}}) == "" + assert _cli._status_verification({}) == "" + + +def test_status_verification_distinguishes_match_mismatch_and_error() -> None: + base = { + "location": "default", + "state": "active", + "auth_method": "browser-native", + "target_account": "123456789012", + "effective_scope": { + "kind": "account-login", + "role_label": None, + "boundary_label": None, + "policy_label": None, + }, + } + rendered = _cli._status_text( + { + "sessions": [ + { + **base, + "profile": "matched", + "verification": {"status": "verified"}, + }, + { + **base, + "profile": "mismatch\nforged", + "verification": { + "status": "mismatch", + "expected_account": "123456789012", + "actual_account": "210987654321\x1b[31m", + }, + }, + { + **base, + "profile": "error", + "verification": {"status": "error", "message": "denied"}, + }, + { + **base, + "profile": "not-applicable", + "verification": {"status": "skipped"}, + }, + ] + } + ) + assert "verified" in rendered + assert "mismatch" in rendered + assert "error" in rendered + assert "skipped" not in rendered + assert "mismatch (210987654321)" in rendered + assert "Verify " not in rendered + assert "mismatch forged" in rendered + assert rendered.endswith("State: 4 🟢active") + assert "\x1b" not in rendered + + +def test_text_table_aligns_terminal_cells_and_strips_controls() -> None: + rendered = _cli._text_table( + ["STATE", "VALUE"], + [ + ["🟢", "emoji"], + ["界", "wide"], + ["e\u0301", "combining"], + ["\x1b[31mred\x1b[0m", "ansi"], + ], + ) + expected_column = None + for line, value in zip( + rendered.splitlines()[2:], ["emoji", "wide", "combining", "ansi"], strict=True + ): + plain = Text.from_ansi(line).plain + offset = Text(plain[: plain.index(value)]).cell_len + expected_column = expected_column or offset + assert offset == expected_column + assert "\x1b" not in rendered + assert "red" in rendered + + def test_json_prescan_wraps_every_early_exit_once_and_preserves_human_help( capsys: pytest.CaptureFixture[str], ) -> None: diff --git a/hacksaws/tests/test_sessions_coverage.py b/hacksaws/tests/test_sessions_coverage.py index ac4ccbc..4eaa672 100644 --- a/hacksaws/tests/test_sessions_coverage.py +++ b/hacksaws/tests/test_sessions_coverage.py @@ -413,6 +413,9 @@ def test_assume_builds_policy_request_and_verifies_final_identity( document='{"Version":"2012-10-17","Statement":[]}' if document_policy else None, identity="Read", provenance="stored", + origin="stored", + cached=document_policy, + source_arn=(None if document_policy else f"arn:aws:iam::{ACCOUNT}:policy/Read"), ) final = MagicMock() with ( @@ -436,6 +439,13 @@ def test_assume_builds_policy_request_and_verifies_final_identity( assert request["ExternalId"] == "external" assert credentials == response["Credentials"] assert metadata["policy_provenance"] == "stored" + assert metadata["policy_reference"] == "Read" + assert metadata["policy_origin"] == "stored" + assert metadata["policy_arn"] == resolved.source_arn + assert metadata["policy_cached"] is document_policy + assert metadata["policy_display"] == "Read" + assert metadata["session_schema_version"] == 2 + assert metadata["target_partition"] == "aws" assert factory.call_args.kwargs["aws_access_key_id"] == "ASIAFINAL" @@ -1014,6 +1024,469 @@ def test_status_is_secret_free_and_handles_expiry_values( assert "backup" not in result[0] assert result[0]["remaining_seconds"] > 0 assert result[1]["remaining_seconds"] is None + assert result[1]["state"] == "legacy-unverified" + assert result[1]["warnings"] == [ + { + "code": "INVALID_EXPIRY", + "source": "expires_at", + "message": "Session expiry metadata is invalid.", + } + ] + + +def test_status_scope_uses_validated_cache_and_safe_fallbacks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + document = { + "Version": "2012-10-17", + "Statement": [{"Resource": "POLICY-DOCUMENT-SENTINEL"}], + } + arn = "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess" + identity = _policies._cache_identity(arn, account=ACCOUNT, partition="aws") + _policies.cache_write( + identity, + document, + origin="aws-managed", + resolver="arn", + source_identity=arn, + ) + local_source = str((tmp_path / "policies" / "debug.yaml").absolute()) + local_identity = "local-" + _state.digest(local_source.casefold().encode())[:24] + _policies.cache_write( + local_identity, + document, + origin="local", + resolver="file", + source_identity=local_source, + ) + customer_arn = f"arn:aws:iam::{ACCOUNT}:policy/team/CustomerDebug" + customer_identity = _policies._cache_identity( + "name:CustomerDebug", account=ACCOUNT, partition="aws" + ) + _policies.cache_write( + customer_identity, + document, + origin="remote-customer", + resolver="name", + source_identity=customer_arn, + ) + wrong_arn = f"arn:aws:iam::{OTHER_ACCOUNT}:policy/CustomerDebug" + wrong_identity = _policies._cache_identity( + "name:CustomerDebug", account=OTHER_ACCOUNT, partition="aws" + ) + _policies.cache_write( + wrong_identity, + document, + origin="remote-customer", + resolver="name", + source_identity=wrong_arn, + ) + corrupt_identity = "local-" + _state.digest(b"c:/policies/debug.yaml")[:24] + _policies.cache_write( + corrupt_identity, + document, + origin="local", + resolver="file", + source_identity="c:/policies/debug.yaml", + ) + corrupt_path = _policies.cache_root() / f"{corrupt_identity}.json" + corrupt = json.loads(corrupt_path.read_text(encoding="utf-8")) + corrupt["digest"] = "corrupt" + corrupt_path.write_text(json.dumps(corrupt), encoding="utf-8") + base = { + "destination": str(tmp_path / "aws"), + "auth_method": "assume-role", + "target_account": ACCOUNT, + "role": f"arn:aws:iam::{ACCOUNT}:role/team/AgentSession", + "backup": ["BACKUP-SECRET-SENTINEL"], + "login_cache_files": ["BROWSER-BYTES-SENTINEL"], + } + _state.save_sessions( + { + "valid": {**base, "profile": "valid", "policy": identity}, + "local": {**base, "profile": "local", "policy": local_identity}, + "stored": { + **base, + "profile": "stored", + "policy": "Investigate", + "policy_provenance": "stored policy Investigate", + }, + "customer": { + **base, + "profile": "customer", + "policy": customer_identity, + }, + "missing": {**base, "profile": "missing", "policy": "missing-cache"}, + "corrupt": { + **base, + "profile": "corrupt", + "policy": corrupt_identity, + }, + "mismatch": { + **base, + "profile": "mismatch", + "policy": wrong_identity, + }, + } + ) + report = _sessions.status_report() + sessions = {item["profile"]: item for item in report["sessions"]} + valid = sessions["valid"]["effective_scope"] + assert valid == { + "kind": "role-session", + "role_label": "team/AgentSession", + "boundary_label": None, + "policy_label": "CloudWatchReadOnlyAccess", + "policy_source": { + "origin": "aws-managed", + "reference": arn, + "arn": arn, + "cached": True, + }, + "policy_known": True, + } + expected_cache_labels = { + "local": ("debug.yaml", "local"), + "stored": ("Investigate", "stored"), + "customer": ("team/CustomerDebug", "remote-customer"), + } + for name, (label, origin) in expected_cache_labels.items(): + scope = sessions[name]["effective_scope"] + assert scope["policy_label"] == label + assert scope["policy_source"]["origin"] == origin + assert scope["policy_known"] is True + for name in ("missing", "corrupt", "mismatch"): + scope = sessions[name]["effective_scope"] + assert scope["policy_label"] == "session policy" + assert scope["policy_source"] is None + assert scope["policy_known"] is False + serialized = json.dumps(report) + for sentinel in ( + "POLICY-DOCUMENT-SENTINEL", + "BACKUP-SECRET-SENTINEL", + "BROWSER-BYTES-SENTINEL", + ): + assert sentinel not in serialized + + +def test_status_scope_kinds_and_fractional_expiry_are_honest() -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + cases: dict[str, tuple[dict[str, object], str]] = { + "browser": ({"auth_method": "browser-native", "policy": None}, "account-login"), + "mfa": ({"auth_method": "mfa", "policy": None}, "mfa-session"), + "ecr": ({"auth_method": "ecr-only"}, "ecr-only"), + "residue": ({"auth_method": "browser-cache-residue"}, "logout-residue"), + "legacy": ({}, "legacy-unknown"), + } + for session, expected in cases.values(): + public = _sessions._public_session(session, now=now) + assert public["effective_scope"]["kind"] == expected + fractional = _sessions._public_session( + { + "auth_method": "mfa", + "policy": None, + "expires_at": (now + timedelta(microseconds=1)).isoformat(), + }, + now=now, + ) + assert fractional["remaining_seconds"] == 1 + + +def test_scope_projection_covers_persisted_metadata_and_rejects_bad_cache_context( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + persisted = _sessions._effective_scope( + { + "auth_method": "assume-role", + "role": ROLE, + "boundary": "Guardrail", + "policy": "opaque", + "policy_display": "ReadLogs", + "policy_origin": "stored", + "policy_reference": "ReadLogs", + "policy_cached": False, + } + ) + assert persisted["role_label"] == "Guard" + assert persisted["boundary_label"] == "Guardrail" + assert persisted["policy_label"] == "ReadLogs" + assert persisted["policy_source"] == { + "origin": "stored", + "reference": "ReadLogs", + "arn": None, + "cached": False, + } + explicit = _sessions._policy_scope( + {"policy": f"arn:aws:iam::{ACCOUNT}:policy/team/Direct"} + ) + assert explicit["label"] == "team/Direct" + assert explicit["source"]["origin"] == "remote-customer" + assert _sessions._role_display_name("Guard") == "Guard" + assert ( + _sessions._role_display_name("arn:aws:iam::123456789012:user/not-role") is None + ) + + document = {"Version": "2012-10-17", "Statement": []} + _policies.cache_write( + "stored-debug", + document, + origin="stored", + resolver="stored", + source_identity="Debug", + ) + assert _sessions._cached_policy_source("stored-debug", target_account=ACCOUNT) == { + "origin": "stored", + "reference": "Debug", + "arn": None, + "cached": True, + "display": "Debug", + } + _policies.cache_write( + "stored-wrong", + document, + origin="stored", + resolver="stored", + source_identity="Debug", + ) + assert ( + _sessions._cached_policy_source("stored-wrong", target_account=ACCOUNT) is None + ) + _policies.cache_write( + "invalid-source", + document, + origin="aws-managed", + resolver="arn", + source_identity="not-an-arn", + ) + assert ( + _sessions._cached_policy_source("invalid-source", target_account=ACCOUNT) + is None + ) + + naive_expiry = _sessions._public_session( + { + "auth_method": "mfa", + "policy": None, + "expires_at": "2026-01-01T01:00:00", + }, + now=datetime(2026, 1, 1, tzinfo=UTC), + ) + assert naive_expiry["remaining_seconds"] is None + assert naive_expiry["warnings"][0]["source"] == "expires_at" + + +def test_public_status_allowlist_rejects_hostile_top_level_and_nested_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + sentinels = { + "ACCESS-KEY-SENTINEL", + "SECRET-KEY-SENTINEL", + "TOKEN-SENTINEL", + "PASSWORD-SENTINEL", + "PRIVATE-KEY-SENTINEL", + "POLICY-DOCUMENT-SENTINEL", + } + _state.save_sessions( + { + "hostile": { + "destination": str(tmp_path / "aws"), + "profile": "safe", + "auth_method": "assume-role", + "target_account": ACCOUNT, + "target_partition": "aws", + "aws_access_key_id": "ACCESS-KEY-SENTINEL", + "aws_secret_access_key": "SECRET-KEY-SENTINEL", + "aws_session_token": "TOKEN-SENTINEL", + "password": "PASSWORD-SENTINEL", + "metadata": {"private_key": "PRIVATE-KEY-SENTINEL"}, + "policy_document": {"Statement": "POLICY-DOCUMENT-SENTINEL"}, + "role": {"password": "PASSWORD-SENTINEL"}, + "boundary": {"token": "TOKEN-SENTINEL"}, + "policy": {"document": "POLICY-DOCUMENT-SENTINEL"}, + "policy_reference": {"secret": "SECRET-KEY-SENTINEL"}, + "ecr": ["safe", {"password": "PASSWORD-SENTINEL"}], + "backup": [{"token": "TOKEN-SENTINEL"}], + "section_backup": { + "credentials": {"private_key": "PRIVATE-KEY-SENTINEL"} + }, + } + } + ) + report = _sessions.status_report() + public = report["sessions"][0] + assert public["profile"] == "safe" + assert public["target_account"] == ACCOUNT + assert public["target_partition"] == "aws" + assert public["effective_scope"]["policy_label"] == "session policy" + serialized = json.dumps(report) + assert all(sentinel not in serialized for sentinel in sentinels) + + +def test_public_status_preserves_documented_raw_scalar_contract( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + destination = str((tmp_path / "aws").absolute()) + raw = { + "session_schema_version": 2, + "source_account": OTHER_ACCOUNT, + "source_partition": "aws", + "target_account": ACCOUNT, + "target_partition": "aws", + "role": ROLE, + "boundary": "Guardrail", + "policy": "Investigate", + "policy_provenance": "stored policy Investigate", + "policy_reference": "Investigate", + "policy_origin": "stored", + "policy_arn": None, + "policy_cached": False, + "policy_display": "Investigate", + "expires_at": None, + "target": "prod", + "destination": destination, + "profile": "debug", + "auth_method": "assume-role", + "started_at": "2026-01-01T00:00:00+00:00", + "ecr": ["123456789012.dkr.ecr.us-east-1.amazonaws.com"], + "ecr_engine": "docker", + "source_profile": "admin", + "source_destination": str(tmp_path / "source"), + "source_auth_method": "mfa", + "source_logged_out": True, + "cache_cleanup_incomplete": True, + "login_cache_residue": [ + {"path": str(tmp_path / "cache.json"), "reason": "outside cache root"} + ], + } + public = _sessions._public_session(raw, now=datetime(2026, 1, 1, tzinfo=UTC)) + for key, value in raw.items(): + assert public[key] == value + assert public["effective_scope"]["role_label"] == "Guard" + assert public["effective_scope"]["boundary_label"] == "Guardrail" + + +def test_public_status_sanitizes_login_cache_residue_entries( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + session = { + "profile": "debug", + "cache_cleanup_incomplete": True, + "login_cache_residue": [ + { + "path": "safe-cache.json", + "reason": "file changed", + "password": "PASSWORD-SENTINEL", + "nested": {"token": "TOKEN-SENTINEL"}, + }, + { + "path": {"private_key": "PRIVATE-KEY-SENTINEL"}, + "reason": "invalid path", + }, + { + "path": "invalid-reason.json", + "reason": {"policy_document": "POLICY-DOCUMENT-SENTINEL"}, + }, + "ACCESS-KEY-SENTINEL", + ], + } + public = _sessions._public_session(session, now=datetime(2026, 1, 1, tzinfo=UTC)) + assert public["cache_cleanup_incomplete"] is True + assert public["login_cache_residue"] == [ + {"path": "safe-cache.json", "reason": "file changed"} + ] + serialized = json.dumps(public) + for sentinel in ( + "PASSWORD-SENTINEL", + "TOKEN-SENTINEL", + "PRIVATE-KEY-SENTINEL", + "POLICY-DOCUMENT-SENTINEL", + "ACCESS-KEY-SENTINEL", + ): + assert sentinel not in serialized + + +def test_status_verification_fails_closed_on_identity_mismatch_and_bad_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + base = { + "state": "active", + "destination": str(tmp_path / "aws"), + "profile": "debug", + "target_account": ACCOUNT, + "target_partition": "aws", + } + with ( + patch("hacksaws._sessions.boto3.Session"), + patch( + "hacksaws._sessions._identity", + return_value=( + OTHER_ACCOUNT, + "aws-us-gov", + f"arn:aws-us-gov:iam::{OTHER_ACCOUNT}:user/debug", + ), + ), + ): + mismatch = _sessions._verify_status(base) + assert mismatch == { + "status": "mismatch", + "reason": "account, partition mismatch", + "expected_account": ACCOUNT, + "actual_account": OTHER_ACCOUNT, + "expected_partition": "aws", + "actual_partition": "aws-us-gov", + "actual_arn": f"arn:aws-us-gov:iam::{OTHER_ACCOUNT}:user/debug", + } + + role_item = { + **base, + "role": f"arn:aws:iam::{ACCOUNT}:role/team/AgentSession", + } + with ( + patch("hacksaws._sessions.boto3.Session"), + patch( + "hacksaws._sessions._identity", + return_value=( + ACCOUNT, + "aws", + f"arn:aws:sts::{ACCOUNT}:assumed-role/OtherRole/status", + ), + ), + ): + role_mismatch = _sessions._verify_status(role_item) + assert role_mismatch["status"] == "mismatch" + assert role_mismatch["reason"] == "role mismatch" + assert role_mismatch["expected_role"] == "AgentSession" + assert role_mismatch["actual_role"] == "OtherRole" + + assert _sessions._verify_status( + {"state": "active", "destination": str(tmp_path), "profile": "legacy"} + ) == { + "status": "error", + "message": ( + "Session metadata does not contain a consistent expected AWS account " + "and partition." + ), + } + with ( + patch("hacksaws._sessions.boto3.Session"), + patch( + "hacksaws._sessions._identity", + return_value=(ACCOUNT, "aws", f"arn:aws:iam::{ACCOUNT}:user/bad?TOKEN"), + ), + ): + unsafe_arn = _sessions._verify_status(base) + assert unsafe_arn == { + "status": "error", + "message": "AWS returned an invalid caller ARN during status verification.", + "actual_account": ACCOUNT, + "actual_partition": "aws", + } def test_explain_target_resolves_locations_defaults_and_boundary( From a332fa8de697b851fa8deb9817233a9e80378a00 Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Sun, 2 Aug 2026 09:38:09 -0500 Subject: [PATCH 5/8] Accelerate IAM Inventory - **Inventory Safety** - Add bounded canonical-path summaries with an account-wide opt-in, live ownership and immutable identity checks, and matched-only detail hydration. Large accounts can inspect IAM faster without allowing summary data to enter destructive cleanup planning. - **Operator Feedback** - Add delayed, sanitized stderr progress and stable JSON scope and completeness fields, with documentation that makes scan limits and automation behavior explicit. --- CHEATSHEET.md | 17 +- README.md | 9 + docs/automation-and-json.md | 11 + docs/cleanup.md | 16 + hacksaws/_configs.py | 5 + hacksaws/_iam_cleanup.py | 513 ++++++++++++++++++- hacksaws/_iam_cli.py | 266 ++++++++-- hacksaws/_iam_managed_policies.py | 47 +- hacksaws/_iam_roles.py | 15 + hacksaws/_output.py | 149 ++++++ hacksaws/tests/test_iam_cleanup.py | 418 +++++++++++++++ hacksaws/tests/test_iam_cli_scaffold.py | 316 +++++++++++- hacksaws/tests/test_iam_inventory_summary.py | 438 ++++++++++++++++ hacksaws/tests/test_iam_managed_policies.py | 85 +++ hacksaws/tests/test_output_foundation.py | 133 +++++ 15 files changed, 2390 insertions(+), 48 deletions(-) create mode 100644 hacksaws/tests/test_iam_inventory_summary.py diff --git a/CHEATSHEET.md b/CHEATSHEET.md index 0a45988..7d5f13c 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -285,7 +285,8 @@ Selector abbreviations and duplicates are rejected. `--location` conflicts with ```shell hacksaws iam list [PATTERN]... [--roles] [--policies] [--group-grants] \ - [--created] [--adopted] [--smoke] [--smoke-run RUN_ID] [--compact|--wide] + [--created] [--adopted] [--smoke] [--smoke-run RUN_ID] [--compact|--wide] \ + [--all-account] [--details] [--progress|--no-progress] hacksaws cleanup PATTERN... [--roles] [--policies] [--group-grants] \ [--created] [--adopted] [--cascade] [--remove-boundaries] \ @@ -301,6 +302,20 @@ flags means created and adopted resources. Smoke selectors further narrow matches. Cleanup orders group grants, roles, then policies; blocked/transient work does not prevent independent resources from being attempted. +Inventory verifies live ownership tags within the canonical `/hacksaws/` paths +by default. That fast scope can miss adopted resources elsewhere, custom or +changed paths, and untagged legacy resources. `--all-account` performs the +comprehensive supported-resource scan and includes resources Hacksaws does not +own; untagged legacy resources still cannot be classified as Hacksaws-owned. +Explicit `--created` or `--adopted` filters still narrow an all-account scan. +`--details` performs the additional dependency lookups; `--wide` only changes +presentation. Progress is delayed and written to stderr for human terminals. +`--progress` forces plain stderr milestones, `--no-progress` suppresses them, +and JSON mode always remains quiet until its single envelope. Summary JSON +reports `detailsComplete: false` and omits dependency fields unless `--details` +is selected. `scope` identifies `canonical` or `all-account`, while +`inventoryComplete: false` means warnings describe candidates that were omitted. + Cleanup exit codes: `0` complete/executable plan, `1` input/auth/planning failure, `2` partial or dependency-blocked, `3` safety refusal. diff --git a/README.md b/README.md index 3f99697..343bf1a 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,15 @@ hacksaws cache status hacksaws config show ``` +`iam list` verifies live ownership tags within the canonical `/hacksaws/` paths +by default. That fast scope can miss adopted resources elsewhere, custom or +changed paths, and untagged legacy resources; add `--all-account` for the +comprehensive supported-resource scan. Add `--details` when dependency +information is worth the additional AWS calls. Human terminals receive delayed +progress on stderr while stdout remains safe to pipe; use `--progress` to force +plain milestones or `--no-progress` to suppress them. JSON mode is always quiet +until its single result envelope. + Global output flags may appear anywhere before `--`: ```shell diff --git a/docs/automation-and-json.md b/docs/automation-and-json.md index 5564ece..1d3f920 100644 --- a/docs/automation-and-json.md +++ b/docs/automation-and-json.md @@ -5,6 +5,17 @@ envelope on stdout or stderr. Prompts are disabled in JSON mode. Mutations that would prompt require explicit `--yes`; create collisions additionally require `--replace` where supported. +Progress is suppressed in JSON mode, even when `--progress` is present, so the +selected stream still contains exactly one envelope. Human progress uses stderr +and never contaminates a final table written to stdout. Inventory summary JSON +uses `detailsComplete: false` and omits dependency fields unless `--details` was +explicitly requested; progress timing and transient counts are never part of the +stable envelope. `scope` is `canonical` or `all-account`; +`inventoryComplete: false` means one or more candidates were omitted and the +`warnings` array explains why. Explicit `--created` or `--adopted` filters still +narrow an `--all-account` inventory; `detailsComplete` reports whether +dependency-detail inclusion was requested, not whether warnings occurred. + ```shell hacksaws --json iam policy create agent.yaml --profile admin --dry-run hacksaws iam list --profile admin --json diff --git a/docs/cleanup.md b/docs/cleanup.md index eb47724..0f907ea 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -5,6 +5,22 @@ whose Hacksaws ownership is established. It never deletes IAM users, groups, instance-profile containers, service-linked roles, AWS-managed policies, or local configuration. +Inspect the same account before planning cleanup: + +```shell +hacksaws iam list --profile admin +hacksaws iam list "*ServiceBuzz*" --all-account --details --profile admin +``` + +The default fast inventory verifies live ownership tags only within canonical +`/hacksaws/` paths. It can miss adopted resources elsewhere, custom or changed +paths, and untagged legacy resources. `--all-account` performs the comprehensive +supported-resource scan and includes unowned resources; untagged resources still +cannot honestly be classified as Hacksaws-owned. `--details` adds dependency +lookups. Explicit `--created` or `--adopted` filters still narrow an all-account +scan. `--wide` changes only the table presentation. Human progress is sent to +stderr after a short delay, while JSON remains one quiet final envelope. + ```shell hacksaws cleanup "*ServiceBuzz*" --policies --profile admin --dry-run hacksaws iam cleanup --all --profile admin --dry-run diff --git a/hacksaws/_configs.py b/hacksaws/_configs.py index 486d9ec..b72a7f4 100644 --- a/hacksaws/_configs.py +++ b/hacksaws/_configs.py @@ -38,6 +38,11 @@ def configure_output( _output_options[0] = _output.OutputOptions(color=color, json=json_output) +def output_options() -> _output.OutputOptions: + """Return the immutable presentation options for the current invocation.""" + return _output_options[0] + + class OperationalError(Exception): """An expected operational failure that is safe to show without a traceback.""" diff --git a/hacksaws/_iam_cleanup.py b/hacksaws/_iam_cleanup.py index f4c1be8..c0b54a3 100644 --- a/hacksaws/_iam_cleanup.py +++ b/hacksaws/_iam_cleanup.py @@ -1,7 +1,7 @@ """Account-scoped inventory and Leave No Trace cleanup planning for IAM.""" # Cleanup deliberately exposes complete operator-facing diagnostics. -# ruff: noqa: ANN401, BLE001, C901, PLR0913, TRY003 +# ruff: noqa: ANN401, BLE001, C901, PLR0913, PLR0915, TRY003, TRY300 from __future__ import annotations @@ -12,6 +12,7 @@ from collections.abc import Callable from collections.abc import Iterable from collections.abc import Mapping +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from dataclasses import field from enum import StrEnum @@ -34,6 +35,8 @@ _RECOVERY_SERVICE = "iam-cleanup" _HANDLER = "aws-operation" _LNT_ATTEMPTS = 3 +_INVENTORY_ATTEMPTS = 4 +_INVENTORY_WORKERS = 4 _TRANSIENT_CODES = frozenset( { "ConcurrentModification", @@ -47,6 +50,15 @@ "TooManyRequestsException", } ) +_TRANSIENT_BOTO_ERRORS = frozenset( + { + "ConnectionClosedError", + "ConnectTimeoutError", + "EndpointConnectionError", + "HTTPClientError", + "ReadTimeoutError", + } +) class ResourceType(StrEnum): @@ -66,6 +78,15 @@ class OwnershipOrigin(StrEnum): UNKNOWN = "unknown" +class InventoryPhase(StrEnum): + """Stable semantic phases for optional inventory progress reporting.""" + + DISCOVERY = "discovery" + OWNERSHIP = "ownership" + FILTER = "filter" + DETAILS = "details" + + class PlanClassification(StrEnum): """Stable cleanup planning outcomes used by CLI exit classification.""" @@ -122,6 +143,68 @@ def as_dict(self) -> dict[str, object]: } +@dataclass(frozen=True, slots=True) +class InventoryQuery: + """Selection and hydration controls for fast, non-destructive inventory.""" + + patterns: tuple[str, ...] = () + resource_types: frozenset[ResourceType] = frozenset() + origins: frozenset[OwnershipOrigin] = frozenset( + {OwnershipOrigin.CREATED, OwnershipOrigin.ADOPTED} + ) + owned_only: bool = True + smoke_only: bool = False + smoke_run_id: str | None = None + all_account: bool = False + details: bool = False + + +@dataclass(frozen=True, slots=True) +class InventoryProgress: + """One credential-free semantic inventory progress event.""" + + phase: InventoryPhase + message: str + completed: int | None = None + total: int | None = None + candidates: int | None = None + inspected: int | None = None + owned: int | None = None + matches: int | None = None + + +@dataclass(frozen=True, slots=True) +class InventorySummary: + """Account-bound display inventory, never accepted by cleanup planning.""" + + account_id: str + partition: str + caller_arn: str + items: tuple[InventoryItem, ...] + warnings: tuple[str, ...] = () + details_complete: bool = False + inventory_complete: bool = True + scope: str = "canonical" + + def as_dict(self) -> dict[str, object]: + """Return structured output without implying absent dependency details.""" + serialized = [item.as_dict() for item in self.items] + if not self.details_complete: + for item in serialized: + item.pop("dependencies", None) + return { + "accountId": self.account_id, + "partition": self.partition, + "callerArn": self.caller_arn, + "count": len(self.items), + "detailsComplete": self.details_complete, + "inventoryComplete": self.inventory_complete, + "scope": self.scope, + "items": serialized, + "warnings": list(self.warnings), + } + + @dataclass(frozen=True, slots=True) class IamInventory: """Account-bound remote IAM inventory.""" @@ -354,6 +437,434 @@ def __init__( self._sleep = sleeper self._jitter = jitter + @staticmethod + def _emit_progress( + callback: Callable[[InventoryProgress], None] | None, + phase: InventoryPhase, + message: str, + *, + completed: int | None = None, + total: int | None = None, + candidates: int | None = None, + inspected: int | None = None, + owned: int | None = None, + matches: int | None = None, + ) -> None: + if callback is not None: + callback( + InventoryProgress( + phase, + message, + completed, + total, + candidates, + inspected, + owned, + matches, + ) + ) + + def _inventory_read(self, operation: Callable[[], Any]) -> Any: + """Retry only transient inventory reads with exponential full jitter.""" + for attempt in range(_INVENTORY_ATTEMPTS): + try: + return operation() + except (BotoCoreError, ClientError) as error: + transient = ( + isinstance(error, ClientError) + and _error_code(error) in _TRANSIENT_CODES + ) or ( + isinstance(error, BotoCoreError) + and type(error).__name__ in _TRANSIENT_BOTO_ERRORS + ) + if not transient or attempt + 1 == _INVENTORY_ATTEMPTS: + raise + ceiling = 0.1 * (2**attempt) + self._sleep(self._jitter(0.0, ceiling)) + raise AssertionError("inventory retry loop did not execute") # pragma: no cover + + @staticmethod + def _summary_matches(name: str, arn: str, patterns: tuple[str, ...]) -> bool: + if not patterns: + return True + folded_name = name.casefold() + folded_arn = arn.casefold() + return any( + fnmatch.fnmatchcase(folded_name, pattern.casefold()) + or fnmatch.fnmatchcase(folded_arn, pattern.casefold()) + for pattern in patterns + ) + + @staticmethod + def _role_item(role: roles.RoleSnapshot, *, details: bool) -> InventoryItem: + tags = _tag_values(role.tags) + owned = tags.get(roles.MANAGED_TAG) == "true" + dependencies: Mapping[str, tuple[str, ...]] = {} + if details: + dependencies = { + "attachedPolicies": tuple(role.attached_policies), + "inlinePolicies": tuple(role.inline_policies), + "instanceProfiles": tuple(role.instance_profiles), + "permissionsBoundary": ( + (role.permissions_boundary,) if role.permissions_boundary else () + ), + } + return InventoryItem( + ResourceType.ROLE, + role.name, + role.arn, + role.role_id, + ownership_origin(role.tags) if owned else OwnershipOrigin.UNKNOWN, + owned, + role.path, + tags.get(SMOKE_TAG) == "true", + tags.get(SMOKE_RUN_TAG), + dependencies, + role if details else None, + ) + + @staticmethod + def _policy_item( + policy: policies.ManagedPolicyRecord, + *, + details: bool, + dependencies: policies.PolicyDependencies | None = None, + ) -> InventoryItem: + tags = _tag_values(policy.tags) + resource_id = tags.get("hacksaws:resource-id", "") + group_name = resource_id.removeprefix("group-") + resource_type = ( + ResourceType.GROUP_GRANT + if policy.owned + and resource_id.startswith("group-") + and group_name + and policy.name == f"hacksaws-{group_name}-assume-roles" + else ResourceType.POLICY + ) + dependency_values: Mapping[str, tuple[str, ...]] = {} + if details and dependencies is not None: + dependency_values = { + "permissionUsers": tuple( + item.name for item in dependencies.permission_users + ), + "permissionGroups": tuple( + item.name for item in dependencies.permission_groups + ), + "permissionRoles": tuple( + item.name for item in dependencies.permission_roles + ), + "boundaryUsers": tuple( + item.name for item in dependencies.boundary_users + ), + "boundaryRoles": tuple( + item.name for item in dependencies.boundary_roles + ), + } + snapshot: object | None = None + if details: + snapshot = (policy, dependencies or policies.PolicyDependencies()) + return InventoryItem( + resource_type, + policy.name, + policy.arn.value, + policy.policy_id, + ownership_origin(policy.tags) if policy.owned else OwnershipOrigin.UNKNOWN, + policy.owned, + policy.path, + tags.get(SMOKE_TAG) == "true", + tags.get(SMOKE_RUN_TAG), + dependency_values, + snapshot, + ) + + @staticmethod + def _query_selects(item: InventoryItem, query: InventoryQuery) -> bool: + owned_only = query.owned_only and not query.all_account + return ( + (not owned_only or item.owned) + and (not query.resource_types or item.resource_type in query.resource_types) + and (not query.origins or item.origin in query.origins) + and (not query.smoke_only or item.smoke) + and (query.smoke_run_id is None or item.smoke_run_id == query.smoke_run_id) + and CleanupService._summary_matches(item.name, item.arn, query.patterns) + ) + + def inventory_summary( + self, + query: InventoryQuery, + *, + progress: Callable[[InventoryProgress], None] | None = None, + ) -> InventorySummary: + """Build a bounded, ownership-validated display inventory.""" + selected_types = query.resource_types + discover_roles = not selected_types or ResourceType.ROLE in selected_types + discover_policies = not selected_types or bool( + selected_types & {ResourceType.POLICY, ResourceType.GROUP_GRANT} + ) + role_path = "/" if query.all_account else roles.DEFAULT_ROLE_PATH + policy_path = None if query.all_account else policies.DEFAULT_PATH + scope_label = "account-wide" if query.all_account else "canonical" + resource_label = ( + "roles and policies" + if discover_roles and discover_policies + else "roles" + if discover_roles + else "policies" + ) + self._emit_progress( + progress, + InventoryPhase.DISCOVERY, + f"Discovering {scope_label} IAM {resource_label}.", + ) + + role_summaries: tuple[roles.RoleSnapshot, ...] = () + policy_summaries: tuple[policies.ManagedPolicyRecord, ...] = () + with ThreadPoolExecutor(max_workers=2) as executor: + role_future = ( + executor.submit( + self._inventory_read, + lambda: self.role_service.list_roles(path_prefix=role_path), + ) + if discover_roles + else None + ) + policy_future = ( + executor.submit( + self._inventory_read, + lambda: self.policy_service.list_policies( + scope=policies.PolicyScope.LOCAL, + path_prefix=policy_path, + include_tags=False, + ), + ) + if discover_policies + else None + ) + if role_future is not None: + role_summaries = role_future.result() + if policy_future is not None: + policy_summaries = policy_future.result() + + roles_to_validate = tuple( + item + for item in sorted(role_summaries, key=lambda item: item.arn.casefold()) + if self._summary_matches(item.name, item.arn, query.patterns) + ) + policies_to_validate = tuple( + item + for item in sorted( + policy_summaries, key=lambda item: item.arn.value.casefold() + ) + if self._summary_matches(item.name, item.arn.value, query.patterns) + ) + candidates: tuple[tuple[str, object], ...] = ( + *(("role", item) for item in roles_to_validate), + *(("policy", item) for item in policies_to_validate), + ) + self._emit_progress( + progress, + InventoryPhase.DISCOVERY, + "Discovery complete:", + candidates=len(candidates), + ) + self._emit_progress( + progress, + InventoryPhase.OWNERSHIP, + "Validating ownership:", + completed=0, + total=len(candidates), + ) + + def validate( + candidate: tuple[str, object], + ) -> tuple[InventoryItem | None, str | None]: + kind, summary = candidate + try: + if kind == "role": + role_summary = cast("roles.RoleSnapshot", summary) + role = cast( + "roles.RoleSnapshot", + self._inventory_read( + lambda: self.role_service.get_role_summary( + role_summary.name + ) + ), + ) + if role.arn != role_summary.arn or ( + role_summary.role_id and role.role_id != role_summary.role_id + ): + return None, ( + f"Role identity changed while reading {role_summary.name}; " + "the candidate was omitted." + ) + expected_arn = ( + f"arn:{self.context.partition}:iam::" + f"{self.context.account_id}:role/" + ) + if not role.arn.startswith(expected_arn): + return None, ( + f"Role {role_summary.name} does not match the verified " + "AWS account and partition; the candidate was omitted." + ) + return self._role_item(role, details=False), None + policy_summary = cast("policies.ManagedPolicyRecord", summary) + policy = cast( + "policies.ManagedPolicyRecord", + self._inventory_read( + lambda: self.policy_service.get_policy_summary(policy_summary) + ), + ) + if ( + policy.arn.value != policy_summary.arn.value + or policy.policy_id != policy_summary.policy_id + ): + return None, ( + f"Policy identity changed while reading {policy_summary.name}; " + "the candidate was omitted." + ) + return self._policy_item(policy, details=False), None + except (BotoCoreError, ClientError, policies.PolicyServiceError) as error: + name = getattr(summary, "name", "unknown") + return None, ( + f"Unable to validate {kind} ownership for {name}: {error}; " + "the candidate was omitted." + ) + + # Botocore clients are shared only for concurrent read operations. The + # workers never mutate client/session configuration or service state. + with ThreadPoolExecutor(max_workers=_INVENTORY_WORKERS) as executor: + validated = tuple(executor.map(validate, candidates)) + items = tuple(item for item, _warning in validated if item is not None) + warnings = [warning for _item, warning in validated if warning is not None] + self._emit_progress( + progress, + InventoryPhase.OWNERSHIP, + "Ownership complete:", + inspected=len(candidates), + owned=sum(item.owned for item in items), + ) + + selected = tuple(item for item in items if self._query_selects(item, query)) + self._emit_progress( + progress, + InventoryPhase.FILTER, + "Filters applied:", + matches=len(selected), + ) + if query.details and selected: + self._emit_progress( + progress, + InventoryPhase.DETAILS, + "Hydrating selected IAM dependency details.", + completed=0, + total=len(selected), + ) + + def hydrate(item: InventoryItem) -> tuple[InventoryItem | None, str | None]: + try: + if item.resource_type is ResourceType.ROLE: + role = cast( + "roles.RoleSnapshot", + self._inventory_read( + lambda: self.role_service.get_role(item.name) + ), + ) + if role.arn != item.arn or role.role_id != item.resource_id: + return None, ( + f"Role identity changed while hydrating {item.name}; " + "the candidate was omitted." + ) + hydrated = self._role_item(role, details=True) + else: + policy = cast( + "policies.ManagedPolicyRecord", + self._inventory_read( + lambda: self.policy_service.get_policy( + item.arn, + include_document=True, + include_versions=True, + include_tags=True, + ) + ), + ) + if ( + policy.arn.value != item.arn + or policy.policy_id != item.resource_id + ): + return None, ( + f"Policy identity changed while hydrating {item.name}; " + "the candidate was omitted." + ) + dependencies = cast( + "policies.PolicyDependencies", + self._inventory_read( + lambda: self.policy_service.policy_dependencies_for_arn( + item.arn + ) + ), + ) + hydrated = self._policy_item( + policy, details=True, dependencies=dependencies + ) + if not self._query_selects(hydrated, query): + return None, ( + f"IAM metadata changed while hydrating {item.name}; " + "the candidate no longer matches and was omitted." + ) + return hydrated, None + except ( + BotoCoreError, + ClientError, + policies.PolicyServiceError, + roles.IamRoleError, + ) as error: + return None, ( + f"Unable to hydrate details for {item.name}: {error}; " + "the candidate was omitted." + ) + + with ThreadPoolExecutor(max_workers=_INVENTORY_WORKERS) as executor: + detailed = tuple(executor.map(hydrate, selected)) + selected = tuple(item for item, _warning in detailed if item is not None) + warnings.extend( + warning for _item, warning in detailed if warning is not None + ) + self._emit_progress( + progress, + InventoryPhase.DETAILS, + "IAM dependency detail hydration complete.", + completed=len(detailed), + total=len(detailed), + ) + elif query.details: + self._emit_progress( + progress, + InventoryPhase.DETAILS, + "No selected IAM resources require dependency details.", + completed=0, + total=0, + ) + + return InventorySummary( + self.context.account_id, + self.context.partition, + self.context.arn, + tuple( + sorted( + selected, + key=lambda item: ( + item.resource_type, + item.name.casefold(), + item.arn, + ), + ) + ), + tuple(warnings), + query.details, + not warnings, + "all-account" if query.all_account else "canonical", + ) + def inventory(self) -> IamInventory: """Hydrate all roles and local policies into one account inventory.""" items: list[InventoryItem] = [] diff --git a/hacksaws/_iam_cli.py b/hacksaws/_iam_cli.py index e4a58b7..39ae4da 100644 --- a/hacksaws/_iam_cli.py +++ b/hacksaws/_iam_cli.py @@ -24,6 +24,7 @@ from hacksaws import _iam_policy_cli from hacksaws import _iam_recovery from hacksaws import _iam_role_cli +from hacksaws import _output from hacksaws import _state if TYPE_CHECKING: @@ -240,7 +241,9 @@ def register_parser( "list", help="List Hacksaws-owned IAM roles, policies, and group grants.", description=( - "Inventory Hacksaws-owned remote IAM resources in one verified AWS account." + "Inventory live-tag-verified IAM resources in one verified AWS account. " + "The default fast scan uses canonical /hacksaws/ paths; use " + "--all-account for a comprehensive supported-resource scan." ), ) _selector_arguments(inventory) @@ -325,6 +328,24 @@ def _resource_filters(parser: argparse.ArgumentParser) -> None: def _inventory_arguments(parser: argparse.ArgumentParser) -> None: _resource_filters(parser) + scope = parser.add_argument_group("inventory scope") + scope.add_argument( + "--all-account", + action="store_true", + help=( + "Expand the default canonical /hacksaws/ scan to matching roles and " + "customer-managed policies across the whole account, including " + "resources not managed by Hacksaws." + ), + ) + scope.add_argument( + "--details", + action="store_true", + help=( + "Fetch dependency details for matching resources; this performs extra " + "IAM requests and may take longer." + ), + ) output = parser.add_argument_group("output") width = output.add_mutually_exclusive_group() width.add_argument( @@ -335,7 +356,24 @@ def _inventory_arguments(parser: argparse.ArgumentParser) -> None: width.add_argument( "--wide", action="store_true", - help="Include ARNs, paths, and dependency counts.", + help="Include ARNs and paths without changing which IAM details are fetched.", + ) + progress = output.add_mutually_exclusive_group() + progress.add_argument( + "--progress", + dest="progress", + action="store_true", + default=None, + help=( + "Show progress on stderr; force plain milestones when stderr is not a " + "terminal." + ), + ) + progress.add_argument( + "--no-progress", + dest="progress", + action="store_false", + help="Suppress progress messages and terminal animation.", ) @@ -399,39 +437,94 @@ def _cleanup_origins( ) -def _inventory_text(items: list[dict[str, object]], *, wide: bool) -> str: +def _inventory_text( + items: list[dict[str, object]], + *, + wide: bool, + query: _iam_cleanup.InventoryQuery | None = None, + summary: _iam_cleanup.InventorySummary | None = None, +) -> str: + details = query.details if query else False + all_account = query.all_account if query else False + warnings = summary.warnings if summary else () if not items: - return "No matching Hacksaws-owned IAM resources." + account = ( + f"AWS account {summary.account_id} ({summary.partition})" + if summary + else "the selected AWS account" + ) + if all_account: + message = ( + f"No matching supported IAM resources were found in {account}. " + "Scope: all-account." + ) + else: + message = ( + f"No matching Hacksaws-owned IAM resources were found in {account}. " + "Scope: canonical /hacksaws/ paths with live-tag-verified ownership. " + "This fast scan can miss adopted resources outside the canonical " + "path, resources whose path changed, and untagged legacy resources. " + "Use --all-account for the comprehensive account scan; untagged " + "legacy resources cannot be classified as Hacksaws-owned." + ) + return _inventory_warnings_text(message, warnings) headers: tuple[str, ...] rows: list[tuple[str, ...]] if wide: - headers = ("Type", "Name", "Origin", "Path", "Deps", "ARN") + headers = ( + ("Type", "Name", "Origin", "Path", "Deps", "ARN") + if details + else ("Type", "Name", "Origin", "Path", "ARN") + ) rows = [ - ( - str(item["type"]), - str(item["name"]), - str(item["origin"]), - str(item["path"]), - str( - sum( - len(value) - for value in cast( - "dict[str, list[object]]", item["dependencies"] - ).values() + tuple( + _output.safe_terminal_text(value) + for value in ( + ( + item["type"], + item["name"], + item["origin"], + item["path"], + _dependency_count(item), + item["arn"], + ) + if details + else ( + item["type"], + item["name"], + item["origin"], + item["path"], + item["arn"], ) - ), - str(item["arn"]), + ) ) for item in items ] else: - headers = ("Type", "Name", "Origin", "Smoke") + headers = ( + ("Type", "Name", "Origin", "Deps", "Smoke") + if details + else ("Type", "Name", "Origin", "Smoke") + ) rows = [ - ( - str(item["type"]), - str(item["name"]), - str(item["origin"]), - "🧪" if item["smoke"] else "", + tuple( + _output.safe_terminal_text(value) + for value in ( + ( + item["type"], + item["name"], + item["origin"], + _dependency_count(item), + "🧪" if item["smoke"] else "", + ) + if details + else ( + item["type"], + item["name"], + item["origin"], + "🧪" if item["smoke"] else "", + ) + ) ) for item in items ] @@ -439,7 +532,7 @@ def _inventory_text(items: list[dict[str, object]], *, wide: bool) -> str: max(len(headers[index]), *(len(row[index]) for row in rows)) for index in range(len(headers)) ] - return "\n".join( + table = "\n".join( [ " ".join( value.ljust(widths[index]) for index, value in enumerate(headers) @@ -451,27 +544,126 @@ def _inventory_text(items: list[dict[str, object]], *, wide: bool) -> str: ), ] ) + return _inventory_warnings_text(table, warnings) -def inventory_result( - args: argparse.Namespace, context: IamCommandContext -) -> _configs.Result: - inventory = _iam_cleanup.CleanupService(context).inventory() - selected = inventory.filter( - patterns=args.patterns, +def _dependency_count(item: dict[str, object]) -> int: + dependencies = cast("dict[str, list[object]]", item.get("dependencies", {})) + return sum(len(value) for value in dependencies.values()) + + +def _inventory_warnings_text(message: str, warnings: tuple[str, ...]) -> str: + if not warnings: + return message + rendered = "\n".join( + f" - {_output.safe_terminal_text(warning)}" for warning in warnings + ) + return f"{message}\n\nWarnings:\n{rendered}" + + +def _inventory_progress_text(event: _iam_cleanup.InventoryProgress) -> str: + message = event.message + if event.candidates is not None: + noun = "candidate" if event.candidates == 1 else "candidates" + return f"{message} {event.candidates} {noun}" + if event.inspected is not None and event.owned is not None: + return f"{message} {event.inspected} inspected, {event.owned} owned" + if event.matches is not None: + noun = "match" if event.matches == 1 else "matches" + return f"{message} {event.matches} {noun}" + if event.completed is not None and event.total is not None: + message = f"{message} {event.completed}/{event.total}" + return message + + +def _inventory_query(args: argparse.Namespace) -> _iam_cleanup.InventoryQuery: + origins = ( + frozenset() + if args.all_account and not (args.created or args.adopted) + else _cleanup_origins(args) + ) + return _iam_cleanup.InventoryQuery( + patterns=tuple(args.patterns), resource_types=_cleanup_types(args), - origins=_cleanup_origins(args), - owned_only=True, + origins=origins, + owned_only=not args.all_account, smoke_only=args.smoke, smoke_run_id=args.smoke_run, + all_account=args.all_account, + details=args.details, + ) + + +def inventory_result( + args: argparse.Namespace, + context: IamCommandContext, + *, + progress: Callable[[_iam_cleanup.InventoryProgress], None] | None = None, +) -> _configs.Result: + query = _inventory_query(args) + summary = _iam_cleanup.CleanupService(context).inventory_summary( + query, progress=progress ) - items = [item.as_dict() for item in selected] - data = {**inventory.as_dict(), "count": len(items), "items": items} + data = summary.as_dict() + items = cast("list[dict[str, object]]", data["items"]) return _configs.Result( - "IAM_INVENTORY", _inventory_text(items, wide=args.wide), data=data + "IAM_INVENTORY", + _inventory_text( + items, + wide=args.wide, + query=query, + summary=summary, + ), + data=data, + kind="warning" if summary.warnings else "info", ) +def _inventory_command_result(args: argparse.Namespace) -> _configs.Result: + selected = getattr(args, "progress", None) + mode: _output.ProgressMode = ( + "always" if selected is True else "never" if selected is False else "auto" + ) + with _output.ProgressReporter(_configs.output_options(), mode=mode) as reporter: + reporter.start("Verifying AWS identity…") + try: + context = IamCommandContext.create(args) + result = inventory_result( + args, + context, + progress=lambda event: reporter.update(_inventory_progress_text(event)), + ) + except KeyboardInterrupt: + return _configs.Result( + "IAM_INVENTORY_INTERRUPTED", + f"IAM inventory cancelled during: {reporter.message} No AWS " + "resources were changed.", + _configs.EXIT_INTERRUPTED, + "stderr", + kind="warning", + ) + except ( + BotoCoreError, + ClientError, + _iam_cleanup.policies.PolicyServiceError, + _iam_cleanup.roles.IamRoleError, + ) as error: + raise _configs.OperationalError( + "Unable to inspect IAM resources during " + f"{reporter.message}: {_output.safe_terminal_text(error)}", + repairs=[ + ( + "Verify the selected profile can list IAM roles and policies, " + "then retry." + ) + ], + ) from error + else: + data = cast("dict[str, object]", result.data) + reporter.update(f"Rendering {data['count']} resources…") + return result + + def cleanup_result( args: argparse.Namespace, context: IamCommandContext ) -> _configs.Result: @@ -743,7 +935,7 @@ def dispatch(args: argparse.Namespace) -> _configs.Result: # noqa: PLR0911 if args.iam_action in {"list", "cleanup"}: if args.iam_action == "cleanup": return _dispatch_cleanup(args) - return inventory_result(args, IamCommandContext.create(args)) + return _inventory_command_result(args) if args.iam_action not in {"policy", "policies", "role", "roles"}: return _configs.Result( "IAM_HELP", "Choose an IAM command.", _configs.EXIT_USAGE, "stderr" diff --git a/hacksaws/_iam_managed_policies.py b/hacksaws/_iam_managed_policies.py index 0a010ec..c89eb50 100644 --- a/hacksaws/_iam_managed_policies.py +++ b/hacksaws/_iam_managed_policies.py @@ -1202,6 +1202,15 @@ def _with_tags(self, record: ManagedPolicyRecord) -> ManagedPolicyRecord: marker = _string(response.get("Marker"), label="Marker") return replace(record, tags=tuple(tags)) + def get_policy_summary(self, record: ManagedPolicyRecord) -> ManagedPolicyRecord: + """Revalidate live identity and tags without documents or dependencies.""" + self._assert_arn_target(record.arn) + if record.arn.kind is not PolicyKind.CUSTOMER_MANAGED: + return record + tagged = self._with_tags(record) + current = self._read_policy(record.arn) + return replace(current, tags=tagged.tags) + def _list_versions( self, arn: ManagedPolicyArn, @@ -1254,22 +1263,32 @@ def _hydrate_policy( include_versions: bool, include_tags: bool, ) -> ManagedPolicyRecord: + # Re-read live metadata before using DefaultVersionId or mutation-relevant + # counts. Callers may pass a list result that changed after discovery. current = self._read_policy(record.arn) tags = ( self._with_tags(current).tags if include_tags and current.arn.kind is PolicyKind.CUSTOMER_MANAGED else () ) - document = ( - self._get_version_document(current.arn, current.default_version_id) - if include_document - else None - ) versions = ( self._list_versions(current.arn, include_documents=include_versions) if include_versions else () ) + document = None + if include_document: + matching = next( + ( + version.document + for version in versions + if version.version_id == current.default_version_id + ), + None, + ) + document = matching or self._get_version_document( + current.arn, current.default_version_id + ) return replace(current, tags=tags, document=document, versions=versions) def plan_create( @@ -1978,6 +1997,24 @@ def policy_dependencies(self, reference: str) -> PolicyDependencies: boundary_roles, ) + def policy_dependencies_for_arn(self, reference: str) -> PolicyDependencies: + """List dependencies for a previously validated customer-policy ARN.""" + arn = ManagedPolicyArn.parse(reference) + self._assert_arn_target(arn) + permission_users, permission_groups, permission_roles = self._list_entities( + arn, "PermissionsPolicy" + ) + boundary_users, _, boundary_roles = self._list_entities( + arn, "PermissionsBoundary" + ) + return PolicyDependencies( + permission_users, + permission_groups, + permission_roles, + boundary_users, + boundary_roles, + ) + @staticmethod def _cascade_delete_steps( arn: str, diff --git a/hacksaws/_iam_roles.py b/hacksaws/_iam_roles.py index bee2c0b..b5e22b4 100644 --- a/hacksaws/_iam_roles.py +++ b/hacksaws/_iam_roles.py @@ -1316,6 +1316,21 @@ def get_role(self, name: str) -> RoleSnapshot: str(response.get("RoleId", "")), ) + def get_role_summary(self, name: str) -> RoleSnapshot: + """Read identity, ownership, and trust metadata without dependencies.""" + response = self.client.get_role(RoleName=name)["Role"] + return RoleSnapshot( + name, + response["Arn"], + response.get("Path", "/"), + decode_document(response["AssumeRolePolicyDocument"]), + response.get("Description"), + response.get("MaxSessionDuration", 3600), + response.get("PermissionsBoundary", {}).get("PermissionsBoundaryArn"), + {item["Key"]: item["Value"] for item in response.get("Tags", [])}, + role_id=str(response.get("RoleId", "")), + ) + def list_roles( self, *, path_prefix: str = DEFAULT_ROLE_PATH ) -> tuple[RoleSnapshot, ...]: diff --git a/hacksaws/_output.py b/hacksaws/_output.py index 6ef1e87..0ff9f45 100644 --- a/hacksaws/_output.py +++ b/hacksaws/_output.py @@ -3,12 +3,19 @@ from __future__ import annotations import os +import re import sys +import threading +import time +import unicodedata from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Literal +from typing import Self +from typing import TextIO from rich.console import Console +from rich.status import Status from rich.table import Table from rich.text import Text @@ -16,8 +23,15 @@ from collections.abc import Iterable ColorMode = Literal["auto", "always", "never"] +ProgressMode = Literal["auto", "always", "never"] SCHEMA_VERSION = 1 +_TERMINAL_STRING_CONTROL = re.compile( + r"(?:\x1b\]|\x9d).*?(?:\x07|\x1b\\|\x9c|$)|" + r"(?:\x1b[P_^X]|[\x90\x98\x9e\x9f]).*?(?:\x1b\\|\x9c|$)", + re.DOTALL, +) + @dataclass(frozen=True) class OutputOptions: @@ -59,6 +73,141 @@ def console_for(options: OutputOptions, *, stream: object) -> Console: ) +def safe_terminal_text(value: object) -> str: + """Return printable single-line text with terminal controls removed.""" + decoded = Text.from_ansi(_TERMINAL_STRING_CONTROL.sub("", str(value))).plain + safe = [] + for character in decoded: + if character in "\r\n\t": + safe.append(" ") + elif unicodedata.category(character) not in {"Cc", "Cf", "Cs"}: + safe.append(character) + return "".join(safe) + + +class ProgressReporter: + """Render delayed human progress without contaminating command stdout.""" + + def __init__( + self, + options: OutputOptions, + *, + mode: ProgressMode = "auto", + stream: TextIO | None = None, + delay: float = 0.4, + ) -> None: + self.options = options + self.mode = mode + self.stream = sys.stderr if stream is None else stream + self.delay = delay + self._tty = bool(getattr(self.stream, "isatty", lambda: False)()) + self._enabled = ( + not options.json and mode != "never" and (mode == "always" or self._tty) + ) + self._rich = ( + self._enabled and self._tty and color_enabled(options, stream=self.stream) + ) + self._message = "Working…" + self._last_plain_message: str | None = None + self._started_at = 0.0 + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._status: Status | None = None + self._lock = threading.Lock() + + @property + def enabled(self) -> bool: + """Return whether this invocation may emit progress.""" + return self._enabled + + @property + def message(self) -> str: + """Return the latest sanitized phase for cancellation diagnostics.""" + with self._lock: + return self._message + + def start(self, message: object) -> ProgressReporter: + """Start delayed progress rendering and retain the latest semantic phase.""" + self._message = safe_terminal_text(message) + if not self._enabled or self._thread is not None: + return self + self._started_at = time.monotonic() + self._thread = threading.Thread( + target=self._run, + name="hacksaws-progress", + daemon=True, + ) + self._thread.start() + return self + + def update(self, message: object) -> None: + """Replace the current semantic phase and emit one plain milestone.""" + rendered = safe_terminal_text(message) + with self._lock: + self._message = rendered + visible = self._stop.wait(0) is False and ( + time.monotonic() - self._started_at >= self.delay + ) + status = self._status + if not self._enabled or not visible: + return + if self._rich and status is not None: + status.update(self._rich_message()) + elif not self._rich: + self._print_plain(rendered) + + def _rich_message(self) -> Text: + with self._lock: + message = self._message + elapsed = max(0.0, time.monotonic() - self._started_at) + return Text(f"{message} {elapsed:.0f}s", style="cyan") + + def _print_plain(self, message: str) -> None: + with self._lock: + if message == self._last_plain_message: + return + self._last_plain_message = message + print(message, file=self.stream, flush=True) + + def _run(self) -> None: + if self._stop.wait(self.delay): + return + if self._rich: + status = Status( + self._rich_message(), + console=console_for(self.options, stream=self.stream), + spinner="dots", + ) + with self._lock: + self._status = status + status.start() + try: + while not self._stop.wait(0.5): + status.update(self._rich_message()) + finally: + status.stop() + with self._lock: + self._status = None + return + with self._lock: + message = self._message + self._print_plain(message) + self._stop.wait() + + def close(self) -> None: + """Stop rendering and clear any live terminal status.""" + self._stop.set() + thread = self._thread + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=max(1.0, self.delay + 0.1)) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_error: object) -> None: + self.close() + + def print_message( message: str, *, diff --git a/hacksaws/tests/test_iam_cleanup.py b/hacksaws/tests/test_iam_cleanup.py index 1c83db9..c093aa2 100644 --- a/hacksaws/tests/test_iam_cleanup.py +++ b/hacksaws/tests/test_iam_cleanup.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import threading from dataclasses import replace from pathlib import Path from types import SimpleNamespace @@ -119,6 +120,74 @@ def policy_dependencies(self, _reference: str) -> managed.PolicyDependencies: return self.dependencies +class SummaryRoleService: + def __init__(self, values: tuple[roles.RoleSnapshot, ...]) -> None: + self.values = {item.name: item for item in values} + self.list_calls: list[str] = [] + self.summary_calls: list[str] = [] + self.detail_calls: list[str] = [] + self.failures: dict[str, list[BaseException]] = {} + + def list_roles(self, *, path_prefix: str) -> tuple[roles.RoleSnapshot, ...]: + self.list_calls.append(path_prefix) + return tuple( + replace(item, tags={}) for item in reversed(tuple(self.values.values())) + ) + + def get_role_summary(self, name: str) -> roles.RoleSnapshot: + self.summary_calls.append(name) + failures = self.failures.get(name, []) + if failures: + raise failures.pop(0) + return self.values[name] + + def get_role(self, name: str) -> roles.RoleSnapshot: + self.detail_calls.append(name) + return self.values[name] + + +class SummaryPolicyService: + def __init__( + self, + values: tuple[managed.ManagedPolicyRecord, ...], + dependencies: managed.PolicyDependencies | None = None, + ) -> None: + self.values = {item.arn.value: item for item in values} + self.dependencies = dependencies or managed.PolicyDependencies() + self.list_calls: list[tuple[managed.PolicyScope, str | None, bool]] = [] + self.summary_calls: list[str] = [] + self.detail_calls: list[str] = [] + self.dependency_calls: list[str] = [] + + def list_policies( + self, + *, + scope: managed.PolicyScope, + path_prefix: str | None, + include_tags: bool, + ) -> tuple[managed.ManagedPolicyRecord, ...]: + self.list_calls.append((scope, path_prefix, include_tags)) + return tuple( + replace(item, tags=()) for item in reversed(tuple(self.values.values())) + ) + + def get_policy_summary( + self, record: managed.ManagedPolicyRecord + ) -> managed.ManagedPolicyRecord: + self.summary_calls.append(record.arn.value) + return self.values[record.arn.value] + + def get_policy( + self, reference: str, **_kwargs: object + ) -> managed.ManagedPolicyRecord: + self.detail_calls.append(reference) + return self.values[reference] + + def policy_dependencies_for_arn(self, reference: str) -> managed.PolicyDependencies: + self.dependency_calls.append(reference) + return self.dependencies + + class Iam: def __init__(self) -> None: self.calls: list[tuple[str, dict[str, object]]] = [] @@ -199,6 +268,27 @@ def service( ) +def summary_service( + role_values: tuple[roles.RoleSnapshot, ...] = (), + policy_values: tuple[managed.ManagedPolicyRecord, ...] = (), + *, + role_service: SummaryRoleService | None = None, + policy_service: SummaryPolicyService | None = None, + sleeps: list[float] | None = None, +) -> tuple[cleanup.CleanupService, SummaryRoleService, SummaryPolicyService]: + selected_roles = role_service or SummaryRoleService(role_values) + selected_policies = policy_service or SummaryPolicyService(policy_values) + delays = sleeps if sleeps is not None else [] + selected = cleanup.CleanupService( + context(), + role_service=selected_roles, # type: ignore[arg-type] + policy_service=selected_policies, # type: ignore[arg-type] + sleeper=delays.append, + jitter=lambda _lower, upper: upper, + ) + return selected, selected_roles, selected_policies + + def test_inventory_classifies_origins_groups_smoke_and_filters() -> None: created = policy() group = policy( @@ -233,6 +323,334 @@ def test_inventory_classifies_origins_groups_smoke_and_filters() -> None: assert inventory.as_dict()["count"] == 3 +def test_summary_inventory_has_bounded_call_budget_and_stable_output() -> None: + role_values = (role("Zulu"), role("Alpha")) + policy_values = (policy("ZuluPolicy"), policy("AlphaPolicy")) + selected, role_reads, policy_reads = summary_service(role_values, policy_values) + events: list[cleanup.InventoryProgress] = [] + + inventory = selected.inventory_summary( + cleanup.InventoryQuery(), progress=events.append + ) + + assert role_reads.list_calls == [roles.DEFAULT_ROLE_PATH] + assert sorted(role_reads.summary_calls) == ["Alpha", "Zulu"] + assert policy_reads.list_calls == [ + (managed.PolicyScope.LOCAL, managed.DEFAULT_PATH, False) + ] + assert sorted(policy_reads.summary_calls) == sorted( + item.arn.value for item in policy_values + ) + assert role_reads.detail_calls == [] + assert policy_reads.detail_calls == [] + assert policy_reads.dependency_calls == [] + assert [item.name for item in inventory.items] == [ + "AlphaPolicy", + "ZuluPolicy", + "Alpha", + "Zulu", + ] + assert inventory.details_complete is False + assert "dependencies" not in inventory.as_dict()["items"][0] # type: ignore[index] + assert [event.phase for event in events] == [ + cleanup.InventoryPhase.DISCOVERY, + cleanup.InventoryPhase.DISCOVERY, + cleanup.InventoryPhase.OWNERSHIP, + cleanup.InventoryPhase.OWNERSHIP, + cleanup.InventoryPhase.FILTER, + ] + assert events[0].message == "Discovering canonical IAM roles and policies." + assert events[1].candidates == 4 + assert (events[2].completed, events[2].total) == (0, 4) + assert (events[3].inspected, events[3].owned) == (4, 4) + assert events[4].matches == 4 + + +def test_summary_inventory_skips_branches_and_prefilters_before_ownership() -> None: + selected, role_reads, policy_reads = summary_service( + (role("Keep"), role("Ignore")), (policy(),) + ) + + inventory = selected.inventory_summary( + cleanup.InventoryQuery( + patterns=("keep",), + resource_types=frozenset({cleanup.ResourceType.ROLE}), + ) + ) + + assert [item.name for item in inventory.items] == ["Keep"] + assert role_reads.summary_calls == ["Keep"] + assert policy_reads.list_calls == [] + assert policy_reads.summary_calls == [] + + +def test_all_account_summary_uses_global_paths_and_includes_unowned() -> None: + unowned = replace(role("External"), tags={}) + selected, role_reads, policy_reads = summary_service((unowned,), (policy(),)) + events: list[cleanup.InventoryProgress] = [] + + inventory = selected.inventory_summary( + cleanup.InventoryQuery( + resource_types=frozenset({cleanup.ResourceType.ROLE}), + origins=frozenset(), + all_account=True, + ), + progress=events.append, + ) + + assert role_reads.list_calls == ["/"] + assert policy_reads.list_calls == [] + assert len(inventory.items) == 1 + assert inventory.items[0].owned is False + assert inventory.items[0].origin is cleanup.OwnershipOrigin.UNKNOWN + assert inventory.as_dict()["scope"] == "all-account" + ownership_complete = next( + event for event in events if event.message == "Ownership complete:" + ) + assert (ownership_complete.inspected, ownership_complete.owned) == (1, 0) + + +def test_summary_filters_before_details_and_revalidates_identity() -> None: + smoke = replace( + role("Smoke"), + tags={ + **role("Smoke").tags, + cleanup.SMOKE_TAG: "true", + cleanup.SMOKE_RUN_TAG: "run-1", + }, + attached_policies=("arn:policy/read",), + ) + selected, role_reads, _policy_reads = summary_service((smoke, role("Ordinary"))) + + inventory = selected.inventory_summary( + cleanup.InventoryQuery( + resource_types=frozenset({cleanup.ResourceType.ROLE}), + smoke_only=True, + smoke_run_id="run-1", + details=True, + ) + ) + + assert role_reads.detail_calls == ["Smoke"] + assert [item.name for item in inventory.items] == ["Smoke"] + assert inventory.details_complete + assert inventory.items[0].dependencies["attachedPolicies"] == ("arn:policy/read",) + assert inventory.as_dict()["detailsComplete"] is True + + +def test_policy_details_hydrate_only_selected_dependencies() -> None: + selected_policy = policy("Selected") + ignored_policy = policy("Ignored") + dependencies = managed.PolicyDependencies( + permission_users=(managed.EntityReference("user", "Reader", "AIDA1"),), + permission_groups=(managed.EntityReference("group", "Agents", "AGPA1"),), + permission_roles=(managed.EntityReference("role", "Worker", "AROA1"),), + boundary_users=(managed.EntityReference("user", "Bounded", "AIDA2"),), + boundary_roles=(managed.EntityReference("role", "Boundary", "AROA2"),), + ) + policy_reads = SummaryPolicyService((selected_policy, ignored_policy), dependencies) + selected, role_reads, _ = summary_service(policy_service=policy_reads) + + inventory = selected.inventory_summary( + cleanup.InventoryQuery( + patterns=("selected",), + resource_types=frozenset({cleanup.ResourceType.POLICY}), + details=True, + ) + ) + + assert role_reads.list_calls == [] + assert policy_reads.summary_calls == [selected_policy.arn.value] + assert policy_reads.detail_calls == [selected_policy.arn.value] + assert policy_reads.dependency_calls == [selected_policy.arn.value] + assert inventory.items[0].dependencies == { + "permissionUsers": ("Reader",), + "permissionGroups": ("Agents",), + "permissionRoles": ("Worker",), + "boundaryUsers": ("Bounded",), + "boundaryRoles": ("Boundary",), + } + assert inventory.items[0].snapshot == (selected_policy, dependencies) + + +def test_summary_retries_only_transient_reads_and_omits_failed_validation() -> None: + transient = role("Transient") + denied = role("Denied") + role_reads = SummaryRoleService((transient, denied)) + role_reads.failures = { + "Transient": [ + ClientError( + {"Error": {"Code": "Throttling", "Message": "wait"}}, + "GetRole", + ) + ], + "Denied": [ + ClientError( + {"Error": {"Code": "AccessDenied", "Message": "no"}}, + "GetRole", + ) + ], + } + sleeps: list[float] = [] + selected, _, _ = summary_service(role_service=role_reads, sleeps=sleeps) + + inventory = selected.inventory_summary( + cleanup.InventoryQuery(resource_types=frozenset({cleanup.ResourceType.ROLE})) + ) + + assert role_reads.summary_calls.count("Transient") == 2 + assert role_reads.summary_calls.count("Denied") == 1 + assert sleeps == [0.1] + assert [item.name for item in inventory.items] == ["Transient"] + assert len(inventory.warnings) == 1 + assert "Denied" in inventory.warnings[0] + assert inventory.inventory_complete is False + assert inventory.as_dict()["inventoryComplete"] is False + + +def test_summary_omits_role_outside_verified_account_or_partition() -> None: + wrong_account = replace( + role("WrongAccount"), + arn="arn:aws:iam::999999999999:role/hacksaws/WrongAccount", + ) + wrong_partition = replace( + role("WrongPartition"), + arn=f"arn:aws-cn:iam::{ACCOUNT}:role/hacksaws/WrongPartition", + ) + selected, _, _ = summary_service((wrong_account, wrong_partition)) + + inventory = selected.inventory_summary( + cleanup.InventoryQuery(resource_types=frozenset({cleanup.ResourceType.ROLE})) + ) + + assert inventory.items == () + assert len(inventory.warnings) == 2 + assert all("account and partition" in warning for warning in inventory.warnings) + + +def test_summary_omits_candidate_when_live_immutable_identity_changes() -> None: + current = role("Changed") + + class ChangedIdentityRoles(SummaryRoleService): + def list_roles(self, *, path_prefix: str) -> tuple[roles.RoleSnapshot, ...]: + self.list_calls.append(path_prefix) + return (replace(current, role_id="AROA-old"),) + + selected, _, _ = summary_service(role_service=ChangedIdentityRoles((current,))) + + inventory = selected.inventory_summary( + cleanup.InventoryQuery(resource_types=frozenset({cleanup.ResourceType.ROLE})) + ) + + assert inventory.items == () + assert inventory.inventory_complete is False + assert "identity changed" in inventory.warnings[0] + + +def test_summary_omits_policy_recreated_at_same_arn_with_new_policy_id() -> None: + listed = policy("Recreated") + live = replace(listed, policy_id="ANPA-new-immutable-id") + + class RecreatedPolicy(SummaryPolicyService): + def list_policies( + self, + *, + scope: managed.PolicyScope, + path_prefix: str | None, + include_tags: bool, + ) -> tuple[managed.ManagedPolicyRecord, ...]: + self.list_calls.append((scope, path_prefix, include_tags)) + return (listed,) + + def get_policy_summary( + self, record: managed.ManagedPolicyRecord + ) -> managed.ManagedPolicyRecord: + self.summary_calls.append(record.arn.value) + return live + + policy_reads = RecreatedPolicy((live,)) + selected, _, _ = summary_service(policy_service=policy_reads) + + inventory = selected.inventory_summary( + cleanup.InventoryQuery(resource_types=frozenset({cleanup.ResourceType.POLICY})) + ) + + assert policy_reads.summary_calls == [listed.arn.value] + assert policy_reads.detail_calls == [] + assert policy_reads.dependency_calls == [] + assert inventory.items == () + assert inventory.inventory_complete is False + assert "identity changed" in inventory.warnings[0] + + +def test_details_empty_selection_reports_completed_semantic_phase() -> None: + selected, _, _ = summary_service((role("Other"),)) + events: list[cleanup.InventoryProgress] = [] + + inventory = selected.inventory_summary( + cleanup.InventoryQuery( + patterns=("missing",), + resource_types=frozenset({cleanup.ResourceType.ROLE}), + details=True, + ), + progress=events.append, + ) + + assert inventory.items == () + assert inventory.details_complete is True + assert events[-1] == cleanup.InventoryProgress( + cleanup.InventoryPhase.DETAILS, + "No selected IAM resources require dependency details.", + completed=0, + total=0, + ) + + +def test_summary_ownership_hydration_is_bounded_to_four_workers() -> None: + class ConcurrentRoles(SummaryRoleService): + def __init__(self, values: tuple[roles.RoleSnapshot, ...]) -> None: + super().__init__(values) + self.barrier = threading.Barrier(4) + self.lock = threading.Lock() + self.active = 0 + self.maximum = 0 + + def get_role_summary(self, name: str) -> roles.RoleSnapshot: + with self.lock: + self.active += 1 + self.maximum = max(self.maximum, self.active) + try: + self.barrier.wait(timeout=2) + return super().get_role_summary(name) + finally: + with self.lock: + self.active -= 1 + + role_reads = ConcurrentRoles(tuple(role(f"Role{index}") for index in range(4))) + selected, _, _ = summary_service(role_service=role_reads) + + inventory = selected.inventory_summary( + cleanup.InventoryQuery(resource_types=frozenset({cleanup.ResourceType.ROLE})) + ) + + assert len(inventory.items) == 4 + assert role_reads.maximum == 4 + + +def test_cleanup_plan_never_uses_summary_inventory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected = service((role(),)) + + def forbidden(*_args: object, **_kwargs: object) -> cleanup.InventorySummary: + raise AssertionError + + monkeypatch.setattr(selected, "inventory_summary", forbidden) + plan = selected.plan(cleanup.CleanupOptions(all_resources=True)) + + assert [item.name for item in plan.resources] == ["AgentRole"] + + def test_plan_requires_explicit_scope_and_reports_dependency_opt_ins() -> None: value = role( attached=("arn:aws:iam::aws:policy/ReadOnlyAccess",), diff --git a/hacksaws/tests/test_iam_cli_scaffold.py b/hacksaws/tests/test_iam_cli_scaffold.py index 42ccdf4..eff7bfd 100644 --- a/hacksaws/tests/test_iam_cli_scaffold.py +++ b/hacksaws/tests/test_iam_cli_scaffold.py @@ -8,9 +8,11 @@ from dataclasses import replace from pathlib import Path from types import SimpleNamespace +from typing import Self import boto3 import pytest +from botocore.exceptions import ClientError from hacksaws import _configs from hacksaws import _iam_cleanup @@ -262,8 +264,20 @@ class Service: def __init__(self, _context: object) -> None: pass - def inventory(self) -> _iam_cleanup.IamInventory: - return inventory + def inventory_summary( + self, + query: _iam_cleanup.InventoryQuery, + *, + progress: object = None, + ) -> _iam_cleanup.InventorySummary: + del progress + return _iam_cleanup.InventorySummary( + inventory.account_id, + inventory.partition, + inventory.caller_arn, + inventory.items, + details_complete=query.details, + ) def plan( self, _options: _iam_cleanup.CleanupOptions @@ -288,12 +302,16 @@ def execute( smoke=False, smoke_run=None, wide=True, + all_account=False, + details=True, ), context, ) assert listed.code == "IAM_INVENTORY" assert "ANPA" not in listed.message assert "AgentRead" in listed.message + assert isinstance(listed.data, dict) + assert listed.data["detailsComplete"] is True dry_run = _iam_cli.cleanup_result( argparse.Namespace( @@ -447,19 +465,309 @@ def test_inventory_rendering_and_central_dispatch_branches( monkeypatch.setattr( _iam_cli, "inventory_result", - lambda _args, _context: _configs.Result("LISTED", "listed"), + lambda _args, _context, **_options: _configs.Result( + "LISTED", "listed", data={"count": 1} + ), ) monkeypatch.setattr( _iam_cli, "cleanup_result", lambda _args, _context: _configs.Result("CLEANED", "cleaned"), ) - assert _iam_cli.dispatch(argparse.Namespace(iam_action="list")).code == "LISTED" + assert ( + _iam_cli.dispatch(argparse.Namespace(iam_action="list", progress=False)).code + == "LISTED" + ) cleanup_args = argparse.Namespace(iam_action="cleanup") assert _iam_cli.dispatch(cleanup_args).code == "CLEANED" assert _iam_cli.dispatch_root_cleanup(cleanup_args).code == "CLEANED" +def test_inventory_empty_scope_warnings_and_details_rendering() -> None: + owned_query = _iam_cleanup.InventoryQuery() + empty = _iam_cleanup.InventorySummary( + "123456789012", + "aws", + "arn:aws:iam::123456789012:user/test", + (), + ("Unable to validate role bad\x1b[31m.\nOmitted.",), + ) + rendered = _iam_cli._inventory_text( + [], wide=False, query=owned_query, summary=empty + ) + assert "AWS account 123456789012 (aws)" in rendered + assert "--all-account" in rendered + assert "untagged legacy" in rendered + assert "Warnings:" in rendered + assert "\x1b" not in rendered + assert "\nOmitted" not in rendered + + all_account = replace(owned_query, all_account=True, owned_only=False) + rendered = _iam_cli._inventory_text( + [], wide=False, query=all_account, summary=replace(empty, warnings=()) + ) + assert "Scope: all-account" in rendered + assert "cannot be classified" not in rendered + + item = _inventory_item().as_dict() + detailed = _iam_cli._inventory_text( + [item], + wide=False, + query=replace(owned_query, details=True), + summary=replace(empty, items=(_inventory_item(),), details_complete=True), + ) + assert "Deps" in detailed + + +def test_inventory_cli_json_query_flags_and_progress_contract( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + from hacksaws import _cli + from hacksaws import _sessions + + queries: list[_iam_cleanup.InventoryQuery] = [] + item = _inventory_item() + + class Service: + def __init__(self, _context: object) -> None: + pass + + def inventory_summary( + self, + query: _iam_cleanup.InventoryQuery, + *, + progress: object = None, + ) -> _iam_cleanup.InventorySummary: + queries.append(query) + if callable(progress): + progress( + _iam_cleanup.InventoryProgress( + _iam_cleanup.InventoryPhase.FILTER, + "Filters applied:", + matches=1, + ) + ) + return _iam_cleanup.InventorySummary( + "123456789012", + "aws", + "arn:aws:iam::123456789012:user/test", + (item,), + details_complete=query.details, + scope="all-account" if query.all_account else "canonical", + ) + + monkeypatch.setattr(_iam_cli._iam_cleanup, "CleanupService", Service) + monkeypatch.setattr( + _iam_cli.IamCommandContext, + "create", + lambda _args: SimpleNamespace(), + ) + monkeypatch.setattr(_sessions, "recover_journal", lambda: None) + result = _cli.console_main( + [ + "iam", + "list", + "*Agent*", + "--roles", + "--all-account", + "--progress", + "--json", + ] + ) + captured = capsys.readouterr() + assert captured.err == "" + envelope = json.loads(captured.out) + assert result.code == "IAM_INVENTORY" + assert envelope["data"]["detailsComplete"] is False + assert envelope["data"]["inventoryComplete"] is True + assert envelope["data"]["scope"] == "all-account" + assert "dependencies" not in envelope["data"]["items"][0] + assert queries[0].patterns == ("*Agent*",) + assert queries[0].resource_types == frozenset({_iam_cleanup.ResourceType.ROLE}) + assert queries[0].all_account is True + assert queries[0].origins == frozenset() + explicit_origin = _cli._create_parser().parse_args( + ["iam", "list", "--all-account", "--created"] + ) + assert _iam_cli._inventory_query(explicit_origin).origins == frozenset( + {_iam_cleanup.OwnershipOrigin.CREATED} + ) + + +def test_inventory_progress_modes_help_and_interruption( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + from hacksaws import _cli + + help_result = _cli.console_main(["iam", "list", "--help"]) + help_text = capsys.readouterr().out + assert help_result.exit_code == 0 + assert "--all-account" in help_text + assert "--details" in help_text + assert "--progress" in help_text + assert "--no-progress" in help_text + with pytest.raises(SystemExit): + _cli._create_parser().parse_args(["iam", "list", "--progress", "--no-progress"]) + + monkeypatch.setattr( + _iam_cli.IamCommandContext, + "create", + lambda _args: (_ for _ in ()).throw(KeyboardInterrupt), + ) + interrupted = _iam_cli._inventory_command_result(argparse.Namespace(progress=False)) + assert interrupted.code == "IAM_INVENTORY_INTERRUPTED" + assert interrupted.exit_code == _configs.EXIT_INTERRUPTED + assert "No AWS resources were changed" in interrupted.message + assert "Verifying AWS identity" in interrupted.message + + monkeypatch.setattr( + _iam_cli.IamCommandContext, + "create", + lambda _args: SimpleNamespace(), + ) + + def fail_inventory(*_args: object, **_kwargs: object) -> _configs.Result: + raise ClientError( + {"Error": {"Code": "AccessDenied", "Message": "denied"}}, + "ListRoles", + ) + + monkeypatch.setattr(_iam_cli, "inventory_result", fail_inventory) + with pytest.raises(_configs.OperationalError, match="Unable to inspect"): + _iam_cli._inventory_command_result(argparse.Namespace(progress=False)) + + +def test_inventory_reporter_lifecycle_maps_modes_and_semantic_phases( + monkeypatch: pytest.MonkeyPatch, +) -> None: + modes: list[str] = [] + messages: list[str] = [] + exits: list[bool] = [] + + class Reporter: + message = "Working…" + + def __init__(self, _options: object, *, mode: str) -> None: + modes.append(mode) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_error: object) -> None: + exits.append(True) + + def start(self, message: str) -> Reporter: + self.message = message + messages.append(message) + return self + + def update(self, message: str) -> None: + self.message = message + messages.append(message) + + def result( + _args: argparse.Namespace, + _context: object, + *, + progress: object, + ) -> _configs.Result: + assert callable(progress) + progress( + _iam_cleanup.InventoryProgress( + _iam_cleanup.InventoryPhase.OWNERSHIP, + "Ownership complete:", + inspected=2, + owned=1, + ) + ) + return _configs.Result("IAM_INVENTORY", "done", data={"count": 1}) + + monkeypatch.setattr(_iam_cli._output, "ProgressReporter", Reporter) + monkeypatch.setattr( + _iam_cli.IamCommandContext, "create", lambda _args: SimpleNamespace() + ) + monkeypatch.setattr(_iam_cli, "inventory_result", result) + for selected in (True, False, None): + assert ( + _iam_cli._inventory_command_result( + argparse.Namespace(progress=selected) + ).code + == "IAM_INVENTORY" + ) + assert modes == ["always", "never", "auto"] + assert len(exits) == 3 + assert messages.count("Verifying AWS identity…") == 3 + assert messages.count("Ownership complete: 2 inspected, 1 owned") == 3 + assert messages.count("Rendering 1 resources…") == 3 + + +@pytest.mark.parametrize( + ("event", "expected"), + [ + ( + _iam_cleanup.InventoryProgress( + _iam_cleanup.InventoryPhase.DISCOVERY, + "Discovery complete:", + candidates=1, + ), + "Discovery complete: 1 candidate", + ), + ( + _iam_cleanup.InventoryProgress( + _iam_cleanup.InventoryPhase.OWNERSHIP, + "Validating ownership:", + completed=0, + total=1, + ), + "Validating ownership: 0/1", + ), + ( + _iam_cleanup.InventoryProgress( + _iam_cleanup.InventoryPhase.OWNERSHIP, + "Ownership complete:", + inspected=1, + owned=0, + ), + "Ownership complete: 1 inspected, 0 owned", + ), + ( + _iam_cleanup.InventoryProgress( + _iam_cleanup.InventoryPhase.FILTER, + "Filters applied:", + matches=0, + ), + "Filters applied: 0 matches", + ), + ], +) +def test_inventory_progress_text_uses_phase_specific_counts( + event: _iam_cleanup.InventoryProgress, expected: str +) -> None: + assert _iam_cli._inventory_progress_text(event) == expected + + +def test_iam_adapter_registration_rejects_duplicate_names() -> None: + class Adapter: + name = "test-adapter" + + def register(self, _parser: argparse.ArgumentParser) -> None: + pass + + def dispatch( + self, _args: argparse.Namespace, _context: _iam_cli.IamCommandContext + ) -> _configs.Result | None: + return None + + adapter = Adapter() + _iam_cli.clear_adapters() + try: + _iam_cli.register_adapter(adapter) + with pytest.raises(ValueError, match="test-adapter"): + _iam_cli.register_adapter(adapter) + finally: + _iam_cli.clear_adapters() + + def test_recovery_get_and_empty_environment( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/hacksaws/tests/test_iam_inventory_summary.py b/hacksaws/tests/test_iam_inventory_summary.py new file mode 100644 index 0000000..6914058 --- /dev/null +++ b/hacksaws/tests/test_iam_inventory_summary.py @@ -0,0 +1,438 @@ +"""Cross-layer contracts for fast, non-destructive IAM inventory summaries.""" + +# Test doubles intentionally expose small boto-shaped dynamic interfaces. +# ruff: noqa: ANN401, D101, D102, D107 + +from __future__ import annotations + +import argparse +import io +import json +from types import SimpleNamespace +from typing import TYPE_CHECKING +from typing import Any + +from botocore.exceptions import ClientError + +from hacksaws import _cli +from hacksaws import _iam_cleanup as cleanup +from hacksaws import _iam_cli +from hacksaws import _iam_managed_policies as managed +from hacksaws import _iam_roles as roles +from hacksaws import _output + +if TYPE_CHECKING: + import pytest + +ACCOUNT = "123456789012" +CALLER = f"arn:aws:iam::{ACCOUNT}:user/tester" +TRUST = {"Version": "2012-10-17", "Statement": []} + + +def role( + name: str, + *, + account: str = ACCOUNT, + partition: str = "aws", + managed_by_hacksaws: bool = True, + origin: str | None = "created", + path: str = "/hacksaws/", +) -> roles.RoleSnapshot: + tags: dict[str, str] = {} + if managed_by_hacksaws: + tags[roles.MANAGED_TAG] = "true" + tags[roles.OWNER_TAG] = CALLER + if origin is not None: + tags[cleanup.ORIGIN_TAG] = origin + return roles.RoleSnapshot( + name, + f"arn:{partition}:iam::{account}:role{path}{name}", + path, + TRUST, + tags=tags, + role_id=f"AROA{name}", + ) + + +def policy( + name: str, + *, + resource_id: str, + origin: str = "created", + owned: bool = True, +) -> managed.ManagedPolicyRecord: + tags: list[managed.Tag] = [] + if owned: + tags.extend( + ( + managed.Tag("hacksaws:managed-by", "hacksaws"), + managed.Tag("hacksaws:resource-kind", "managed-policy"), + managed.Tag("hacksaws:resource-id", resource_id), + ) + ) + tags.append(managed.Tag(cleanup.ORIGIN_TAG, origin)) + return managed.ManagedPolicyRecord( + managed.ManagedPolicyArn.parse( + f"arn:aws:iam::{ACCOUNT}:policy/hacksaws/{name}" + ), + f"ANPA{name}", + name, + "/hacksaws/", + "v1", + 0, + 0, + tuple(tags), + ) + + +class RoleService: + def __init__( + self, + summaries: tuple[roles.RoleSnapshot, ...] = (), + hydrated: tuple[roles.RoleSnapshot, ...] = (), + *, + failure: BaseException | None = None, + ) -> None: + self.summaries = summaries + self.hydrated = {item.name: item for item in hydrated} + self.failure = failure + self.paths: list[str] = [] + self.summary_calls: list[str] = [] + + def list_roles(self, *, path_prefix: str) -> tuple[roles.RoleSnapshot, ...]: + self.paths.append(path_prefix) + return self.summaries + + def get_role_summary(self, name: str) -> roles.RoleSnapshot: + self.summary_calls.append(name) + if self.failure is not None: + raise self.failure + return self.hydrated[name] + + def get_role(self, name: str) -> roles.RoleSnapshot: + return self.hydrated[name] + + +class PolicyService: + def __init__( + self, + summaries: tuple[managed.ManagedPolicyRecord, ...] = (), + hydrated: tuple[managed.ManagedPolicyRecord, ...] = (), + ) -> None: + self.summaries = summaries + self.hydrated = {item.arn.value: item for item in hydrated} + self.list_calls: list[dict[str, object]] = [] + self.summary_calls: list[str] = [] + + def list_policies( + self, **kwargs: object + ) -> tuple[managed.ManagedPolicyRecord, ...]: + self.list_calls.append(dict(kwargs)) + return self.summaries + + def get_policy_summary( + self, item: managed.ManagedPolicyRecord + ) -> managed.ManagedPolicyRecord: + self.summary_calls.append(item.arn.value) + return self.hydrated[item.arn.value] + + def get_policy( + self, reference: str, **_kwargs: object + ) -> managed.ManagedPolicyRecord: + return self.hydrated[reference] + + def policy_dependencies_for_arn( + self, _reference: str + ) -> managed.PolicyDependencies: + return managed.PolicyDependencies() + + def policy_dependencies(self, _reference: str) -> managed.PolicyDependencies: + return managed.PolicyDependencies() + + +def service( + role_service: RoleService | None = None, + policy_service: PolicyService | None = None, +) -> cleanup.CleanupService: + context = SimpleNamespace( + account_id=ACCOUNT, + partition="aws", + arn=CALLER, + iam=SimpleNamespace(), + sts=SimpleNamespace(), + access_analyzer=None, + ) + return cleanup.CleanupService( + context, + role_service=role_service or RoleService(), # type: ignore[arg-type] + policy_service=policy_service or PolicyService(), # type: ignore[arg-type] + sleeper=lambda _delay: None, + jitter=lambda _lower, upper: upper, + ) + + +def test_patterns_prefilter_summaries_but_never_establish_ownership() -> None: + summaries = ( + role("OtherOwned"), + role("MatchOwned"), + role("MatchSpoof", managed_by_hacksaws=False), + ) + roles_api = RoleService(summaries, summaries) + policies_api = PolicyService() + summary = service(roles_api, policies_api).inventory_summary( + cleanup.InventoryQuery( + patterns=("match*",), + resource_types=frozenset({cleanup.ResourceType.ROLE}), + ) + ) + + assert roles_api.paths == [roles.DEFAULT_ROLE_PATH] + assert set(roles_api.summary_calls) == {"MatchOwned", "MatchSpoof"} + assert policies_api.list_calls == [] + assert [item.name for item in summary.items] == ["MatchOwned"] + assert summary.items[0].owned is True + assert summary.inventory_complete is True + + +def test_group_grants_require_live_policy_tag_classification() -> None: + group = policy( + "hacksaws-Agents-assume-roles", + resource_id="group-Agents", + ) + ordinary = policy("AgentRead", resource_id="policy-AgentRead") + policies_api = PolicyService((ordinary, group), (ordinary, group)) + roles_api = RoleService() + + summary = service(roles_api, policies_api).inventory_summary( + cleanup.InventoryQuery( + resource_types=frozenset({cleanup.ResourceType.GROUP_GRANT}) + ) + ) + + assert roles_api.paths == [] + assert set(policies_api.summary_calls) == { + ordinary.arn.value, + group.arn.value, + } + assert [(item.resource_type, item.name) for item in summary.items] == [ + (cleanup.ResourceType.GROUP_GRANT, group.name) + ] + + +def test_all_account_origin_filters_are_honored_and_results_are_stable() -> None: + created = role("ZuluCreated", origin="created", path="/") + adopted = role("AlphaAdopted", origin="adopted", path="/") + unowned = role("MiddleUnowned", managed_by_hacksaws=False, origin=None, path="/") + roles_api = RoleService((created, unowned, adopted), (created, unowned, adopted)) + + summary = service(roles_api).inventory_summary( + cleanup.InventoryQuery( + all_account=True, + owned_only=False, + origins=frozenset({cleanup.OwnershipOrigin.CREATED}), + ) + ) + + assert roles_api.paths == ["/"] + assert [item.name for item in summary.items] == ["ZuluCreated"] + + every_origin = service( + RoleService((created, unowned, adopted), (created, unowned, adopted)) + ).inventory_summary( + cleanup.InventoryQuery( + all_account=True, + owned_only=False, + origins=frozenset(), + ) + ) + assert [item.name for item in every_origin.items] == [ + "AlphaAdopted", + "MiddleUnowned", + "ZuluCreated", + ] + + +def test_role_identity_must_match_verified_account_and_partition() -> None: + foreign = role( + "Foreign", + account="999999999999", + partition="aws-us-gov", + path="/", + ) + summary = service(RoleService((foreign,), (foreign,))).inventory_summary( + cleanup.InventoryQuery(all_account=True, owned_only=False, origins=frozenset()) + ) + + assert summary.items == () + assert summary.inventory_complete is False + assert any("account" in warning.casefold() for warning in summary.warnings) + + +def test_ownership_hydration_failure_is_partial_not_authoritative_empty() -> None: + candidate = role("Denied") + denied = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "denied"}}, + "GetRole", + ) + summary = service( + RoleService((candidate,), (candidate,), failure=denied) + ).inventory_summary(cleanup.InventoryQuery()) + data = summary.as_dict() + + assert summary.items == () + assert summary.details_complete is False + assert summary.inventory_complete is False + assert data["inventoryComplete"] is False + assert data["warnings"] + + +def test_cleanup_planning_never_uses_summary_inventory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + owned = role("CleanupOwned") + selected = service(RoleService((owned,), (owned,))) + + def forbidden(*_args: object, **_kwargs: object) -> Any: + raise AssertionError + + monkeypatch.setattr(selected, "inventory_summary", forbidden) + plan = selected.plan( + cleanup.CleanupOptions(patterns=("CleanupOwned",), dry_run=True) + ) + assert [item.name for item in plan.resources] == ["CleanupOwned"] + + +def test_json_inventory_is_one_quiet_envelope_even_with_forced_progress( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + context = SimpleNamespace() + monkeypatch.setattr(_iam_cli.IamCommandContext, "create", lambda _args: context) + + class SummaryService: + def __init__(self, actual: object) -> None: + assert actual is context + + def inventory_summary( + self, + _query: cleanup.InventoryQuery, + *, + progress: Any = None, + ) -> cleanup.InventorySummary: + progress( + cleanup.InventoryProgress( + cleanup.InventoryPhase.DISCOVERY, + "Discovery complete:", + candidates=1, + ) + ) + return cleanup.InventorySummary( + account_id=ACCOUNT, + partition="aws", + caller_arn=CALLER, + items=(), + warnings=(), + details_complete=False, + inventory_complete=True, + ) + + monkeypatch.setattr(_iam_cli._iam_cleanup, "CleanupService", SummaryService) + result = _cli.console_main(["--json", "iam", "list", "--roles", "--progress"]) + captured = capsys.readouterr() + envelope = json.loads(captured.out) + + assert result.code == "IAM_INVENTORY" + assert captured.err == "" + assert envelope["code"] == "IAM_INVENTORY" + assert envelope["data"]["detailsComplete"] is False + assert envelope["data"]["inventoryComplete"] is True + + +def test_rich_progress_lifecycle_is_bounded_and_deduplicates_plain_updates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + + class Terminal(io.StringIO): + def isatty(self) -> bool: + return True + + class ScriptedEvent: + def __init__(self) -> None: + self.responses = iter((False, False, True)) + + def wait(self, _timeout: float | None = None) -> bool: + return next(self.responses, True) + + def set(self) -> None: + events.append("event-set") + + class Status: + def __init__(self, _message: object, **_kwargs: object) -> None: + events.append("created") + + def start(self) -> None: + events.append("started") + + def update(self, _message: object) -> None: + events.append("updated") + + def stop(self) -> None: + events.append("stopped") + + monkeypatch.setattr(_output, "Status", Status) + reporter = _output.ProgressReporter( + _output.OutputOptions(color="always"), + mode="always", + stream=Terminal(), + delay=0, + ) + assert reporter.enabled is True + reporter.start("rich phase") + reporter.close() + worker = reporter._thread + assert worker is not None + assert not worker.is_alive() + + direct = _output.ProgressReporter( + _output.OutputOptions(color="always"), + mode="always", + stream=Terminal(), + delay=0, + ) + monkeypatch.setattr(direct, "_stop", ScriptedEvent()) + direct._run() + assert events[-4:] == ["created", "started", "updated", "stopped"] + + plain_stream = io.StringIO() + plain = _output.ProgressReporter( + _output.OutputOptions(color="never"), + mode="always", + stream=plain_stream, + ) + plain._print_plain("one milestone") + plain._print_plain("one milestone") + assert plain_stream.getvalue().count("one milestone") == 1 + assert _output.confirm("ignored", assume_yes=True) is True + + +def test_cli_query_distinguishes_default_and_explicit_all_account_origins() -> None: + defaults = { + "patterns": [], + "roles": False, + "policies": False, + "group_grants": False, + "created": False, + "adopted": False, + "smoke": False, + "smoke_run": None, + "all_account": True, + "details": False, + } + all_resources = _iam_cli._inventory_query(argparse.Namespace(**defaults)) + created_only = _iam_cli._inventory_query( + argparse.Namespace(**{**defaults, "created": True}) + ) + + assert all_resources.origins == frozenset() + assert created_only.origins == frozenset({cleanup.OwnershipOrigin.CREATED}) diff --git a/hacksaws/tests/test_iam_managed_policies.py b/hacksaws/tests/test_iam_managed_policies.py index f4792b4..c345cd4 100644 --- a/hacksaws/tests/test_iam_managed_policies.py +++ b/hacksaws/tests/test_iam_managed_policies.py @@ -360,6 +360,91 @@ def make_service( ) +def test_summary_tags_use_one_metadata_read_without_documents_or_versions() -> None: + iam = StatefulIam() + service = make_service(iam) + summary = service.list_policies( + scope=managed.PolicyScope.LOCAL, + path_prefix=managed.DEFAULT_PATH, + include_tags=False, + )[0] + + metadata_reads = 0 + read_order: list[str] = [] + original_get = iam.get_policy + original_tags = iam.list_policy_tags + + def tracked_get(**kwargs: object) -> dict[str, object]: + nonlocal metadata_reads + metadata_reads += 1 + read_order.append("metadata") + return original_get(**kwargs) + + def tracked_tags(**kwargs: object) -> dict[str, object]: + read_order.append("tags") + return original_tags(**kwargs) + + def unexpected_read(**_kwargs: object) -> dict[str, object]: + pytest.fail("summary reads must not hydrate policy details") + + iam.get_policy = tracked_get # type: ignore[method-assign] + iam.list_policy_tags = tracked_tags # type: ignore[method-assign] + iam.get_policy_version = unexpected_read # type: ignore[method-assign] + iam.list_policy_versions = unexpected_read # type: ignore[method-assign] + hydrated = service.get_policy_summary(summary) + + assert metadata_reads == 1 + assert read_order == ["tags", "metadata"] + assert hydrated.owned + assert hydrated.document is None + assert hydrated.versions == () + + +def test_known_arn_dependencies_skip_policy_metadata_and_document_reads() -> None: + iam = StatefulIam() + service = make_service(iam) + + def unexpected_read(**_kwargs: object) -> dict[str, object]: + pytest.fail("known-ARN dependency reads must not hydrate policy metadata") + + iam.get_policy = unexpected_read # type: ignore[method-assign] + iam.get_policy_version = unexpected_read # type: ignore[method-assign] + iam.list_policy_versions = unexpected_read # type: ignore[method-assign] + + assert service.policy_dependencies_for_arn(ARN).empty + + +def test_policy_summary_metadata_fence_detects_replacement_during_tag_read() -> None: + class ReplacedDuringTags(StatefulIam): + def __init__(self) -> None: + super().__init__() + self.policy_id = "ANPA-before-tag-read" + + def _metadata(self) -> dict[str, object]: + metadata = super()._metadata() + metadata["PolicyId"] = self.policy_id + return metadata + + def list_policy_tags(self, **kwargs: object) -> dict[str, object]: + response = super().list_policy_tags(**kwargs) + self.policy_id = "ANPA-after-tag-read" + return response + + iam = ReplacedDuringTags() + service = make_service(iam) + listed = service.list_policies( + scope=managed.PolicyScope.LOCAL, + path_prefix=managed.DEFAULT_PATH, + include_tags=False, + )[0] + + current = service.get_policy_summary(listed) + + assert listed.policy_id == "ANPA-before-tag-read" + assert current.policy_id == "ANPA-after-tag-read" + assert current.owned + + def test_strict_policy_input_formats_metadata_and_canonicalization( tmp_path: Path, ) -> None: diff --git a/hacksaws/tests/test_output_foundation.py b/hacksaws/tests/test_output_foundation.py index 45fcaed..636160b 100644 --- a/hacksaws/tests/test_output_foundation.py +++ b/hacksaws/tests/test_output_foundation.py @@ -3,8 +3,10 @@ from __future__ import annotations import argparse +import io import json import os +import time from pathlib import Path from unittest.mock import patch @@ -28,6 +30,11 @@ def isatty() -> bool: return True +class _TerminalStream(io.StringIO): + def isatty(self) -> bool: + return True + + def test_color_policy_handles_windows_style_non_tty_no_color_and_json() -> None: automatic = _output.OutputOptions(color="auto") assert not _output.color_enabled(automatic, stream=_NotATerminal(), environ={}) @@ -53,6 +60,132 @@ def test_color_policy_handles_windows_style_non_tty_no_color_and_json() -> None: assert not _output.confirm("Continue?", stdin=_NotATerminal()) +def test_progress_is_stderr_only_and_json_always_suppresses_it( + capsys: pytest.CaptureFixture[str], +) -> None: + with _output.ProgressReporter( + _output.OutputOptions(), mode="always", delay=0 + ) as progress: + progress.start("Discovering roles…") + time.sleep(0.02) + progress.update("Inspecting roles… 2 found") + captured = capsys.readouterr() + assert captured.out == "" + assert "Discovering roles" in captured.err + assert "Inspecting roles" in captured.err + + stdout = io.StringIO() + stderr = io.StringIO() + with _output.ProgressReporter( + _output.OutputOptions(json=True), + mode="always", + stream=stderr, + delay=0, + ) as progress: + progress.start("must not render") + progress.update("still hidden") + assert stdout.getvalue() == "" + assert stderr.getvalue() == "" + + +def test_plain_progress_sanitizes_terminal_controls_and_honors_delay() -> None: + stream = io.StringIO() + progress = _output.ProgressReporter( + _output.OutputOptions(color="never"), + mode="always", + stream=stream, + delay=0.05, + ) + progress.start("Inspecting\x1b[31m roles\r\n") + progress.close() + assert stream.getvalue() == "" + + with _output.ProgressReporter( + _output.OutputOptions(color="never"), + mode="always", + stream=stream, + delay=0, + ) as visible: + visible.start("Inspecting\x1b[31m roles\r\n") + time.sleep(0.02) + rendered = stream.getvalue() + assert "Inspecting roles" in rendered + assert "\x1b" not in rendered + assert "\r" not in rendered + + +def test_progress_auto_mode_respects_terminal_and_plain_output_policies( + monkeypatch: pytest.MonkeyPatch, +) -> None: + redirected = io.StringIO() + with _output.ProgressReporter( + _output.OutputOptions(), mode="auto", stream=redirected, delay=0 + ) as quiet: + quiet.start("must remain quiet") + time.sleep(0.02) + assert redirected.getvalue() == "" + + for environment in ({"NO_COLOR": "1"}, {"TERM": "dumb"}): + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("TERM", raising=False) + for key, value in environment.items(): + monkeypatch.setenv(key, value) + terminal = _TerminalStream() + with _output.ProgressReporter( + _output.OutputOptions(), mode="auto", stream=terminal, delay=0 + ) as plain: + plain.start("Validating ownership…") + time.sleep(0.02) + assert "Validating ownership" in terminal.getvalue() + assert "\x1b" not in terminal.getvalue() + + terminal = _TerminalStream() + with _output.ProgressReporter( + _output.OutputOptions(color="never"), + mode="auto", + stream=terminal, + delay=0, + ) as no_color: + no_color.start("Applying filters…") + time.sleep(0.02) + assert "Applying filters" in terminal.getvalue() + assert "\x1b" not in terminal.getvalue() + + +def test_rich_progress_lifecycle_updates_elapsed_status_and_clears() -> None: + terminal = _TerminalStream() + reporter = _output.ProgressReporter( + _output.OutputOptions(color="always"), + mode="auto", + stream=terminal, + delay=0, + ) + assert reporter.enabled is True + with reporter: + assert reporter.start("Discovering roles…") is reporter + time.sleep(0.03) + reporter.update("Validating ownership… 1/2") + assert reporter.message == "Validating ownership… 1/2" + time.sleep(0.52) + assert "Validating ownership" in terminal.getvalue() + + +def test_plain_progress_deduplicates_milestones_and_confirmation_shortcut() -> None: + stream = io.StringIO() + reporter = _output.ProgressReporter( + _output.OutputOptions(color="never"), + mode="always", + stream=stream, + delay=0, + ) + with reporter: + reporter.start("Applying filters…") + time.sleep(0.02) + reporter.update("Applying filters…") + assert stream.getvalue().count("Applying filters") == 1 + assert _output.confirm("Continue?", assume_yes=True) is True + + def test_global_output_flags_work_anywhere_and_emit_a_stable_envelope( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: From 11b356f96d5c5e0084b2d0b4a62c3dda7bafd831 Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Sun, 2 Aug 2026 14:26:42 -0500 Subject: [PATCH 6/8] Harden AWS Session Workflows - **Session Safety** - Bind browser refreshes to verified identity lineage, handle MFA codes without persistence, and make role assumptions and cleanup preserve only the intended credentials. - **IAM Management** - Add deterministic policy and role input resolution, legacy-resource adoption, and credential-free mutation plans so remote changes are explicit, reviewable, and recoverable. - **Audit History** - Record searchable, credential-free command outcomes with configurable retention and safe export so operators can understand activity without creating another secret store. - **CLI Experience** - Expand help and workflow documentation, and constrain formatting tasks to Git-visible files so repository checks work reliably from the project root. --- .gitignore | 2 + CHEATSHEET.md | 38 + README.md | 22 + docs/assume-role.md | 14 + docs/configuration.md | 13 + docs/history.md | 103 ++ docs/iam-policies.md | 12 + docs/iam-roles-and-trust.md | 26 + docs/login.md | 12 + hacksaws/_audit.py | 30 + hacksaws/_cli.py | 539 ++++++++- hacksaws/_history.py | 985 ++++++++++++++++ hacksaws/_iam_cleanup.py | 73 +- hacksaws/_iam_cli.py | 268 ++++- hacksaws/_iam_managed_policies.py | 461 ++++++-- hacksaws/_iam_policy_cli.py | 1071 +++++++++++++++--- hacksaws/_iam_role_cli.py | 654 ++++++++++- hacksaws/_iam_roles.py | 131 ++- hacksaws/_mutation_view.py | 237 ++++ hacksaws/_output.py | 9 +- hacksaws/_resource_input.py | 141 +++ hacksaws/_sessions.py | 1030 +++++++++++++---- hacksaws/_state.py | 40 +- hacksaws/tests/test_assume_role.py | 229 +++- hacksaws/tests/test_browser_lineage.py | 891 +++++++++++++++ hacksaws/tests/test_coverage_closure.py | 10 +- hacksaws/tests/test_history.py | 693 ++++++++++++ hacksaws/tests/test_iam_cleanup.py | 70 +- hacksaws/tests/test_iam_cli_scaffold.py | 68 +- hacksaws/tests/test_iam_inventory_summary.py | 2 + hacksaws/tests/test_iam_managed_policies.py | 65 ++ hacksaws/tests/test_iam_policy_cli.py | 371 +++++- hacksaws/tests/test_iam_roles.py | 55 +- hacksaws/tests/test_local_lifecycle.py | 18 +- hacksaws/tests/test_mutation_contract.py | 620 ++++++++++ hacksaws/tests/test_sessions_coverage.py | 130 ++- hacksaws/tests/test_v04.py | 73 +- scripts/prettier.py | 8 +- 38 files changed, 8555 insertions(+), 659 deletions(-) create mode 100644 docs/history.md create mode 100644 hacksaws/_audit.py create mode 100644 hacksaws/_history.py create mode 100644 hacksaws/_mutation_view.py create mode 100644 hacksaws/_resource_input.py create mode 100644 hacksaws/tests/test_browser_lineage.py create mode 100644 hacksaws/tests/test_history.py create mode 100644 hacksaws/tests/test_mutation_contract.py diff --git a/.gitignore b/.gitignore index cebe4d8..fc6dcfd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,10 @@ __pycache__/ .pytest_cache/ .ruff_cache/ .cache/ +.tmp/ .tmp-pytest-*/ .coverage +.coverage-* coverage.xml htmlcov/ diff --git a/CHEATSHEET.md b/CHEATSHEET.md index 7d5f13c..f06883a 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -12,6 +12,8 @@ hacksaws mfa login PROFILE MFA_CODE [-l SECONDS|--lifespan SECONDS] hacksaws mfa in PROFILE MFA_CODE hacksaws mfa in +TARGET MFA_CODE hacksaws mfa in MFA_CODE --target TARGET +hacksaws mfa in PROFILE # hidden interactive MFA prompt +hacksaws mfa in PROFILE --mfa-code-stdin hacksaws mfa logout [PROFILE] hacksaws mfa out [PROFILE] @@ -76,8 +78,12 @@ hacksaws assume SOURCE --role ROLE_OR_ARN \ hacksaws assume SOURCE --boundary NAME (--self | --to ... | --to-profile ...) hacksaws assume +TARGET [OPTIONS] hacksaws assume --target TARGET [OPTIONS] +hacksaws assume SOURCE DEST --role ROLE_OR_ARN [OPTIONS] ``` +Positional `DEST` means `--to-profile DEST` in the source location. It conflicts +with every other destination spelling. + Common options: ```text @@ -185,6 +191,32 @@ hacksaws config export [ARCHIVE.zip] hacksaws config import ARCHIVE.zip [--replace] [--yes] ``` +## Redacted command history + +```shell +hacksaws history list [PATTERN]... [--since TIME] [--until TIME] [--wide] +hacksaws history search PATTERN... [--command FAMILY] [--outcome OUTCOME] +hacksaws history show HISTORY_ID +hacksaws history report [--since TIME] [--account ACCOUNT] +hacksaws history export [PATTERN]... [--format jsonl|json] [--output FILE] +hacksaws history status +hacksaws history check +hacksaws history clear (--before TIME|--all) [--dry-run] [--yes] +``` + +List/search/report/export also accept `--command`, `--outcome`, `--account`, +`--resource`, `--limit`, and `--include-running`. `TIME` is an ISO timestamp or +a duration ago using seconds, minutes, hours, days, or weeks, such as `15m`, +`24h`, `7d`, or `2weeks`. Clear never removes a running command or unresolved +recovery record; interactive apply requires typing exactly `yes`, and +noninteractive/JSON apply requires `--yes`. + +History stores safe command metadata and outcomes, never raw arguments, +stdout/stderr, prompts, paths, documents, credentials, MFA codes, external IDs, +or exception text. Defaults are 90 days, 10,000 records, and 50 MiB. Inspect or +change `history.enabled`, `history.max_age`, `history.max_entries`, and +`history.max_bytes` with `hacksaws config options|get|set`. + Human `status` output is a compact, dynamic table. `LOCATION` is hidden when all rows use the default location; `TTL` is hidden when no displayed session has a meaningful expiry; and `VERIFY` is hidden unless `--verify` returns a useful STS @@ -433,6 +465,12 @@ hacksaws iam policy adopt POLICY [--tag KEY=VALUE]... [selectors] [--dry-run] [- hacksaws iam policy release POLICY [selectors] [--dry-run] [--yes] ``` +Policy create accepts `NAME FILE` or `FILE NAME`; `--name` and `--file` are the +explicit forms. Policy update similarly accepts `POLICY FILE` in either +unambiguous order, with `--policy` and `--file` available to resolve ambiguity. +Role `inline-policy put` and `trust set` follow the same rule. Ambiguous or +missing inputs fail before AWS credential discovery. + `POLICY` accepts the adapter's account- and partition-safe ARN/name resolution. AWS-managed policies may be inspected, exported, validated, and checked, but cannot be created, updated, tagged, adopted, released, rolled back, or deleted. diff --git a/README.md b/README.md index 343bf1a..5aed355 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ MFA login starts from persistent source credentials: hacksaws mfa in admin --name horizon 123456 ``` +Omit the code in an interactive terminal for a hidden prompt, or use +`--mfa-code-stdin` to read one line from standard input. JSON and other +non-interactive use never prompts. + The source above is profile `admin` in `~/.aws-horizon`. To write temporary credentials somewhere else, use `--to LOCATION:PROFILE`: @@ -108,6 +112,10 @@ hacksaws assume admin --name horizon \ --to default:agent ``` +For a destination profile in the same AWS location, +`hacksaws assume SOURCE DEST --role ...` is the short form of +`--to-profile DEST`. + The destination is always explicit. The source is removed after a successful handoff unless `--keep-source` is deliberate; use `--self` for an intentional in-place replacement. See [Assume a role](docs/assume-role.md) for destination, @@ -125,6 +133,7 @@ hacksaws profile list --verify hacksaws iam list --profile admin --wide hacksaws cache status hacksaws config show +hacksaws history list --since 24h ``` `iam list` verifies live ownership tags within the canonical `/hacksaws/` paths @@ -136,6 +145,12 @@ progress on stderr while stdout remains safe to pipe; use `--progress` to force plain milestones or `--no-progress` to suppress them. JSON mode is always quiet until its single result envelope. +Local history records redacted command families, outcomes, timings, and +validated identifiers—not raw arguments, output, prompts, paths, policy +documents, or credentials. Use `hacksaws history status` to inspect retention +and health. See [Local command history](docs/history.md) for the full security +contract, filters, exports, and clearing behavior. + Global output flags may appear anywhere before `--`: ```shell @@ -163,6 +178,12 @@ validation, collision checks, and planning, but creates no recovery journal and changes neither AWS nor local state. Recovery `continue` and `rollback` commands resume an already-journaled operation and therefore do not accept `--dry-run`. +Mutation previews and results use one credential-free contract: exact resource +identity and ownership, scalar before/after changes, ordered AWS actions, +dependencies, warnings, confirmation, applied actions, resource IDs/ARNs, +console links, and recovery journal IDs. Policy documents and tag values are +represented only by non-reversible summaries. + ## Leave No Trace cleanup Cleanup deletes only resources whose Hacksaws ownership can be established in @@ -252,6 +273,7 @@ models. - [Cleanup and Leave No Trace](docs/cleanup.md) - [Configuration](docs/configuration.md) - [Policy cache](docs/cache.md) +- [Local command history](docs/history.md) - [Security model](docs/security-model.md) - [Automation and JSON](docs/automation-and-json.md) - [Troubleshooting](docs/troubleshooting.md) diff --git a/docs/assume-role.md b/docs/assume-role.md index 12b5627..b9c86a0 100644 --- a/docs/assume-role.md +++ b/docs/assume-role.md @@ -17,6 +17,15 @@ profile `agent` in `~/.aws`. Use `--to-profile agent` to write into the source location, or a bounded target such as `hacksaws assume +prod-agent` to load the source, destination, role, and optional policy together. +The concise same-location form is also supported: + +```shell +hacksaws assume admin agent --name horizon --role AgentSession +``` + +Here `SOURCE` is `admin` and positional `DEST` is `agent`; `DEST` is exactly +equivalent to `--to-profile agent` and conflicts with other destination forms. + ## Destination safety A destination is mandatory. Choose exactly one: @@ -97,3 +106,8 @@ hacksaws assume admin --role AgentSession --to default:agent --yes --json Preview and result JSON never include access keys, secret keys, session tokens, credential backups, or policy documents. Account or partition disagreement is a hard failure before local credential mutation. + +Browser sources tolerate ordinary token refresh only when live STS identity and +stable hashed browser lineage still match. A different login generation or a +refresh racing final deletion is preserved and reported as residue instead of +being deleted as though Hacksaws still owned it. diff --git a/docs/configuration.md b/docs/configuration.md index 77ef383..0a7294d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,5 +27,18 @@ performs a deny-all AssumeRole probe before offering interactive repairs. Naming rules have global account defaults and policy/role overrides for prefix, suffix, case, path, and enforcement. +Local history settings are first-class options as well: + +```shell +hacksaws config get history.enabled +hacksaws config set history.enabled false +hacksaws config set history.max_age 7776000 +hacksaws config set history.max_entries 10000 +hacksaws config set history.max_bytes 52428800 +``` + +The limits use seconds, entries, and bytes. See +[Local command history](history.md) for the redaction and retention contract. + `config export` creates a portable zip excluding temporary caches. `config import` validates the complete archive before replacing state. diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..cde4782 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,103 @@ +# Local command history + +Hacksaws keeps a local, credential-free command history so people and agents can +understand what was attempted without retaining the material used to perform it. +It is enabled by default and stored in `~/.hacksaws/history/history.db`. + +## What is recorded + +Every CLI invocation receives a lifecycle record before argument parsing. After +successful parsing, Hacksaws adds only positively allowlisted metadata: + +- the canonical command family and a recognized alias; +- validated profile, location, account, target, and IAM resource identifiers; +- safe booleans and enums such as dry-run, format, and output mode; +- input roles and formats, never file paths or file contents; +- whether an MFA code or external ID was supplied, never its value; +- semantic confirmation state, outcome, result code, timing, and safe counts; +- unresolved recovery state, which is protected from automatic retention and + manual clear operations. + +Hacksaws never stores raw command-line arguments, stdout, stderr, result or +exception text, prompts or responses, environment variables, working +directories, file paths, policy documents, credentials, MFA codes, external IDs, +or other secret values. History is diagnostic only: a history-write failure +never changes the command result or corrupts JSON output. + +The database uses SQLite WAL mode and an additive `PRAGMA user_version` schema. +An invocation begins as `running` and is atomically finalized as completed, +interrupted, or crashed. A running invocation older than 24 hours is marked +abandoned during routine maintenance. + +## Inspect history + +```shell +hacksaws history list +hacksaws history list --wide --since 7d --outcome operational-error +hacksaws history search "*ServiceBuzz*" "*iam.policy*" +hacksaws history show 12ab34cd +hacksaws history report --since 30d --account 123456789012 +hacksaws history status +hacksaws history check +``` + +`list` and `search` default to the 50 newest completed records. Filters include +`--since`, `--until`, `--command`, `--outcome`, `--account`, `--resource`, and +`--limit`. Times may be ISO timestamps or durations meaning “that long ago.” +Duration units accept the same seconds/minutes/hours grammar as session duration +plus days and weeks, including `600s`, `15minutes`, `24h`, `7d`, and `2weeks`. +Add `--include-running` when diagnosing an active process. + +`show` accepts a complete history ID or an unambiguous prefix of at least four +hexadecimal characters. Its human view includes a reconstructed command +template. File and secret inputs appear only as placeholders, so the template is +useful for teaching without becoming a credential-recovery mechanism. + +Every command accepts global `--json`. Machine mode retains the same single +Hacksaws result envelope used by the rest of the CLI. + +## Export history + +```shell +# Deterministic JSON Lines on stdout +hacksaws history export --format jsonl --since 7d + +# One JSON array in a file +hacksaws history export --format json --output history.json +``` + +Exports contain the same redacted records returned by `history list`; export +does not re-read command output or policy documents. JSON Lines is the default. +When `--output` is omitted, the export is written to stdout. In global JSON +mode, records are returned inside the standard result envelope. + +## Retention and clearing + +Defaults retain resolved records for 90 days, up to 10,000 entries and 50 MiB of +logical record size. Maintenance runs at most daily after command completion. +Oldest eligible records are removed first. Running commands and unresolved +recovery records are never removed by retention or `history clear`. + +```shell +hacksaws history clear --before 30d --dry-run +hacksaws history clear --before 30d +hacksaws history clear --all --yes +``` + +Without `--dry-run` or `--yes`, an interactive clear requires typing exactly +`yes`. JSON and non-interactive execution require `--yes`. + +The settings are self-documented by `hacksaws config options` and may be managed +like other configuration values: + +```shell +hacksaws config get history.enabled +hacksaws config set history.enabled false +hacksaws config set history.max_age 2592000 +hacksaws config set history.max_entries 5000 +hacksaws config set history.max_bytes 26214400 +``` + +The three limits are positive integers expressed in seconds, entries, and bytes. +Disabling history prevents new records; it does not delete existing history. Use +`history clear` for explicit removal. diff --git a/docs/iam-policies.md b/docs/iam-policies.md index 01ecaf6..462d4e2 100644 --- a/docs/iam-policies.md +++ b/docs/iam-policies.md @@ -22,8 +22,20 @@ Policy files accept JSON, YAML, and TOML. Export metadata modes are: - `sidecar`: bare policy plus a separate metadata file. - `none`: bare IAM policy document. +Create accepts `NAME FILE` or `FILE NAME`; omit the name to derive it from the +filename. `--name NAME --file FILE` is the explicit form. Update accepts +`POLICY FILE` in either unambiguous order and provides `--policy` / `--file` for +the explicit form. Path syntax, a supported extension, or an existing local file +identifies the file; ambiguous input fails before AWS access. + All mutations accept `--dry-run`. AWS Access Analyzer validation is used unless `--local-validation-only` is explicit. +`create --replace` never adopts an unowned name collision. Use `adopt` as a +separate reviewed mutation. Adoption reconciles the protected Hacksaws ownership +tags while preserving the policy document and unrelated tags; `release` removes +only protected ownership tags. Legacy partial ownership is never silently +inferred—run `adopt --dry-run` to review its exact tag repair. + Local reusable documents use `hacksaws policy add|get|list|update|remove|rename` and are stored as YAML under `~/.hacksaws/stored_session_policies`. diff --git a/docs/iam-roles-and-trust.md b/docs/iam-roles-and-trust.md index 1f3dc9a..ea14bd6 100644 --- a/docs/iam-roles-and-trust.md +++ b/docs/iam-roles-and-trust.md @@ -33,6 +33,32 @@ Role commands also support get/list/update/delete, tag CRUD, managed-policy attach/detach, and inline-policy list/get/export/put/edit/delete. Use `--dry-run` on every mutation. +Local document leaves accept ergonomic order-independent input: + +```shell +hacksaws iam role inline-policy put Agent LocalRead ./read.yaml +hacksaws iam role inline-policy put Agent ./read.yaml LocalRead +hacksaws iam role inline-policy put Agent --policy-name LocalRead --file ./read.yaml +hacksaws iam role trust set Agent ./trust.yaml +hacksaws iam role trust set ./trust.yaml Agent +hacksaws iam role trust set --role Agent --file ./trust.yaml +``` + +Path syntax, a supported extension, or an existing file identifies the file. +Ambiguous inputs fail before AWS access with the explicit flags to use. + +Create/replace never adopts an unowned same-name role. `adopt` is the explicit +ownership transition and reconciles protected ownership tags while preserving +trust, permissions, and unrelated tags; `release` removes only those protected +tags. A legacy safely-owned role can be upgraded by reviewing `adopt --dry-run`. + +Every mutation preview shows credential-free identity/ownership, exact scalar +before→after changes, ordered AWS actions, dependencies, warnings, and required +confirmation. Results report the applied actions, ARN/ID, console link, and +recovery journal. Documents and tag values are represented only by +non-reversible summaries. Ordinary mutations require exact `yes`; role deletion +requires the exact role name. `--yes` is the explicit noninteractive form. + The complete role command families are: ```text diff --git a/docs/login.md b/docs/login.md index 10650c3..d4b03dc 100644 --- a/docs/login.md +++ b/docs/login.md @@ -14,6 +14,12 @@ hacksaws pk in default --name default `web` and `pk` are equivalent. `in` aliases `login`; `out` aliases `logout`. +Hacksaws tracks the browser cache by stable, hashed login lineage rather than +storing its tokens. Normal AWS access/refresh-token rotation is accepted only +after STS verifies the same account, partition, and principal. A different +client/DPoP login generation is preserved as residue for review—even with +`--force`—and compare-and-delete preserves a cache that refreshes concurrently. + ## MFA login MFA requires persistent source credentials. `PROFILE --name LOCATION` selects @@ -24,6 +30,12 @@ hacksaws mfa in admin --name horizon 123456 hacksaws mfa in admin --name horizon --to default:debug 123456 ``` +In an interactive terminal, omit `123456` for a hidden MFA prompt. Use +`--mfa-code-stdin` to read exactly one line from standard input. Supplying both +forms is an error; JSON and non-TTY use require a positional or stdin code and +never prompt. History records only that a code was provided and whether its +source was argument, stdin, or prompt. + The source credentials should have only bootstrap permissions. See [Security model](security-model.md). diff --git a/hacksaws/_audit.py b/hacksaws/_audit.py new file mode 100644 index 0000000..5d968ac --- /dev/null +++ b/hacksaws/_audit.py @@ -0,0 +1,30 @@ +"""Dependency-free semantic audit context shared by CLI confirmation helpers.""" + +from __future__ import annotations + +from contextvars import ContextVar +from typing import Literal + +ConfirmationMechanism = Literal["exact-yes", "yes-no", "resource-name", "yes-flag"] +ConfirmationOutcome = Literal["accepted", "declined", "bypassed", "unavailable"] + +_confirmation: ContextVar[str] = ContextVar( + "hacksaws_audit_confirmation", default="not-requested" +) + + +def reset_confirmation() -> None: + """Reset confirmation state at the start or end of an invocation.""" + _confirmation.set("not-requested") + + +def note_confirmation( + mechanism: ConfirmationMechanism, outcome: ConfirmationOutcome +) -> None: + """Retain only a semantic confirmation classification, never entered text.""" + _confirmation.set(f"{mechanism}:{outcome}") + + +def confirmation() -> str: + """Return the current invocation's semantic confirmation state.""" + return _confirmation.get() diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index 16b7801..320f0be 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -5,6 +5,7 @@ import argparse import contextlib import fnmatch +import getpass import io import json import os @@ -27,6 +28,7 @@ from hacksaws import _configs from hacksaws import _duration from hacksaws import _ecr +from hacksaws import _history from hacksaws import _iam_cli from hacksaws import _output from hacksaws import _policies @@ -74,6 +76,56 @@ def _ecr_arguments(parser: argparse.ArgumentParser) -> None: ) +def _history_filter_arguments(parser: argparse.ArgumentParser) -> None: + """Add structured, secret-free history filters to one leaf command.""" + parser.add_argument( + "--since", + help=( + "Include records at or after an ISO timestamp or duration ago using " + "seconds, minutes, hours, days, or weeks (for example 24h or 2weeks)." + ), + ) + parser.add_argument( + "--until", + help=( + "Include records at or before an ISO timestamp or duration ago using " + "seconds, minutes, hours, days, or weeks." + ), + ) + parser.add_argument( + "--command", + help="Include this canonical command family, such as iam.policy.", + ) + parser.add_argument( + "--outcome", + choices=( + "success", + "usage-error", + "policy-refusal", + "cancelled", + "operational-error", + "interrupted", + "crashed", + ), + help="Include only this command outcome.", + ) + parser.add_argument("--account", help="Include only this recorded AWS account.") + parser.add_argument( + "--resource", help="Match a recorded resource name or ARN fragment." + ) + parser.add_argument( + "--limit", + type=int, + default=50, + help="Maximum records to return (default: 50; maximum: 10000).", + ) + parser.add_argument( + "--include-running", + action="store_true", + help="Also include commands whose final outcome has not been recorded.", + ) + + def _login_arguments(parser: argparse.ArgumentParser, *, browser: bool = False) -> None: parser.add_argument( "profile", @@ -85,7 +137,17 @@ def _login_arguments(parser: argparse.ArgumentParser, *, browser: bool = False) ) if not browser: parser.add_argument( - "mfa_code", nargs="?", help="Current six-digit MFA token code." + "mfa_code", + nargs="?", + help=( + "Current six-digit MFA token; omit for a hidden interactive prompt, " + "or use --mfa-code-stdin for noninteractive input." + ), + ) + parser.add_argument( + "--mfa-code-stdin", + action="store_true", + help="Read the MFA token code from standard input instead of the command line.", ) parser.add_argument( "-l", @@ -203,6 +265,12 @@ def _assume_arguments(parser: argparse.ArgumentParser) -> None: "saved target; +TARGET is the documented form." ), ) + parser.add_argument( + "destination", + nargs="?", + metavar="DEST", + help="Destination profile in the source AWS location (equivalent to --to-profile).", + ) parser.add_argument( "-n", "--name", @@ -288,6 +356,7 @@ def _assume_arguments(parser: argparse.ArgumentParser) -> None: ecr_region=[], remote=False, mfa_code=None, + mfa_code_stdin=False, ) @@ -668,6 +737,104 @@ def _create_parser() -> argparse.ArgumentParser: option_set.add_argument("key") option_set.add_argument("value") option_set.add_argument("--json", action="store_true") + + history = types.add_parser( + "history", + help="Inspect redacted local command outcomes without exposing credentials.", + description=( + "Inspect the credential-free local audit trail. Hacksaws records command " + "families, validated identifiers, outcomes, and timings; it never records " + "raw arguments, command output, prompt input, credential values, or paths." + ), + ) + history_actions = history.add_subparsers(dest="history_action") + history_list = history_actions.add_parser( + "list", help="List recent completed command outcomes in a compact table." + ) + history_list.add_argument( + "patterns", + nargs="*", + help="Case-insensitive fnmatch patterns matched across safe record fields.", + ) + history_list.add_argument( + "--wide", + action="store_true", + help="Show account, resource, and result details.", + ) + _history_filter_arguments(history_list) + history_search = history_actions.add_parser( + "search", help="Search safe history fields using one or more ORed patterns." + ) + history_search.add_argument( + "patterns", + nargs="+", + help="Case-insensitive fnmatch patterns; plain text is treated as *TEXT*.", + ) + history_search.add_argument( + "--wide", + action="store_true", + help="Show account, resource, and result details.", + ) + _history_filter_arguments(history_search) + history_show = history_actions.add_parser( + "show", help="Show one safe record by full or unique-prefix history ID." + ) + history_show.add_argument("history_id", help="Full or unique ID prefix (4+ hex).") + history_report = history_actions.add_parser( + "report", help="Summarize outcomes and command families for a time window." + ) + _history_filter_arguments(history_report) + history_export = history_actions.add_parser( + "export", help="Export selected safe records as deterministic JSONL or JSON." + ) + history_export.add_argument( + "patterns", + nargs="*", + help="Optional case-insensitive fnmatch patterns for safe record fields.", + ) + _history_filter_arguments(history_export) + history_export.add_argument( + "--format", + choices=("jsonl", "json"), + default="jsonl", + help="Export encoding (default: jsonl).", + ) + history_export.add_argument( + "--output", + "-o", + help="Write to this file instead of standard output.", + ) + history_actions.add_parser( + "status", help="Show database health, size, and retention settings." + ) + history_actions.add_parser( + "check", help="Validate the history schema, database, and safe payloads." + ) + history_clear = history_actions.add_parser( + "clear", + help="Remove resolved history while preserving active recovery records.", + ) + history_clear_scope = history_clear.add_mutually_exclusive_group(required=True) + history_clear_scope.add_argument( + "--before", + help=( + "Remove records before an ISO timestamp or duration ago, such as 30d " + "or 2weeks." + ), + ) + history_clear_scope.add_argument( + "--all", action="store_true", help="Remove every eligible resolved record." + ) + history_clear.add_argument( + "--dry-run", + action="store_true", + help="Plan the clear without changing history.", + ) + history_clear.add_argument( + "--yes", + action="store_true", + help="Apply without prompting; intended for deliberate automation.", + ) _register_extension_commands(types) return parser @@ -847,6 +1014,24 @@ def _validate_login(namespace: argparse.Namespace) -> None: def _validate_assume(namespace: argparse.Namespace) -> None: """Validate assume-only grammar before AWS discovery or confirmation.""" + positional_destination = getattr(namespace, "destination", None) + if positional_destination: + if ( + namespace.target + or namespace.self_destination + or namespace.to + or namespace.to_directory + or namespace.to_profile + ): + raise _configs.OperationalError( + "Positional DEST is mutually exclusive with saved targets, --self, " + "--to, --to-directory, and --to-profile." + ) + namespace.to_profile = ( + "default" + if positional_destination in {".", "default"} + else positional_destination + ) profile = getattr(namespace, "profile", None) if profile in {".", "default"}: namespace.profile = "default" @@ -1067,8 +1252,28 @@ def _run_mfa(context: _configs.Context) -> _configs.Result: raise _configs.OperationalError( "MFA login requires a source profile unless a saved target supplies it." ) - if context.args.mfa_code is None: - raise _configs.OperationalError("MFA login requires a token code.") + if context.args.mfa_code is not None and bool( + getattr(context.args, "mfa_code_stdin", False) + ): + raise _configs.OperationalError( + "Specify the MFA token either positionally or with --mfa-code-stdin, not both." + ) + if bool(getattr(context.args, "mfa_code_stdin", False)): + context.args.mfa_code = sys.stdin.readline().strip() + context.args.mfa_code_source = "stdin" + elif context.args.mfa_code is None: + if bool(getattr(context.args, "json", False)) or not sys.stdin.isatty(): + raise _configs.OperationalError( + "MFA login requires a token code; provide it positionally or use " + "--mfa-code-stdin." + ) + context.args.mfa_code = getpass.getpass("MFA token code: ").strip() + context.args.mfa_code_source = "prompt" + else: + context.args.mfa_code_source = "argument" + if not context.args.mfa_code: + raise _configs.OperationalError("MFA token code cannot be empty.") + _history.note_mfa_code(source=context.args.mfa_code_source) if _sessions.is_expanded_login(context.args): return _sessions.mfa_login(context) @@ -2298,7 +2503,299 @@ def _run_config(args: argparse.Namespace) -> _configs.Result: return _configs.Result("CONFIG_HELP", "Choose a config action.", 2, "stderr") -def _console_main_invocation(arguments: Sequence[str] | None = None) -> _configs.Result: +_HISTORY_OUTCOMES = { + "success": ("✓", "success"), + "usage-error": ("?", "usage error"), + "policy-refusal": ("⊘", "policy refusal"), + "cancelled": ("○", "cancelled"), + "operational-error": ("!", "operational error"), + "interrupted": ("↯", "interrupted"), + "crashed": ("X", "crashed/abandoned"), +} + + +def _history_records(args: argparse.Namespace) -> list[dict[str, object]]: + return _history.list_records( + patterns=tuple(getattr(args, "patterns", ()) or ()), + since=_history.parse_time(args.since) if getattr(args, "since", None) else None, + until=( + _history.parse_time(args.until) if getattr(args, "until", None) else None + ), + command=getattr(args, "command", None), + outcome=getattr(args, "outcome", None), + account=getattr(args, "account", None), + resource=getattr(args, "resource", None), + limit=getattr(args, "limit", 50), + include_running=bool(getattr(args, "include_running", False)), + ) + + +def _history_list_text(records: list[dict[str, object]], *, wide: bool = False) -> str: + columns = ["ID", "STARTED", "S", "COMMAND", "PROFILE"] + if wide: + columns.extend(("ACCOUNT", "RESOURCE", "RESULT", "MS")) + rows: list[list[object]] = [] + used_symbols: set[str] = set() + for record in records: + outcome = str(record.get("outcome") or "") + symbol = _HISTORY_OUTCOMES.get(outcome, ("…", "running/unknown"))[0] + used_symbols.add(symbol) + row: list[object] = [ + str(record["id"])[:8], + str(record.get("startedAt") or "-").replace("T", " ")[:19], + symbol, + record.get("command"), + record.get("profile"), + ] + if wide: + row.extend( + ( + record.get("accountId"), + record.get("resourceName") or record.get("resourceArn"), + record.get("resultCode"), + record.get("durationMs"), + ) + ) + rows.append(row) + table = _text_table(columns, rows) + if not rows: + return table + meanings = [ + (symbol, meaning) + for outcome, (symbol, meaning) in _HISTORY_OUTCOMES.items() + if symbol in used_symbols and outcome + ] + if "…" in used_symbols: + meanings.append(("…", "running/unknown")) + return f"{table}\n\nKey: " + " ".join( + f"{symbol} {meaning}" for symbol, meaning in meanings + ) + + +def _history_show_text(record: dict[str, object]) -> str: + lines = [ + f"History ID: {record['id']}", + f"Command: {record['command']}", + f"Safe template: {_history.command_template(record)}", + f"Outcome: {record.get('outcome') or record.get('state')}", + f"Result: {record.get('resultCode') or '-'} (exit {record.get('exitCode')})", + f"Started: {record.get('startedAt')}", + f"Ended: {record.get('endedAt') or '-'}", + f"Duration: {record.get('durationMs') or 0} ms", + f"Confirmation: {record.get('confirmation')}", + ] + context = [ + f"{label}={record.get(key)}" + for key, label in ( + ("accountId", "account"), + ("location", "location"), + ("profile", "profile"), + ("target", "target"), + ("resourceName", "resource"), + ("resourceArn", "arn"), + ) + if record.get(key) + ] + if context: + lines.append("Context: " + ", ".join(context)) + safe = record.get("safe") + if isinstance(safe, dict): + input_kinds = safe.get("inputKinds") + if isinstance(input_kinds, list) and input_kinds: + lines.append( + "Inputs: " + + ", ".join( + f"{item.get('role')} ({item.get('format')})" + for item in input_kinds + if isinstance(item, dict) + ) + ) + secret_presence = safe.get("secretPresence") + if isinstance(secret_presence, dict) and any(secret_presence.values()): + present = [ + name + for key, name in ( + ("mfaCode", "MFA code"), + ("externalId", "external ID"), + ) + if secret_presence.get(key) is True + ] + lines.append( + "Secret inputs supplied (values never stored): " + ", ".join(present) + ) + if record.get("recoveryUnresolved") is True: + lines.append("Recovery: unresolved; retention and clear preserve this record.") + return "\n".join(lines) + + +def _history_report(records: list[dict[str, object]]) -> dict[str, object]: + outcomes: dict[str, int] = {} + commands: dict[str, int] = {} + duration = 0 + for record in records: + outcome = str(record.get("outcome") or record.get("state") or "unknown") + command = str(record.get("command") or "unknown") + outcomes[outcome] = outcomes.get(outcome, 0) + 1 + commands[command] = commands.get(command, 0) + 1 + value = record.get("durationMs") + if type(value) is int: + duration += value + return { + "count": len(records), + "durationMs": duration, + "outcomes": dict(sorted(outcomes.items())), + "commands": dict(sorted(commands.items())), + } + + +def _history_report_text(report: dict[str, object]) -> str: + outcomes = cast("dict[str, int]", report["outcomes"]) + commands = cast("dict[str, int]", report["commands"]) + return "\n\n".join( + ( + f"Commands: {report['count']} Total duration: {report['durationMs']} ms", + "Outcomes\n" + _text_table(("OUTCOME", "COUNT"), list(outcomes.items())), + "Command families\n" + + _text_table(("COMMAND", "COUNT"), list(commands.items())), + ) + ) + + +def _history_status_text(report: dict[str, object]) -> str: + retention = cast("dict[str, object]", report["retention"]) + return "\n".join( + ( + f"History database: {report['database']}", + f"Health: {report['integrity']}", + f"Records: {report['count']} ({report['running']} running)", + f"Logical size: {report['logicalBytes']} bytes", + f"Range: {report['oldest'] or '-'} to {report['newest'] or '-'}", + ( + "Retention: " + f"{retention['max_age']}s, {retention['max_entries']} entries, " + f"{retention['max_bytes']} bytes; " + f"recording {'enabled' if retention['enabled'] else 'disabled'}" + ), + ) + ) + + +def _run_history(args: argparse.Namespace) -> _configs.Result: + action = args.history_action + if action in {"list", "search"}: + records = _history_records(args) + return _configs.Result( + "HISTORY_LIST" if action == "list" else "HISTORY_SEARCH", + _history_list_text(records, wide=args.wide), + data={"count": len(records), "records": records}, + kind="info", + ) + if action == "show": + record = _history.get_record(args.history_id) + return _configs.Result( + "HISTORY_SHOW", _history_show_text(record), data=record, kind="info" + ) + if action == "report": + report = _history_report(_history_records(args)) + return _configs.Result( + "HISTORY_REPORT", _history_report_text(report), data=report, kind="info" + ) + if action == "export": + records = _history_records(args) + encoded = _history.export_records(records, format_name=args.format) + if args.output: + destination = Path(args.output).expanduser().absolute() + _state.atomic_write(destination, encoded.encode("utf-8")) + return _configs.Result( + "HISTORY_EXPORT", + f"Exported {len(records)} safe history records to {destination}.", + data={ + "count": len(records), + "format": args.format, + "output": str(destination), + }, + ) + return _configs.Result( + "HISTORY_EXPORT", + encoded.rstrip("\n"), + data={"count": len(records), "format": args.format, "records": records}, + kind="info", + ) + if action == "status": + report = _history.status() + return _configs.Result( + "HISTORY_STATUS", _history_status_text(report), data=report, kind="info" + ) + if action == "check": + report = _history.check() + return _configs.Result( + "HISTORY_CHECK_OK" if report["ok"] else "HISTORY_CHECK_FAILED", + ( + "History database and safe records are valid." + if report["ok"] + else f"History check found {report['corruptRecords']} corrupt records." + ), + 0 if report["ok"] else 1, + data=report, + kind="success" if report["ok"] else "error", + ) + if action == "clear": + before = _history.parse_time(args.before) if args.before else None + plan = _history.clear(before=before, all_records=args.all, apply=False) + if args.dry_run or plan["count"] == 0: + return _configs.Result( + "HISTORY_CLEAR_PLAN", + f"Would remove {plan['count']} eligible safe history records; no changes made.", + data=plan, + kind="info", + ) + if args.yes: + _history.note_confirmation("yes-flag", "bypassed") + elif bool(getattr(args, "json", False)) or not sys.stdin.isatty(): + _history.note_confirmation("exact-yes", "unavailable") + return _configs.Result( + "CONFIRMATION_REQUIRED", + "History clear requires an interactive exact 'yes' or --yes.", + _configs.EXIT_CANCELLED, + "stderr", + data=plan, + ) + else: + answer = input( + f"History clear plan: remove {plan['count']} eligible records " + f"({plan['logicalBytes']} logical bytes).\n" + "Running commands and unresolved recovery records are preserved.\n" + "Type 'yes' exactly to continue: " + ) + accepted = answer.strip() == "yes" + _history.note_confirmation( + "exact-yes", "accepted" if accepted else "declined" + ) + if not accepted: + return _configs.Result( + "HISTORY_CLEAR_CANCELLED", + "History clear cancelled; no records were removed.", + _configs.EXIT_CANCELLED, + "stderr", + data=plan, + ) + applied = _history.clear(before=before, all_records=args.all, apply=True) + return _configs.Result( + "HISTORY_CLEAR", + f"Removed {applied['count']} eligible safe history records.", + data=applied, + ) + _print_help(("history",)) + return _configs.Result( + "HISTORY_HELP", "Choose a history action.", _configs.EXIT_USAGE, "stderr" + ) + + +def _console_main_invocation( + arguments: Sequence[str] | None = None, + *, + history_handle: _history.HistoryHandle | None = None, +) -> _configs.Result: raw_arguments = list(sys.argv[1:] if arguments is None else arguments) preselected_json = _json_requested(raw_arguments) _configs.configure_output(color="auto", json_output=preselected_json) @@ -2358,11 +2855,21 @@ def _console_main_invocation(arguments: Sequence[str] | None = None) -> _configs return _configs.Result( "ACCESS_TYPE_HELP", "Not enough arguments.", 2, "stderr" ).echo() + if ( + namespace.access_type == "mfa" + and namespace.action in {"login", "in"} + and namespace.target + and namespace.profile + and namespace.mfa_code is None + ): + namespace.mfa_code = namespace.profile + namespace.profile = None if namespace.access_type == "mfa" and namespace.action in {"login", "in"}: - if namespace.target and namespace.profile and namespace.mfa_code is None: - namespace.mfa_code = namespace.profile - namespace.profile = None - if namespace.mfa_code is None: + missing_code = namespace.mfa_code is None and not bool( + getattr(namespace, "mfa_code_stdin", False) + ) + missing_source = namespace.profile is None and not namespace.target + if missing_source or (missing_code and (use_json or not sys.stdin.isatty())): usage = parser.format_usage().strip() if not use_json: parser.print_usage(sys.stderr) @@ -2373,6 +2880,8 @@ def _console_main_invocation(arguments: Sequence[str] | None = None) -> _configs "stderr", {"usage": usage} if use_json else None, ).echo() + if history_handle is not None: + _history.enrich(history_handle, namespace) captured_stdout = io.StringIO() captured_stderr = io.StringIO() machine_stdin = _NonInteractiveStdin() @@ -2431,6 +2940,8 @@ def _console_main_invocation(arguments: Sequence[str] | None = None) -> _configs result = _run_policy(namespace) elif namespace.access_type == "cache": result = _run_cache(namespace) + elif namespace.access_type == "history": + result = _run_history(namespace) else: result = _run_config(namespace) except _configs.OperationalError as error: @@ -2448,8 +2959,18 @@ def _console_main_invocation(arguments: Sequence[str] | None = None) -> _configs def console_main(arguments: Sequence[str] | None = None) -> _configs.Result: """Run one isolated CLI invocation without leaking output mode to callers.""" + raw_arguments = list(sys.argv[1:] if arguments is None else arguments) + history_handle = _history.begin( + json_mode=_json_requested(raw_arguments), interactive=sys.stdin.isatty() + ) _configs.configure_output() try: - return _console_main_invocation(arguments) + result = _console_main_invocation(raw_arguments, history_handle=history_handle) + except BaseException as error: + _history.fail(history_handle, error) + raise + else: + _history.finish(history_handle, result) + return result finally: _configs.configure_output() diff --git a/hacksaws/_history.py b/hacksaws/_history.py new file mode 100644 index 0000000..4dfd10f --- /dev/null +++ b/hacksaws/_history.py @@ -0,0 +1,985 @@ +"""Credential-free, best-effort command history for Hacksaws invocations.""" + +from __future__ import annotations + +import contextlib +import fnmatch +import json +import re +import sqlite3 +import threading +import time +import uuid +from contextvars import ContextVar +from dataclasses import dataclass +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from typing import TYPE_CHECKING +from typing import TypedDict + +from hacksaws import _audit +from hacksaws import _state +from hacksaws._configs import OperationalError +from hacksaws._duration import parse_count +from hacksaws._duration import parse_duration + +if TYPE_CHECKING: + import argparse + from collections.abc import Iterator + +SCHEMA_VERSION = 1 +REDACTION_VERSION = 1 +DEFAULT_MAX_AGE = 90 * 24 * 60 * 60 +DEFAULT_MAX_ENTRIES = 10_000 +DEFAULT_MAX_BYTES = 50 * 1024 * 1024 +_MAINTENANCE_INTERVAL = 24 * 60 * 60 +_ABANDONED_AGE = 24 * 60 * 60 +_INTERRUPTED_EXIT_CODE = 130 +_IDENTIFIER = re.compile(r"^[\w+=,.@:/-]{1,1024}$", re.ASCII) +_COMMAND_SEGMENT = re.compile(r"^[a-z][a-z0-9-]{0,63}$") +_RESULT_CODE = re.compile(r"^[A-Z][A-Z0-9_]{0,63}$") +_LONG_AGO = re.compile( + r"^((?:[0-9]+(?:\.[0-9]+)?)|(?:\.[0-9]+))\s*" + r"(d|day|days|w|week|weeks)$", + re.IGNORECASE, +) +_current: ContextVar[str | None] = ContextVar("hacksaws_history_id", default=None) +_suspended: ContextVar[bool] = ContextVar("hacksaws_history_suspended", default=False) +_initialization_lock = threading.Lock() +_initialized_databases: set[Path] = set() + + +class HistoryError(RuntimeError): + """Raised internally when best-effort history cannot be recorded.""" + + +class HistorySettings(TypedDict): + """Validated history settings with built-in fallbacks.""" + + enabled: bool + max_age: int + max_entries: int + max_bytes: int + + +@dataclass(frozen=True, slots=True) +class HistoryHandle: + """One optional invocation record active in the current process.""" + + id: str | None + started_monotonic: float + enabled: bool + + +def root() -> Path: + """Return the contained history directory.""" + return _state.root() / "history" + + +def database_path() -> Path: + """Return the one SQLite history database path.""" + return root() / "history.db" + + +def _now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _settings() -> HistorySettings: + try: + value = _state.load_config().get("history", {}) + except OperationalError: + value = {} + enabled = value.get("enabled", True) + max_age = value.get("max_age", DEFAULT_MAX_AGE) + max_entries = value.get("max_entries", DEFAULT_MAX_ENTRIES) + max_bytes = value.get("max_bytes", DEFAULT_MAX_BYTES) + return { + "enabled": enabled if type(enabled) is bool else True, + "max_age": max_age if type(max_age) is int else DEFAULT_MAX_AGE, + "max_entries": ( + max_entries if type(max_entries) is int else DEFAULT_MAX_ENTRIES + ), + "max_bytes": max_bytes if type(max_bytes) is int else DEFAULT_MAX_BYTES, + } + + +def _secure(path: Path) -> None: + with contextlib.suppress(OSError): + path.chmod(0o600 if path.is_file() else 0o700) + + +def _connect() -> sqlite3.Connection: + directory = root() + directory.mkdir(parents=True, exist_ok=True) + _secure(directory) + path = database_path() + connection = sqlite3.connect(path, timeout=5.0) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout = 5000") + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA synchronous = NORMAL") + with _initialization_lock: + if path not in _initialized_databases: + try: + connection.execute("PRAGMA journal_mode = WAL") + _migrate(connection) + except (OSError, sqlite3.Error, HistoryError): + connection.close() + raise + _initialized_databases.add(path) + _secure(path) + return connection + + +@contextlib.contextmanager +def _database() -> Iterator[sqlite3.Connection]: + """Commit or roll back one transaction and always close its connection.""" + connection = _connect() + try: + with connection: + yield connection + finally: + connection.close() + + +def _migrate(connection: sqlite3.Connection) -> None: + version = int(connection.execute("PRAGMA user_version").fetchone()[0]) + if version > SCHEMA_VERSION: + raise HistoryError(_schema_too_new_message(version)) + if version == 0: + with connection: + connection.execute( + """ + CREATE TABLE invocations ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + ended_at TEXT, + state TEXT NOT NULL, + command TEXT NOT NULL, + alias_used TEXT, + json_mode INTEGER NOT NULL, + interactive INTEGER NOT NULL, + dry_run INTEGER NOT NULL DEFAULT 0, + profile TEXT, + location TEXT, + target TEXT, + account_id TEXT, + partition_name TEXT, + resource_kind TEXT, + resource_name TEXT, + resource_arn TEXT, + confirmation TEXT NOT NULL DEFAULT 'not-requested', + outcome TEXT, + result_code TEXT, + exit_code INTEGER, + duration_ms INTEGER, + safe_json TEXT NOT NULL DEFAULT '{}', + recovery_unresolved INTEGER NOT NULL DEFAULT 0, + size_bytes INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ) + """ + ) + connection.execute( + """ + CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invocation_id TEXT REFERENCES invocations(id) ON DELETE CASCADE, + occurred_at TEXT NOT NULL, + kind TEXT NOT NULL, + data_json TEXT NOT NULL DEFAULT '{}' + ) + """ + ) + connection.execute( + "CREATE INDEX invocation_started ON invocations(started_at DESC, id)" + ) + connection.execute( + "CREATE INDEX invocation_command " + "ON invocations(command, started_at DESC)" + ) + connection.execute( + "CREATE INDEX invocation_outcome " + "ON invocations(outcome, started_at DESC)" + ) + connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + + +def _schema_too_new_message(version: int) -> str: + return f"History schema {version} is newer than supported schema {SCHEMA_VERSION}." + + +def _canonical_command(args: argparse.Namespace) -> tuple[str, str | None]: + access = str(getattr(args, "access_type", "unknown") or "unknown") + alias = None + if access == "remote": + alias, access = access, "iam" + elif access == "web": + alias, access = access, "pk" + parts = [access] if _COMMAND_SEGMENT.fullmatch(access) else ["unknown"] + for field in ( + "action", + "iam_action", + "policy_action", + "policy_tag_action", + "role_command", + "role_tag_action", + "role_inline_action", + "role_trust_action", + "role_trust_kind", + "resource_action", + "cache_action", + "config_action", + "option_action", + "profile_action", + "history_action", + ): + value = getattr(args, field, None) + segment = str(value) if value is not None else "" + if _COMMAND_SEGMENT.fullmatch(segment) and segment not in parts: + parts.append(segment) + return ".".join(parts), alias + + +def _safe_identifier(value: object) -> str | None: + if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value): + return None + if not value.startswith("arn:") and _looks_like_file(value): + return None + return value + + +def _looks_like_file(value: str) -> bool: + return ( + value == "-" + or value.startswith((".", "~", "/", "\\")) + or "/" in value + or "\\" in value + or Path(value).suffix.casefold() in {".json", ".yaml", ".yml", ".toml", ".zip"} + ) + + +def _safe_namespace(args: argparse.Namespace) -> dict[str, object]: + values = vars(args) + flags = sorted( + key.replace("_", "-") + for key in ( + "all", + "all_account", + "allow_unmanaged", + "cascade", + "created", + "adopted", + "legacy", + "details", + "dry_run", + "inline", + "probe", + "remote", + "replace", + "smoke", + "verify", + "wide", + "yes", + ) + if values.get(key) is True + ) + enums: dict[str, str] = {} + for key in ("format", "metadata", "color"): + value = values.get(key) + if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,32}", value): + enums[key] = value + input_kinds: list[dict[str, str]] = [] + for key in ("file", "trust_policy", "metadata_file", "zip", "output"): + value = values.get(key) + if value is None: + continue + suffix = Path(str(value)).suffix.casefold().removeprefix(".") or "unknown" + input_kinds.append({"role": key.replace("_", "-"), "format": suffix}) + policy = values.get("policy") + if isinstance(policy, str) and _looks_like_file(policy): + suffix = Path(policy).suffix.casefold().removeprefix(".") or "unknown" + input_kinds.append({"role": "policy-file", "format": suffix}) + identifiers: dict[str, str] = {} + for key in ( + "profile", + "aws_account_name", + "location", + "target", + "account", + "region", + "resource_name", + "role", + "target_role", + "policy", + ): + value = values.get(key) + if key == "policy" and isinstance(value, str) and _looks_like_file(value): + continue + safe = _safe_identifier(value) + if safe is not None: + identifiers[key] = safe + return { + "flags": flags, + "enums": enums, + "identifiers": identifiers, + "inputKinds": input_kinds, + "secretPresence": { + "mfaCode": bool(values.get("mfa_code")), + "externalId": bool(values.get("external_id")), + }, + } + + +def begin(*, json_mode: bool, interactive: bool) -> HistoryHandle: + """Start one best-effort invocation without retaining argv.""" + _audit.reset_confirmation() + settings = _settings() + if settings["enabled"] is not True or _suspended.get(): + return HistoryHandle(id=None, started_monotonic=time.monotonic(), enabled=False) + identifier = uuid.uuid4().hex + started = _now() + try: + with _database() as connection: + connection.execute( + """ + INSERT INTO invocations ( + id, started_at, state, command, json_mode, interactive, updated_at + ) VALUES (?, ?, 'running', 'unknown', ?, ?, ?) + """, + (identifier, started, int(json_mode), int(interactive), started), + ) + _current.set(identifier) + return HistoryHandle( + id=identifier, started_monotonic=time.monotonic(), enabled=True + ) + except (OSError, sqlite3.Error, HistoryError): + return HistoryHandle(id=None, started_monotonic=time.monotonic(), enabled=False) + + +def enrich(handle: HistoryHandle, args: argparse.Namespace) -> None: + """Attach only validated, allowlisted parser metadata.""" + if not handle.enabled or handle.id is None: + return + command, alias = _canonical_command(args) + resource_kind = next( + ( + kind + for kind in ("policy", "role", "user", "group", "boundary", "target") + if kind in command.split(".") + ), + None, + ) + safe = _safe_namespace(args) + identifiers = safe["identifiers"] + if not isinstance(identifiers, dict): + return + try: + with _database() as connection: + connection.execute( + """ + UPDATE invocations SET + command = ?, alias_used = ?, dry_run = ?, profile = ?, + location = ?, target = ?, account_id = ?, resource_kind = ?, + safe_json = ?, updated_at = ? + WHERE id = ? + """, + ( + command, + alias, + int(bool(getattr(args, "dry_run", False))), + identifiers.get("profile"), + identifiers.get("aws_account_name") or identifiers.get("location"), + identifiers.get("target"), + identifiers.get("account"), + resource_kind, + json.dumps(safe, sort_keys=True, separators=(",", ":")), + _now(), + handle.id, + ), + ) + except (OSError, sqlite3.Error, HistoryError): + return + + +def note_confirmation( + mechanism: _audit.ConfirmationMechanism, + outcome: _audit.ConfirmationOutcome, +) -> None: + """Record semantic confirmation state without prompt or entered text.""" + _audit.note_confirmation(mechanism, outcome) + identifier = _current.get() + if identifier is None: + return + value = f"{mechanism}:{outcome}" + try: + with _database() as connection: + connection.execute( + "UPDATE invocations SET confirmation = ?, updated_at = ? WHERE id = ?", + (value, _now(), identifier), + ) + except (OSError, sqlite3.Error, HistoryError): + return + + +def note_mfa_code(*, source: str) -> None: + """Record only MFA presence and its allowlisted delivery mechanism.""" + if source not in {"argument", "stdin", "prompt"}: + return + identifier = _current.get() + if identifier is None: + return + try: + with _database() as connection: + row = connection.execute( + "SELECT safe_json FROM invocations WHERE id = ?", (identifier,) + ).fetchone() + if row is None: + return + with contextlib.suppress(json.JSONDecodeError): + safe = json.loads(row[0]) + if isinstance(safe, dict): + safe["mfaCodeProvided"] = True + safe["mfaCodeSource"] = source + connection.execute( + "UPDATE invocations SET safe_json = ?, updated_at = ? " + "WHERE id = ?", + ( + json.dumps(safe, sort_keys=True, separators=(",", ":")), + _now(), + identifier, + ), + ) + except (OSError, sqlite3.Error, HistoryError): + return + + +def _outcome(exit_code: int) -> str: + return { + 0: "success", + 2: "usage-error", + 3: "policy-refusal", + 4: "cancelled", + 130: "interrupted", + }.get(exit_code, "operational-error") + + +def _result_metadata(result: object) -> tuple[dict[str, object], dict[str, int]]: + data = getattr(result, "data", None) + raw_code = str(getattr(result, "code", "UNKNOWN")) + code = raw_code if _RESULT_CODE.fullmatch(raw_code) else "UNKNOWN" + selected: dict[str, object] = {} + metrics: dict[str, int] = {} + if isinstance(data, dict): + for key in ( + "accountId", + "partition", + "arn", + "name", + "role", + "policy", + "action", + ): + value = data.get(key) + safe_value = _safe_identifier(value) + if safe_value is not None: + selected[key] = safe_value + for key in ("count", "matched", "changed", "failed"): + value = data.get(key) + if type(value) is int and value >= 0: + metrics[key] = value + journal = data.get("journalId") + if isinstance(journal, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,64}", journal): + selected["journalId"] = journal + classification = data.get("classification") + if isinstance(classification, str) and re.fullmatch( + r"[a-z-]{1,32}", classification + ): + selected["classification"] = classification + selected["resultCode"] = code + return selected, metrics + + +def finish(handle: HistoryHandle, result: object) -> None: + """Atomically finalize one invocation using only positive-allowlist metadata.""" + if not handle.enabled or handle.id is None: + return + ended = _now() + exit_code = int(getattr(result, "exit_code", 1)) + selected, metrics = _result_metadata(result) + result_code = str(selected["resultCode"]) + safe: dict[str, object] = {"result": selected, "metrics": metrics} + resource_arn = selected.get("arn") or selected.get("role") + resource_name = selected.get("name") or selected.get("policy") + unresolved = ( + selected.get("classification") == "recovery-required" + or "RECOVERY_REQUIRED" in result_code + ) + state = "interrupted" if exit_code == _INTERRUPTED_EXIT_CODE else "completed" + try: + with _database() as connection: + row = connection.execute( + "SELECT safe_json, confirmation FROM invocations WHERE id = ?", + (handle.id,), + ).fetchone() + if row is not None: + with contextlib.suppress(json.JSONDecodeError): + parsed = json.loads(row[0]) + if isinstance(parsed, dict): + safe = {**parsed, **safe} + encoded = json.dumps(safe, sort_keys=True, separators=(",", ":")) + confirmation = row["confirmation"] if row is not None else "not-requested" + if confirmation == "not-requested": + confirmation = _audit.confirmation() + flags = safe.get("flags") + if ( + confirmation == "not-requested" + and isinstance(flags, list) + and "yes" in flags + ): + confirmation = "yes-flag:bypassed" + connection.execute( + """ + UPDATE invocations SET + ended_at = ?, state = ?, outcome = ?, result_code = ?, + exit_code = ?, duration_ms = ?, resource_name = ?, + resource_arn = ?, safe_json = ?, recovery_unresolved = ?, + account_id = COALESCE(?, account_id), + partition_name = COALESCE(?, partition_name), confirmation = ?, + size_bytes = ?, updated_at = ? + WHERE id = ? + """, + ( + ended, + state, + _outcome(exit_code), + result_code, + exit_code, + max(0, round((time.monotonic() - handle.started_monotonic) * 1000)), + resource_name, + resource_arn, + encoded, + int(unresolved), + selected.get("accountId"), + selected.get("partition"), + confirmation, + len(encoded.encode("utf-8")) + 512, + ended, + handle.id, + ), + ) + _maintain() + except (OSError, sqlite3.Error, HistoryError): + return + finally: + _current.set(None) + _audit.reset_confirmation() + + +def fail(handle: HistoryHandle, error: BaseException) -> None: + """Finalize an interrupted or crashed invocation without exception text.""" + if not handle.enabled or handle.id is None: + return + interrupted = isinstance(error, KeyboardInterrupt) + ended = _now() + try: + with _database() as connection: + connection.execute( + """ + UPDATE invocations SET ended_at = ?, state = ?, outcome = ?, + exit_code = ?, duration_ms = ?, safe_json = ?, size_bytes = ?, + confirmation = ?, updated_at = ? WHERE id = ? + """, + ( + ended, + "interrupted" if interrupted else "crashed", + "interrupted" if interrupted else "crashed", + 130 if interrupted else 1, + max(0, round((time.monotonic() - handle.started_monotonic) * 1000)), + "{}", + 512, + _audit.confirmation(), + ended, + handle.id, + ), + ) + except (OSError, sqlite3.Error, HistoryError): + return + finally: + _current.set(None) + _audit.reset_confirmation() + + +def _maintain() -> None: + settings = _settings() + with _database() as connection: + row = connection.execute( + "SELECT occurred_at FROM events WHERE invocation_id IS NULL " + "AND kind = 'retention' ORDER BY id DESC LIMIT 1" + ).fetchone() + now = datetime.now(UTC) + if row is not None: + previous = datetime.fromisoformat(str(row[0])) + if (now - previous).total_seconds() < _MAINTENANCE_INTERVAL: + return + abandoned_before = ( + (now - timedelta(seconds=_ABANDONED_AGE)).isoformat().replace("+00:00", "Z") + ) + connection.execute( + "UPDATE invocations SET state = 'abandoned', outcome = 'crashed', " + "ended_at = updated_at WHERE state = 'running' AND started_at < ?", + (abandoned_before,), + ) + cutoff = ( + (now - timedelta(seconds=int(settings["max_age"]))) + .isoformat() + .replace("+00:00", "Z") + ) + connection.execute( + "DELETE FROM invocations WHERE recovery_unresolved = 0 " + "AND state != 'running' AND started_at < ?", + (cutoff,), + ) + while True: + count, total = connection.execute( + "SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) FROM invocations " + "WHERE state != 'running'" + ).fetchone() + if count <= int(settings["max_entries"]) and total <= int( + settings["max_bytes"] + ): + break + deleted = connection.execute( + "DELETE FROM invocations WHERE id = (SELECT id FROM invocations " + "WHERE recovery_unresolved = 0 AND state != 'running' " + "ORDER BY started_at, id LIMIT 1)" + ).rowcount + if deleted == 0: + break + connection.execute( + "INSERT INTO events (invocation_id, occurred_at, kind, data_json) " + "VALUES (NULL, ?, 'retention', '{}')", + (_now(),), + ) + + +def _row_data(row: sqlite3.Row) -> dict[str, object]: + return { + "schemaVersion": SCHEMA_VERSION, + "redactionVersion": REDACTION_VERSION, + "id": row["id"], + "startedAt": row["started_at"], + "endedAt": row["ended_at"], + "state": row["state"], + "command": row["command"], + "aliasUsed": row["alias_used"], + "json": bool(row["json_mode"]), + "interactive": bool(row["interactive"]), + "dryRun": bool(row["dry_run"]), + "profile": row["profile"], + "location": row["location"], + "target": row["target"], + "accountId": row["account_id"], + "partition": row["partition_name"], + "resourceKind": row["resource_kind"], + "resourceName": row["resource_name"], + "resourceArn": row["resource_arn"], + "confirmation": row["confirmation"], + "outcome": row["outcome"], + "resultCode": row["result_code"], + "exitCode": row["exit_code"], + "durationMs": row["duration_ms"], + "safe": json.loads(row["safe_json"]), + "recoveryUnresolved": bool(row["recovery_unresolved"]), + } + + +def list_records( # noqa: PLR0913 + *, + patterns: tuple[str, ...] = (), + since: datetime | None = None, + until: datetime | None = None, + command: str | None = None, + outcome: str | None = None, + account: str | None = None, + resource: str | None = None, + limit: int = 50, + include_running: bool = False, +) -> list[dict[str, object]]: + """Return newest safe history records matching structured filters.""" + clauses = ["1 = 1"] + params: list[object] = [] + if not include_running: + clauses.append("state != 'running'") + for clause, value in ( + ( + "started_at >= ?", + since.isoformat().replace("+00:00", "Z") if since else None, + ), + ( + "started_at <= ?", + until.isoformat().replace("+00:00", "Z") if until else None, + ), + ("command LIKE ?", f"{command}%" if command else None), + ("outcome = ?", outcome), + ("account_id = ?", account), + ): + if value is not None: + clauses.append(clause) + params.append(value) + if resource: + clauses.append("(resource_name LIKE ? OR resource_arn LIKE ?)") + params.extend((f"%{resource}%", f"%{resource}%")) + params.append(max(1, min(limit, 10_000))) + try: + with _database() as connection: + rows = connection.execute( + "SELECT * FROM invocations WHERE " # noqa: S608 + + " AND ".join(clauses) + + " ORDER BY started_at DESC, id DESC LIMIT ?", + params, + ).fetchall() + except (OSError, sqlite3.Error, HistoryError) as error: + raise OperationalError(_read_error_message(error)) from error + values = [_row_data(row) for row in rows] + if not patterns: + return values + folded = tuple(pattern.casefold() for pattern in patterns) + return [ + item + for item in values + if any( + fnmatch.fnmatchcase( + " ".join( + str(item.get(key) or "") + for key in ( + "command", + "resultCode", + "accountId", + "resourceName", + "resourceArn", + "profile", + "target", + ) + ).casefold(), + pattern + if any(character in pattern for character in "*?[") + else f"*{pattern}*", + ) + for pattern in folded + ) + ] + + +def get_record(identifier: str) -> dict[str, object]: + """Resolve one full or unique-prefix invocation identifier.""" + if not re.fullmatch(r"[a-f0-9]{4,32}", identifier): + raise OperationalError(_invalid_id_message()) + with _database() as connection: + rows = connection.execute( + "SELECT * FROM invocations WHERE id LIKE ? ORDER BY id", (f"{identifier}%",) + ).fetchall() + if not rows: + raise OperationalError(_missing_id_message(identifier)) + if len(rows) > 1: + raise OperationalError(_ambiguous_id_message(identifier)) + return _row_data(rows[0]) + + +def command_template(record: dict[str, object]) -> str: + """Reconstruct a safe teaching template without inventing stored values.""" + command = str(record.get("command") or "unknown").replace(".", " ") + parts = ["hacksaws", command] + safe = record.get("safe") + safe_values = safe if isinstance(safe, dict) else {} + identifiers = safe_values.get("identifiers") + if isinstance(identifiers, dict): + parts.extend(_template_identifiers(identifiers)) + input_kinds = safe_values.get("inputKinds") + if isinstance(input_kinds, list): + parts.extend(_template_inputs(input_kinds)) + secret_presence = safe_values.get("secretPresence") + if isinstance(secret_presence, dict): + if secret_presence.get("externalId") is True: + parts.extend(("--external-id", "")) + if secret_presence.get("mfaCode") is True: + parts.append("") + return " ".join(parts) + + +def _template_identifiers(identifiers: dict[object, object]) -> list[str]: + parts: list[str] = [] + for key in ( + "profile", + "aws_account_name", + "location", + "target", + "account", + "region", + "resource_name", + "role", + "target_role", + "policy", + ): + value = identifiers.get(key) + if isinstance(value, str): + parts.extend((f"--{key.replace('_', '-')}", value)) + return parts + + +def _template_inputs(input_kinds: list[object]) -> list[str]: + parts: list[str] = [] + for value in input_kinds: + if not isinstance(value, dict): + continue + role = value.get("role") + if isinstance(role, str) and _COMMAND_SEGMENT.fullmatch(role): + parts.extend((f"--{role}", f"<{role}>")) + return parts + + +def status() -> dict[str, object]: + """Return database health and retention metadata.""" + settings = _settings() + try: + with _database() as connection: + integrity = str(connection.execute("PRAGMA quick_check").fetchone()[0]) + current = _current.get() + query = ( + "SELECT COUNT(*), MIN(started_at), MAX(started_at), " + "SUM(state = 'running'), COALESCE(SUM(size_bytes), 0) " + "FROM invocations" + ) + parameters: tuple[str, ...] = () + if current is not None: + query += " WHERE id != ?" + parameters = (current,) + row = connection.execute(query, parameters).fetchone() + except (OSError, sqlite3.Error, HistoryError) as error: + raise OperationalError(_inspect_error_message(error)) from error + return { + "database": str(database_path()), + "integrity": integrity, + "count": int(row[0]), + "oldest": row[1], + "newest": row[2], + "running": int(row[3] or 0), + "logicalBytes": int(row[4]), + "retention": settings, + } + + +def clear( + *, before: datetime | None, all_records: bool, apply: bool +) -> dict[str, object]: + """Plan or apply deletion of completed, resolved history records.""" + clauses = ["state != 'running'", "recovery_unresolved = 0"] + params: list[object] = [] + if not all_records: + if before is None: + raise OperationalError(_clear_selector_message()) + clauses.append("started_at < ?") + params.append(before.isoformat().replace("+00:00", "Z")) + where = " AND ".join(clauses) + with _database() as connection: + count, size = connection.execute( + f"SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) " # noqa: S608 + f"FROM invocations WHERE {where}", + params, + ).fetchone() + if apply: + connection.execute(f"DELETE FROM invocations WHERE {where}", params) # noqa: S608 + return {"count": int(count), "logicalBytes": int(size), "applied": apply} + + +def export_records(records: list[dict[str, object]], *, format_name: str) -> str: + """Serialize safe records deterministically for agents.""" + ordered = sorted( + records, key=lambda item: (str(item["startedAt"]), str(item["id"])) + ) + if format_name == "json": + return json.dumps(ordered, indent=2, sort_keys=True) + "\n" + return "".join(json.dumps(item, sort_keys=True) + "\n" for item in ordered) + + +def check() -> dict[str, object]: + """Validate schema and every stored record without exposing payloads.""" + report = status() + with _database() as connection: + corrupt = 0 + for row in connection.execute("SELECT safe_json FROM invocations"): + try: + value = json.loads(row[0]) + if not isinstance(value, dict): + corrupt += 1 + except json.JSONDecodeError: + corrupt += 1 + return { + **report, + "corruptRecords": corrupt, + "ok": report["integrity"] == "ok" and corrupt == 0, + } + + +def parse_time(value: str, *, now: datetime | None = None) -> datetime: + """Parse an ISO timestamp or a duration meaning that long ago.""" + selected_now = now or datetime.now(UTC) + try: + parsed = datetime.fromisoformat(value) + except ValueError: + match = _LONG_AGO.fullmatch(value.strip()) + if match is not None: + multiplier = ( + 7 * 24 * 60 * 60 + if match.group(2).casefold().startswith("w") + else 24 * 60 * 60 + ) + seconds = parse_count(match.group(1), multiplier) + else: + seconds = parse_duration(value) + return selected_now - timedelta(seconds=seconds) + return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + +def current_id() -> str | None: + """Return the active invocation ID for semantic hooks.""" + return _current.get() + + +@contextlib.contextmanager +def disabled() -> Iterator[None]: + """Temporarily suppress recursive recording in isolated internal operations.""" + current_token = _current.set(None) + suspended_token = _suspended.set(True) + try: + yield + finally: + _suspended.reset(suspended_token) + _current.reset(current_token) + + +def _read_error_message(error: BaseException) -> str: + return f"Unable to read command history: {error}" + + +def _inspect_error_message(error: BaseException) -> str: + return f"Unable to inspect command history: {error}" + + +def _invalid_id_message() -> str: + return "History ID must be 4-32 lowercase hexadecimal characters." + + +def _missing_id_message(identifier: str) -> str: + return f"History invocation {identifier!r} was not found." + + +def _ambiguous_id_message(identifier: str) -> str: + return f"History ID prefix {identifier!r} is ambiguous." + + +def _clear_selector_message() -> str: + return "History clear requires --before or --all." diff --git a/hacksaws/_iam_cleanup.py b/hacksaws/_iam_cleanup.py index c0b54a3..f255862 100644 --- a/hacksaws/_iam_cleanup.py +++ b/hacksaws/_iam_cleanup.py @@ -150,7 +150,11 @@ class InventoryQuery: patterns: tuple[str, ...] = () resource_types: frozenset[ResourceType] = frozenset() origins: frozenset[OwnershipOrigin] = frozenset( - {OwnershipOrigin.CREATED, OwnershipOrigin.ADOPTED} + { + OwnershipOrigin.CREATED, + OwnershipOrigin.ADOPTED, + OwnershipOrigin.LEGACY, + } ) owned_only: bool = True smoke_only: bool = False @@ -266,7 +270,9 @@ class CleanupOptions: patterns: tuple[str, ...] = () all_resources: bool = False resource_types: frozenset[ResourceType] = frozenset() - origins: frozenset[OwnershipOrigin] = frozenset() + origins: frozenset[OwnershipOrigin] = frozenset( + {OwnershipOrigin.CREATED, OwnershipOrigin.ADOPTED} + ) smoke_only: bool = False smoke_run_id: str | None = None cascade: bool = False @@ -396,6 +402,8 @@ def ownership_origin( return OwnershipOrigin.CREATED if value == OwnershipOrigin.ADOPTED.value: return OwnershipOrigin.ADOPTED + if value == OwnershipOrigin.LEGACY.value: + return OwnershipOrigin.LEGACY if value is None: return OwnershipOrigin.LEGACY return OwnershipOrigin.UNKNOWN @@ -498,7 +506,7 @@ def _summary_matches(name: str, arn: str, patterns: tuple[str, ...]) -> bool: @staticmethod def _role_item(role: roles.RoleSnapshot, *, details: bool) -> InventoryItem: tags = _tag_values(role.tags) - owned = tags.get(roles.MANAGED_TAG) == "true" + owned = role.owned dependencies: Mapping[str, tuple[str, ...]] = {} if details: dependencies = { @@ -1186,8 +1194,12 @@ def _role_steps( for index, operation in enumerate(plan.operations): identifier = _step_id(f"role-{role.role_id or role.name}", index) params = dict(operation.params) - if operation.action == "delete_role": - params["ExpectedRoleId"] = role.role_id + params["ExpectedRoleId"] = role.role_id + params["ExpectedOwnershipTags"] = dict(role.tags) + compensation_params = dict(operation.compensate_params or {}) + if compensation_params: + compensation_params["ExpectedRoleId"] = role.role_id + compensation_params["ExpectedOwnershipTags"] = dict(role.tags) result.append( CleanupStep( identifier, @@ -1195,7 +1207,7 @@ def _role_steps( operation.action, params, operation.compensate_action, - dict(operation.compensate_params or {}), + compensation_params, previous, operation.action == "delete_role", ) @@ -1270,14 +1282,27 @@ def append( identifier = _step_id( f"policy-{policy.policy_id or policy.name}", len(result) ) + forward_params = { + **dict(params), + "ExpectedPolicyId": policy.policy_id, + "ExpectedPolicyArn": policy.arn.value, + "ExpectedOwnershipTags": {tag.key: tag.value for tag in policy.tags}, + } + reverse_params = dict(compensation_params or {}) + if reverse_params: + reverse_params["ExpectedPolicyId"] = policy.policy_id + reverse_params["ExpectedPolicyArn"] = policy.arn.value + reverse_params["ExpectedOwnershipTags"] = { + tag.key: tag.value for tag in policy.tags + } result.append( CleanupStep( identifier, item.key, action, - dict(params), + forward_params, compensation, - dict(compensation_params or {}), + reverse_params, previous, irreversible, ) @@ -1338,7 +1363,7 @@ def append( ) append( "delete_policy", - {"PolicyArn": arn, "ExpectedPolicyId": policy.policy_id}, + {"PolicyArn": arn}, irreversible=True, ) return result, [] @@ -1614,14 +1639,42 @@ def _call(context: Any, action: str, params: Mapping[str, object]) -> None: request = dict(params) expected_role_id = request.pop("ExpectedRoleId", None) expected_policy_id = request.pop("ExpectedPolicyId", None) + expected_policy_arn = request.pop("ExpectedPolicyArn", None) + expected_tags = request.pop("ExpectedOwnershipTags", None) + if expected_tags is not None and not isinstance(expected_tags, Mapping): + raise OperationalError("Cleanup ownership checkpoint is invalid.") if expected_role_id is not None: current = context.iam.get_role(RoleName=request["RoleName"])["Role"] if current.get("RoleId") != expected_role_id: raise OperationalError("Role identity changed after cleanup planning.") + if expected_tags is not None: + live_tags = { + str(item.get("Key")): str(item.get("Value", "")) + for item in current.get("Tags", []) + if isinstance(item, Mapping) and item.get("Key") is not None + } + if live_tags != dict(expected_tags): + raise OperationalError( + "Role ownership tags changed after cleanup planning." + ) if expected_policy_id is not None: - current = context.iam.get_policy(PolicyArn=request["PolicyArn"])["Policy"] + policy_arn = expected_policy_arn or request.get("PolicyArn") + if not isinstance(policy_arn, str) or not policy_arn: + raise OperationalError("Cleanup policy identity checkpoint is invalid.") + current = context.iam.get_policy(PolicyArn=policy_arn)["Policy"] if current.get("PolicyId") != expected_policy_id: raise OperationalError("Policy identity changed after cleanup planning.") + if expected_tags is not None: + response = context.iam.list_policy_tags(PolicyArn=policy_arn) + live_tags = { + str(item.get("Key")): str(item.get("Value", "")) + for item in response.get("Tags", []) + if isinstance(item, Mapping) and item.get("Key") is not None + } + if live_tags != dict(expected_tags): + raise OperationalError( + "Policy ownership tags changed after cleanup planning." + ) getattr(context.iam, action)(**request) diff --git a/hacksaws/_iam_cli.py b/hacksaws/_iam_cli.py index 39ae4da..c216eea 100644 --- a/hacksaws/_iam_cli.py +++ b/hacksaws/_iam_cli.py @@ -7,7 +7,6 @@ import json import os import re -import sys from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -30,6 +29,7 @@ if TYPE_CHECKING: from collections.abc import Callable from collections.abc import Iterator + from collections.abc import Mapping class IamAdapter(Protocol): @@ -314,6 +314,14 @@ def _resource_filters(parser: argparse.ArgumentParser) -> None: action="store_true", help="Include resources explicitly adopted by Hacksaws.", ) + resources.add_argument( + "--legacy", + action="store_true", + help=( + "Include safely identified Hacksaws resources created before origin " + "tracking. Cleanup excludes them unless this selector is explicit." + ), + ) resources.add_argument( "--smoke", action="store_true", @@ -424,17 +432,22 @@ def _cleanup_types(args: argparse.Namespace) -> frozenset[_iam_cleanup.ResourceT def _cleanup_origins( - args: argparse.Namespace, + args: argparse.Namespace, *, include_legacy_by_default: bool = False ) -> frozenset[_iam_cleanup.OwnershipOrigin]: values: set[_iam_cleanup.OwnershipOrigin] = set() if args.created: values.add(_iam_cleanup.OwnershipOrigin.CREATED) if args.adopted: values.add(_iam_cleanup.OwnershipOrigin.ADOPTED) - return frozenset( - values - or {_iam_cleanup.OwnershipOrigin.CREATED, _iam_cleanup.OwnershipOrigin.ADOPTED} - ) + if getattr(args, "legacy", False): + values.add(_iam_cleanup.OwnershipOrigin.LEGACY) + defaults = { + _iam_cleanup.OwnershipOrigin.CREATED, + _iam_cleanup.OwnershipOrigin.ADOPTED, + } + if include_legacy_by_default: + defaults.add(_iam_cleanup.OwnershipOrigin.LEGACY) + return frozenset(values or defaults) def _inventory_text( @@ -579,8 +592,9 @@ def _inventory_progress_text(event: _iam_cleanup.InventoryProgress) -> str: def _inventory_query(args: argparse.Namespace) -> _iam_cleanup.InventoryQuery: origins = ( frozenset() - if args.all_account and not (args.created or args.adopted) - else _cleanup_origins(args) + if args.all_account + and not (args.created or args.adopted or getattr(args, "legacy", False)) + else _cleanup_origins(args, include_legacy_by_default=True) ) return _iam_cleanup.InventoryQuery( patterns=tuple(args.patterns), @@ -664,6 +678,128 @@ def _inventory_command_result(args: argparse.Namespace) -> _configs.Result: return result +def _cleanup_plan_data(plan: _iam_cleanup.CleanupPlan) -> dict[str, object]: + """Build a bounded cleanup review without policy/trust documents or raw params.""" + resources = [item.as_dict() for item in plan.resources] + operations = [ + { + "order": index, + "id": step.id, + "resource": step.resource_key, + "action": step.action, + "reversible": step.compensate_action is not None, + "irreversible": step.irreversible, + "prerequisites": list(step.prerequisites), + } + for index, step in enumerate(plan.steps, 1) + ] + dependency_counts = { + key: sum( + len(values) + for item in plan.resources + for name, values in item.dependencies.items() + if name == key + ) + for key in sorted({key for item in plan.resources for key in item.dependencies}) + } + return { + "classification": plan.classification.value, + "action": "cleanup", + "risk": "critical" if plan.resources else "none", + "verifiedIdentity": { + "accountId": plan.account_id, + "partition": plan.partition, + "callerArn": plan.caller_arn, + }, + "selection": { + "resources": resources, + "count": len(resources), + "origins": sorted({str(item["origin"]) for item in resources}), + "types": sorted({str(item["type"]) for item in resources}), + }, + "dependencies": dependency_counts, + "blockers": [item.as_dict() for item in plan.blockers], + "warnings": list(plan.warnings), + "operations": operations, + "journalExpected": plan.classification + is _iam_cleanup.PlanClassification.PLANNED, + "recovery": ( + "A credential-free journal will be written before the first AWS mutation; " + "irreversible identity deletions retain commit-point receipts." + if plan.resources + else "No journal is needed because no resources matched." + ), + "leaveNoTrace": { + "expectedAbsent": [item.key for item in plan.resources], + "localRecoveryJournalRetained": True, + }, + } + + +def _cleanup_plan_text(data: Mapping[str, object]) -> str: + """Render compact cleanup review text from the credential-free plan model.""" + identity = cast("Mapping[str, object]", data["verifiedIdentity"]) + selection = cast("Mapping[str, object]", data["selection"]) + resources = cast("list[Mapping[str, object]]", selection["resources"]) + lines = [ + f"PLAN — CLEANUP ({data['risk']} risk)", + f"Account: {identity['accountId']} ({identity['partition']})", + f"Caller: {identity['callerArn']}", + f"Resources selected: {selection['count']}", + ] + lines.extend( + f" - {item['type']} {item['name']} [{item['origin']}] {item['arn']}" + for item in resources + ) + dependencies = cast("Mapping[str, int]", data["dependencies"]) + populated = {key: count for key, count in dependencies.items() if count} + lines.append( + "Dependencies: " + + ( + ", ".join(f"{key}={count}" for key, count in populated.items()) + if populated + else "none" + ) + ) + operations = cast("list[Mapping[str, object]]", data["operations"]) + if operations: + lines.append("Ordered AWS operations:") + lines.extend( + f" {item['order']}. {item['action']} — {item['resource']} " + "[" + + ( + "irreversible" + if item["irreversible"] + else "reversible" + if item["reversible"] + else "one-way" + ) + + "]" + for item in operations + ) + blockers = cast("list[Mapping[str, object]]", data["blockers"]) + if blockers: + lines.append("Blockers:") + lines.extend(f" - {item['message']}" for item in blockers) + warnings = cast("list[str]", data["warnings"]) + if warnings: + lines.append("Warnings:") + lines.extend(f" - {item}" for item in warnings) + lines.append(str(data["recovery"])) + lines.append("No changes have been made.") + return "\n".join(_output.safe_terminal_text(line) for line in lines) + + +def _iam_console_url(partition: str) -> str: + """Return the partition-appropriate IAM console home link.""" + domain = { + "aws": "console.aws.amazon.com", + "aws-cn": "console.amazonaws.cn", + "aws-us-gov": "console.amazonaws-us-gov.com", + }.get(partition, "console.aws.amazon.com") + return f"https://{domain}/iam/home#/home" + + def cleanup_result( args: argparse.Namespace, context: IamCommandContext ) -> _configs.Result: @@ -690,49 +826,109 @@ def cleanup_result( ) service = _iam_cleanup.CleanupService(context) plan = service.plan(options) - plan_data = plan.as_dict() - if ( - args.dry_run - or plan.classification is not _iam_cleanup.PlanClassification.PLANNED - ): - blocked = plan.classification is _iam_cleanup.PlanClassification.BLOCKED + plan_data = _cleanup_plan_data(plan) + review = _cleanup_plan_text(plan_data) + if plan.classification is _iam_cleanup.PlanClassification.BLOCKED: + return _configs.Result( + "IAM_CLEANUP_BLOCKED", + review, + _configs.EXIT_POLICY, + "stderr", + {"plan": plan_data, "result": {"classification": "blocked"}}, + ) + if plan.classification is _iam_cleanup.PlanClassification.NO_MATCHES: + return _configs.Result( + "IAM_CLEANUP_NO_MATCHES", + "NO CHANGE — Cleanup matched no resources.\nNo changes have been made.", + data={ + "plan": plan_data, + "applied": {"operationsCompleted": 0, "resourcesDeleted": 0}, + "result": { + "classification": "no-change", + "journalId": None, + "leaveNoTrace": True, + }, + }, + ) + if args.dry_run: return _configs.Result( - "IAM_CLEANUP_PLAN", - "DRY RUN — cleanup plan\n" - + json.dumps(plan_data, indent=2) - + "\nNo AWS or local state was changed.", - 2 if blocked else 0, - "stderr" if blocked else "stdout", - plan_data, + "IAM_CLEANUP_DRY_RUN", + "DRY RUN\n" + review, + data={ + "plan": plan_data, + "result": { + "classification": "dry-run", + "journalId": None, + "changed": False, + }, + }, ) if not args.yes: if _configs.json_output_enabled() or not os.isatty(0): return _configs.Result( "IAM_CLEANUP_CONFIRMATION_REQUIRED", - "Cleanup requires --yes in non-interactive or JSON mode.", + review + "\nConfirmation required: rerun with --yes.", _configs.EXIT_CANCELLED, "stderr", - plan_data, + { + "plan": plan_data, + "result": {"classification": "confirmation-required"}, + }, ) - sys.stdout.write("Cleanup plan:\n" + json.dumps(plan_data, indent=2) + "\n") - if input("\nType exactly 'yes' to execute this plan:\n> ").strip() != "yes": + if ( + input(review + "\n\nType exactly 'yes' to execute this plan:\n> ").strip() + != "yes" + ): return _configs.Result( "IAM_CLEANUP_CANCELLED", - "Cleanup cancelled; no AWS changes were made.", + "CANCELLED — Cleanup declined. No changes have been made.", _configs.EXIT_CANCELLED, "stderr", - plan_data, + { + "plan": plan_data, + "result": {"classification": "cancelled"}, + }, ) outcome = service.execute(plan) - data = {"plan": plan_data, "result": outcome.as_dict()} + result_data = outcome.as_dict() + console_url = _iam_console_url(plan.partition) + result_data["consoleUrl"] = console_url + applied = { + "operationsPlanned": len(plan.steps), + "operationsCompleted": len(outcome.completed), + "resourcesSelected": len(plan.resources), + "resourcesDeleted": len(plan.resources) if outcome.lnt else 0, + "failures": list(outcome.failed), + "residue": list(outcome.remaining), + } + data = {"plan": plan_data, "applied": applied, "result": result_data} partial = outcome.classification is not _iam_cleanup.ResultClassification.CLEANED + message = ( + ( + f"Cleanup incomplete: {len(outcome.failed)} failed operation(s), " + f"{len(outcome.remaining)} residue item(s)." + ) + if partial + else f"Deleted {len(plan.resources)} IAM resource(s)." + ) + message += ( + f"\nApplied: {len(outcome.completed)}/{len(plan.steps)} ordered AWS " + "operation(s) completed." + f"\nFailures: {len(outcome.failed)}" + f"\nResidue: {len(outcome.remaining)}" + f"\nVerified: Leave No Trace {'succeeded' if outcome.lnt else 'not proven'}." + + ( + f"\nJournal: {outcome.journal_id} " + "(recovery receipt retained; use 'hacksaws iam recovery get')." + if outcome.journal_id + else "\nJournal: none." + ) + + "\nAWS Console: " + + console_url + ) return _configs.Result( "IAM_CLEANUP_PARTIAL" if partial else "IAM_CLEANUP_COMPLETE", - ( - "Cleanup completed with remaining resources." - if partial - else "Leave No Trace cleanup completed successfully." - ), + _output.safe_terminal_text(message), 2 if partial else 0, "stderr" if partial else "stdout", data, @@ -928,7 +1124,7 @@ def recovery_result(args: argparse.Namespace) -> _configs.Result: ) -def dispatch(args: argparse.Namespace) -> _configs.Result: # noqa: PLR0911 +def dispatch(args: argparse.Namespace) -> _configs.Result: # noqa: C901, PLR0911 """Dispatch recovery locally or hand verified context to the owning leaf adapter.""" if args.iam_action in {"recovery", "recover"}: return recovery_result(args) @@ -957,6 +1153,10 @@ def dispatch(args: argparse.Namespace) -> _configs.Result: # noqa: PLR0911 _configs.EXIT_USAGE, "stderr", ) + for adapter in candidates: + normalize = getattr(adapter, "normalize_arguments", None) + if normalize is not None: + normalize(args) context = IamCommandContext.create(args) for adapter in candidates: result = adapter.dispatch(args, context) diff --git a/hacksaws/_iam_managed_policies.py b/hacksaws/_iam_managed_policies.py index c89eb50..5b416a4 100644 --- a/hacksaws/_iam_managed_policies.py +++ b/hacksaws/_iam_managed_policies.py @@ -173,6 +173,23 @@ class PolicyKind(StrEnum): CUSTOMER_MANAGED = "customer-managed" +class OwnershipOrigin(StrEnum): + """Supported ownership origins persisted on current resources.""" + + CREATED = "created" + ADOPTED = "adopted" + LEGACY = "legacy" + + +class OwnershipStatus(StrEnum): + """Safety classification for the complete protected tag domain.""" + + CURRENT = "current" + LEGACY = "legacy" + UNOWNED = "unowned" + UNSAFE = "unsafe" + + class DiagnosticSeverity(StrEnum): """Severity of a local or AWS policy validation diagnostic.""" @@ -268,6 +285,99 @@ def as_request(self) -> dict[str, str]: return {"Key": self.key, "Value": self.value} +def _protected_tag_values(tags: Iterable[Tag]) -> dict[str, str] | None: + """Return case-folded protected tags, or ``None`` for duplicate keys.""" + values: dict[str, str] = {} + for tag in tags: + key = tag.key.casefold() + if key not in RESERVED_TAGS: + continue + if key in values: + return None + values[key] = tag.value + return values + + +def classify_ownership(tags: Iterable[Tag]) -> OwnershipStatus: # noqa: PLR0911 + """Classify current, safely legacy, truly unowned, and unsafe tag sets.""" + values = _protected_tag_values(tags) + if values is None: + return OwnershipStatus.UNSAFE + if not values: + return OwnershipStatus.UNOWNED + core = OWNERSHIP_TAGS + legacy_audit = RESERVED_TAGS - {"hacksaws:ownership-origin"} + migrated_core = core | {"hacksaws:ownership-origin"} + if set(values) not in {core, legacy_audit, migrated_core, RESERVED_TAGS}: + return OwnershipStatus.UNSAFE + if ( + values.get("hacksaws:managed-by") != "hacksaws" + or values.get("hacksaws:resource-kind") != "managed-policy" + or not values.get("hacksaws:resource-id") + ): + return OwnershipStatus.UNSAFE + if set(values) in {core, legacy_audit}: + if set(values) == legacy_audit and ( + not values.get("hacksaws:created-by") + or not values.get("hacksaws:created-at") + ): + return OwnershipStatus.UNSAFE + return OwnershipStatus.LEGACY + if set(values) == migrated_core: + return ( + OwnershipStatus.CURRENT + if values.get("hacksaws:ownership-origin") == OwnershipOrigin.LEGACY.value + else OwnershipStatus.UNSAFE + ) + if not values.get("hacksaws:created-by") or not values.get("hacksaws:created-at"): + return OwnershipStatus.UNSAFE + if values.get("hacksaws:ownership-origin") not in { + item.value for item in OwnershipOrigin + }: + return OwnershipStatus.UNSAFE + return OwnershipStatus.CURRENT + + +def ownership_origin(tags: Iterable[Tag]) -> OwnershipOrigin | None: + """Return a safe policy origin, inferring only complete legacy ownership.""" + status = classify_ownership(tags) + if status is OwnershipStatus.LEGACY: + return OwnershipOrigin.LEGACY + if status is not OwnershipStatus.CURRENT: + return None + values = _protected_tag_values(tags) + if values is None: # pragma: no cover - guaranteed by current status + return None + return OwnershipOrigin(values["hacksaws:ownership-origin"]) + + +def reconcile_owned_tags( + policy: ManagedPolicyRecord, user_tags: Sequence[Tag] = () +) -> tuple[Tag, ...]: + """Build exact owned-policy tags while preserving its immutable identity.""" + collisions = [tag.key for tag in user_tags if tag.key.casefold() in RESERVED_TAGS] + if collisions: + message = f"User tags cannot override reserved tags: {', '.join(collisions)}." + raise PolicyServiceError(message) + status = policy.ownership_status + if status is OwnershipStatus.UNOWNED: + message = ( + f"Policy {policy.arn.value} is not Hacksaws-owned; adopt it before " + "creating or updating it by name." + ) + raise PolicyServiceError(message) + if status is OwnershipStatus.UNSAFE: + message = ( + f"Policy {policy.arn.value} has conflicting or partial Hacksaws " + "ownership tags; repair or release those tags explicitly." + ) + raise PolicyServiceError(message) + protected = [tag for tag in policy.tags if tag.key.casefold() in RESERVED_TAGS] + if status is OwnershipStatus.LEGACY: + protected.append(Tag("hacksaws:ownership-origin", OwnershipOrigin.LEGACY.value)) + return (*user_tags, *protected) + + @dataclass(frozen=True, slots=True) class RepairAction: """Machine-readable proposed correction for a diagnostic.""" @@ -342,13 +452,21 @@ class ManagedPolicyRecord: @property def owned(self) -> bool: - """Return whether standard Hacksaws ownership tags are present.""" - values = {tag.key.casefold(): tag.value for tag in self.tags} - return ( - values.get("hacksaws:managed-by") == "hacksaws" - and values.get("hacksaws:resource-kind") == "managed-policy" - and bool(values.get("hacksaws:resource-id")) - ) + """Return whether the policy has a safe current or legacy identity.""" + return classify_ownership(self.tags) in { + OwnershipStatus.CURRENT, + OwnershipStatus.LEGACY, + } + + @property + def ownership_status(self) -> OwnershipStatus: + """Return the complete protected-tag safety classification.""" + return classify_ownership(self.tags) + + @property + def ownership_origin(self) -> OwnershipOrigin | None: + """Return the safe ownership origin, including inferred legacy origin.""" + return ownership_origin(self.tags) @dataclass(frozen=True, slots=True) @@ -441,9 +559,26 @@ class PolicyChangePlan: tags: tuple[Tag, ...] expected_default_version_id: str | None = None expected_digest: str | None = None + expected_tag_digest: str | None = None prune_version_id: str | None = None rollback_version_id: str | None = None validation: ValidationReport = ValidationReport() + before: PlannedPolicyState | None = None + after: PlannedPolicyState | None = None + + +@dataclass(frozen=True, slots=True) +class PlannedPolicyState: + """Credential-free semantic state used by previews and durable adapters.""" + + arn: str + policy_id: str | None + name: str + path: str + description: str | None + document: dict[str, JsonValue] + tags: tuple[Tag, ...] + default_version_id: str | None @dataclass(frozen=True, slots=True) @@ -522,6 +657,8 @@ class TagChangePlan: add: tuple[Tag, ...] remove: tuple[str, ...] expected_digest: str + before_tags: tuple[Tag, ...] = () + after_tags: tuple[Tag, ...] = () @dataclass(frozen=True, slots=True) @@ -1061,6 +1198,18 @@ def ownership_tags( raise PolicyValidationError(report) return tuple(result) + def reconciled_owned_tags( + self, + policy: ManagedPolicyRecord, + user_tags: Sequence[Tag] = (), + ) -> tuple[Tag, ...]: + """Return exact desired tags without ever rewriting owned identity tags.""" + result = reconcile_owned_tags(policy, user_tags) + report = ValidationReport(tuple(self._validate_tags(result))) + if not report.valid: + raise PolicyValidationError(report) + return tuple(result) + def list_policies( self, *, @@ -1291,6 +1440,47 @@ def _hydrate_policy( ) return replace(current, tags=tags, document=document, versions=versions) + @staticmethod + def _planned_state( # noqa: PLR0913 + *, + arn: str, + policy_id: str | None, + name: str, + path: str, + description: str | None, + document: dict[str, JsonValue], + tags: Sequence[Tag], + default_version_id: str | None, + ) -> PlannedPolicyState: + """Build a typed renderer/recovery checkpoint from semantic policy data.""" + return PlannedPolicyState( + arn, + policy_id, + name, + path, + description, + document, + tuple(tags), + default_version_id, + ) + + @classmethod + def _record_state(cls, record: ManagedPolicyRecord) -> PlannedPolicyState: + """Build a typed checkpoint from a fully hydrated policy record.""" + if record.document is None: + message = "Current managed policy document was not loaded." + raise PolicyServiceError(message) + return cls._planned_state( + arn=record.arn.value, + policy_id=record.policy_id, + name=record.name, + path=record.path, + description=record.description, + document=record.document, + tags=record.tags, + default_version_id=record.default_version_id, + ) + def plan_create( self, name: str, @@ -1348,6 +1538,20 @@ def plan_create( description=selected_options.description, tags=tags, validation=report, + before=None, + after=self._planned_state( + arn=( + f"arn:{self.partition}:iam::{self.account_id}:policy" + f"{selected_path}{name}" + ), + policy_id=None, + name=name, + path=selected_path, + description=selected_options.description, + document=document, + tags=tags, + default_version_id=None, + ), ) def plan_publish( @@ -1356,6 +1560,7 @@ def plan_publish( document: dict[str, JsonValue], *, include_aws_validation: bool = True, + planned_tags: Sequence[Tag] | None = None, ) -> PolicyChangePlan: """Plan a no-op or safely versioned customer-policy update.""" current = self.get_policy( @@ -1365,18 +1570,24 @@ def plan_publish( include_tags=True, ) self._require_mutable(current) + desired_tags = tuple(planned_tags) if planned_tags is not None else current.tags report = self.validate_policy( document, name=current.name, path=current.path, - tags=current.tags, + tags=desired_tags, include_aws=include_aws_validation, ) if current.document is None: message = "Current managed policy document was not loaded." raise PolicyServiceError(message) current_digest = policy_digest(current.document) - if current_digest == policy_digest(document): + document_changed = current_digest != policy_digest(document) + current_tag_digest = self._tag_digest(current.tags) + tags_changed = current_tag_digest != self._tag_digest(desired_tags) + before = self._record_state(current) + after = replace(before, document=document, tags=desired_tags) + if current_digest == policy_digest(document) and not tags_changed: operation = _operation_plan( ChangeAction.NOOP, f"Policy {current.arn.value} is semantically unchanged.", @@ -1392,13 +1603,16 @@ def plan_publish( tags=current.tags, expected_default_version_id=current.default_version_id, expected_digest=current_digest, + expected_tag_digest=current_tag_digest, validation=report, + before=before, + after=after, ) prune_id: str | None = None warnings: list[str] = [] steps: list[OperationStep] = [] - if len(current.versions) >= MAX_POLICY_VERSIONS: + if document_changed and len(current.versions) >= MAX_POLICY_VERSIONS: nondefault = [item for item in current.versions if not item.is_default] if not current.owned: repair = RepairAction( @@ -1457,26 +1671,78 @@ def plan_publish( ) ) ) - steps.append( - _new_step( - "CreatePolicyVersion", - { - "PolicyArn": current.arn.value, - "PolicyDocument": canonical_policy_json(document), - "SetAsDefault": True, - }, - compensation=Compensation( - "SetDefaultPolicyVersion", + if document_changed: + steps.append( + _new_step( + "CreatePolicyVersion", { "PolicyArn": current.arn.value, - "VersionId": current.default_version_id, + "PolicyDocument": canonical_policy_json(document), + "SetAsDefault": True, }, - ), + compensation=Compensation( + "SetDefaultPolicyVersion", + { + "PolicyArn": current.arn.value, + "VersionId": current.default_version_id, + }, + ), + ) ) + if tags_changed: + observed = {tag.key: tag.value for tag in current.tags} + wanted = {tag.key: tag.value for tag in desired_tags} + additions = tuple( + Tag(key, value) + for key, value in wanted.items() + if observed.get(key) != value + ) + removals = tuple(key for key in observed if key not in wanted) + if additions: + steps.append( + _new_step( + "TagPolicy", + { + "PolicyArn": current.arn.value, + "Tags": [tag.as_request() for tag in additions], + }, + compensation=Compensation( + "RestorePolicyTags", + {"Tags": [tag.as_request() for tag in current.tags]}, + ), + ) + ) + if removals: + steps.append( + _new_step( + "UntagPolicy", + { + "PolicyArn": current.arn.value, + "TagKeys": list(removals), + }, + compensation=Compensation( + "TagPolicy", + { + "PolicyArn": current.arn.value, + "Tags": [ + tag.as_request() + for tag in current.tags + if tag.key in set(removals) + ], + }, + ), + ) + ) + summary = ( + f"Publish a new default version and reconcile tags for {current.arn.value}." + if document_changed and tags_changed + else f"Publish a new default version for {current.arn.value}." + if document_changed + else f"Reconcile ownership and user tags for {current.arn.value}." ) operation = _operation_plan( ChangeAction.UPDATE, - f"Publish a new default version for {current.arn.value}.", + summary, steps, warnings=warnings, ) @@ -1487,11 +1753,14 @@ def plan_publish( path=current.path, document=document, description=None, - tags=current.tags, + tags=desired_tags, expected_default_version_id=current.default_version_id, expected_digest=current_digest, + expected_tag_digest=current_tag_digest, prune_version_id=prune_id, validation=report, + before=before, + after=after, ) def plan_rollback( @@ -1543,6 +1812,7 @@ def plan_rollback( f"Set {version_id} as default for {current.arn.value}.", steps, ) + before = self._record_state(current) return PolicyChangePlan( operation=operation, policy_arn=current.arn, @@ -1553,7 +1823,14 @@ def plan_rollback( tags=current.tags, expected_default_version_id=current.default_version_id, expected_digest=policy_digest(current.document), + expected_tag_digest=self._tag_digest(current.tags), rollback_version_id=version_id, + before=before, + after=replace( + before, + document=target_document, + default_version_id=version_id, + ), ) def execute_change(self, plan: PolicyChangePlan) -> PublishResult: @@ -1599,6 +1876,7 @@ def _execute_create( created.arn, expected_version=created.default_version_id, expected_digest=policy_digest(plan.document), + expected_tags=plan.tags, ) return PublishResult(ChangeAction.CREATE, verified, journal) @@ -1621,6 +1899,10 @@ def _assert_change_precondition( if ( current.default_version_id != plan.expected_default_version_id or policy_digest(current.document) != plan.expected_digest + or ( + plan.expected_tag_digest is not None + and self._tag_digest(current.tags) != plan.expected_tag_digest + ) ): message = ( f"Policy {current.arn.value} changed after planning; rebuild and " @@ -1636,36 +1918,37 @@ def _execute_update( current: ManagedPolicyRecord, journal: OperationJournal, ) -> PublishResult: - step_index = 0 - if plan.prune_version_id is not None: - prune_step = plan.operation.steps[step_index] + version_id = current.default_version_id + for step in plan.operation.steps: try: - self._iam.delete_policy_version( - PolicyArn=current.arn.value, - VersionId=plan.prune_version_id, - ) + if step.operation == "DeletePolicyVersion": + self._iam.delete_policy_version(**dict(step.parameters)) + elif step.operation == "CreatePolicyVersion": + response = self._iam.create_policy_version(**dict(step.parameters)) + version = _mapping( + response.get("PolicyVersion"), label="PolicyVersion" + ) + version_id = _string(version.get("VersionId"), label="VersionId") + elif step.operation == "TagPolicy": + self._iam.tag_policy(**dict(step.parameters)) + elif step.operation == "UntagPolicy": + self._iam.untag_policy(**dict(step.parameters)) + else: # pragma: no cover - plans are constructed internally + message = f"Unsupported policy update step {step.operation!r}." + raise PolicyServiceError(message) except ClientError as error: - journal.record(prune_step.step_id, StepState.FAILED, str(error)) + journal.record(step.step_id, StepState.FAILED, str(error)) raise - journal.record(prune_step.step_id, StepState.SUCCEEDED) - step_index += 1 - publish_step = plan.operation.steps[step_index] - try: - response = self._iam.create_policy_version( - PolicyArn=current.arn.value, - PolicyDocument=canonical_policy_json(plan.document), - SetAsDefault=True, + journal.record( + step.step_id, + StepState.SUCCEEDED, + version_id if step.operation == "CreatePolicyVersion" else None, ) - except ClientError as error: - journal.record(publish_step.step_id, StepState.FAILED, str(error)) - raise - version = _mapping(response.get("PolicyVersion"), label="PolicyVersion") - version_id = _string(version.get("VersionId"), label="VersionId") - journal.record(publish_step.step_id, StepState.SUCCEEDED, version_id) verified = self._verify_policy( current.arn, expected_version=version_id, expected_digest=policy_digest(plan.document), + expected_tags=plan.tags, ) return PublishResult(ChangeAction.UPDATE, verified, journal) @@ -1702,6 +1985,7 @@ def _verify_policy( *, expected_version: str, expected_digest: str, + expected_tags: Sequence[Tag] | None = None, ) -> ManagedPolicyRecord: last_detail = "policy was not visible" for delay in self.retry.delays: @@ -1726,6 +2010,10 @@ def _verify_policy( if ( current.default_version_id == expected_version and policy_digest(current.document) == expected_digest + and ( + expected_tags is None + or self._tag_digest(current.tags) == self._tag_digest(expected_tags) + ) ): return current last_detail = ( @@ -1792,35 +2080,68 @@ def plan_adopt( include_tags=True, ) self._require_mutable(policy) - values = {tag.key.casefold(): tag.value for tag in policy.tags} - manager = values.get("hacksaws:managed-by") - if manager is not None and manager != "hacksaws": - message = f"Policy is already managed by {manager!r}." + status = policy.ownership_status + if status is OwnershipStatus.UNSAFE: + values = _protected_tag_values(policy.tags) or {} + manager = values.get("hacksaws:managed-by") + if manager not in {None, "hacksaws"}: + message = ( + f"Policy is already managed by {manager!r}; release or repair " + "the conflicting ownership tags before adoption." + ) + raise PolicyServiceError(message) + message = ( + f"Policy {policy.arn.value} has conflicting or partial Hacksaws " + "ownership tags; repair or release those tags before adoption." + ) raise PolicyServiceError(message) - add = self.ownership_tags( - resource_id, - user_tags, - caller=caller, - ownership_origin="adopted", - ) - report = ValidationReport(tuple(self._validate_tags(add))) + if status is OwnershipStatus.UNOWNED: + desired = self.ownership_tags( + resource_id, + user_tags, + caller=caller, + ownership_origin=OwnershipOrigin.ADOPTED.value, + ) + existing = {tag.key: tag.value for tag in policy.tags} + desired = tuple( + Tag(key, value) + for key, value in { + **existing, + **{tag.key: tag.value for tag in desired}, + }.items() + ) + else: + existing = {tag.key: tag.value for tag in policy.tags} + existing.update({tag.key: tag.value for tag in user_tags}) + if status is OwnershipStatus.LEGACY: + existing["hacksaws:ownership-origin"] = OwnershipOrigin.LEGACY.value + desired = tuple(Tag(key, value) for key, value in existing.items()) + observed = {tag.key: tag.value for tag in policy.tags} + add = tuple(tag for tag in desired if observed.get(tag.key) != tag.value) + report = ValidationReport(tuple(self._validate_tags(desired))) if not report.valid: raise PolicyValidationError(report) - step = _new_step( - "TagPolicy", - { - "PolicyArn": policy.arn.value, - "Tags": [tag.as_request() for tag in add], - }, - compensation=Compensation( - "RestorePolicyTags", - {"Tags": [tag.as_request() for tag in policy.tags]}, - ), + steps = ( + ( + _new_step( + "TagPolicy", + { + "PolicyArn": policy.arn.value, + "Tags": [tag.as_request() for tag in add], + }, + compensation=Compensation( + "RestorePolicyTags", + {"Tags": [tag.as_request() for tag in policy.tags]}, + ), + ), + ) + if add + else () ) operation = _operation_plan( ChangeAction.ADOPT, f"Adopt {policy.arn.value} into Hacksaws ownership.", - (step,), + steps, ) return TagChangePlan( policy, @@ -1828,6 +2149,8 @@ def plan_adopt( add, (), self._tag_digest(policy.tags), + policy.tags, + desired, ) def plan_release(self, reference: str) -> TagChangePlan: @@ -1868,6 +2191,8 @@ def plan_release(self, reference: str) -> TagChangePlan: (), remove, self._tag_digest(policy.tags), + policy.tags, + tuple(tag for tag in policy.tags if tag.key not in set(remove)), ) def execute_tag_change(self, plan: TagChangePlan) -> MutationResult: diff --git a/hacksaws/_iam_policy_cli.py b/hacksaws/_iam_policy_cli.py index 2dbf442..27ff236 100644 --- a/hacksaws/_iam_policy_cli.py +++ b/hacksaws/_iam_policy_cli.py @@ -17,6 +17,8 @@ import sys import tempfile import uuid +from collections.abc import Mapping +from collections.abc import Sequence from dataclasses import asdict from dataclasses import replace from io import StringIO @@ -34,8 +36,10 @@ from hacksaws import _iam_recovery from hacksaws import _output from hacksaws import _policies +from hacksaws import _resource_input from hacksaws import _state from hacksaws._configs import OperationalError +from hacksaws._iam_managed_policies import RESERVED_TAGS from hacksaws._iam_managed_policies import AssumeRoleProbeOptions from hacksaws._iam_managed_policies import ChangeAction from hacksaws._iam_managed_policies import CreatePolicyOptions @@ -45,6 +49,8 @@ from hacksaws._iam_managed_policies import ImmutablePolicyError from hacksaws._iam_managed_policies import ManagedPolicyArn from hacksaws._iam_managed_policies import ManagedPolicyRecord +from hacksaws._iam_managed_policies import OperationStep +from hacksaws._iam_managed_policies import OwnershipStatus from hacksaws._iam_managed_policies import PackedPolicyProbeError from hacksaws._iam_managed_policies import PolicyChangePlan from hacksaws._iam_managed_policies import PolicyDeletionPlan @@ -57,7 +63,11 @@ from hacksaws._iam_managed_policies import PolicyValidationError from hacksaws._iam_managed_policies import PolicyVersionRecord from hacksaws._iam_managed_policies import Tag +from hacksaws._iam_managed_policies import TagChangePlan from hacksaws._iam_managed_policies import ValidationReport +from hacksaws._iam_managed_policies import classify_ownership +from hacksaws._iam_managed_policies import ownership_origin +from hacksaws._iam_managed_policies import reconcile_owned_tags from hacksaws._iam_policy_documents import InputMetadata from hacksaws._iam_policy_documents import JsonValue from hacksaws._iam_policy_documents import LoadedPolicyInput @@ -70,8 +80,6 @@ if TYPE_CHECKING: from collections.abc import Iterable - from collections.abc import Mapping - from collections.abc import Sequence from hacksaws._iam_cli import IamCommandContext @@ -138,20 +146,33 @@ def _selectors(parser: argparse.ArgumentParser, *, mutation: bool = False) -> No "--dry-run", action="store_true", default=argparse.SUPPRESS, - help="Validate and show the plan without changing AWS or local state.", + help=( + "Show credential-free identity, before/after, actions, dependencies, " + "warnings, and confirmation without changing AWS or local state." + ), ) safety.add_argument( "--yes", action="store_true", default=argparse.SUPPRESS, - help="Approve the displayed plan without prompting.", + help="Approve the exact displayed plan without prompting.", ) def _input_options(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--format", choices=_FORMAT_CHOICES) - parser.add_argument("--metadata", choices=_METADATA_CHOICES) - parser.add_argument("--metadata-file", type=Path) + parser.add_argument( + "--format", + choices=_FORMAT_CHOICES, + help="Explicit input document format when it cannot be inferred.", + ) + parser.add_argument( + "--metadata", + choices=_METADATA_CHOICES, + help="Input metadata layout: embedded, separate sidecar, or omitted.", + ) + parser.add_argument( + "--metadata-file", type=Path, help="Metadata sidecar file for the policy input." + ) parser.add_argument( "--local-validation-only", action="store_true", @@ -192,12 +213,20 @@ def register(parser: argparse.ArgumentParser) -> None: ) create.set_defaults(policy_action="create") create.add_argument( - "file", help="JSON, YAML, or TOML policy document, or '-' for stdin." + "policy_inputs", + nargs="*", + metavar="NAME_OR_FILE", + help="Policy NAME and FILE in either order; NAME may be omitted.", + ) + create.add_argument( + "--name", + dest="explicit_name", + help="Explicit IAM policy name; otherwise derive it from FILE.", ) create.add_argument( - "name", - nargs="?", - help="IAM policy name; defaults to a configured name derived from FILE.", + "--file", + dest="explicit_file", + help="Explicit JSON, YAML, or TOML policy document, or '-' for stdin.", ) create.add_argument("--description", help="Human-readable IAM policy description.") create.add_argument( @@ -239,9 +268,27 @@ def register(parser: argparse.ArgumentParser) -> None: _selectors(export) update = actions.add_parser("update", help="Publish a new policy version.") - update.add_argument("policy_or_file", nargs="?") - update.add_argument("file", nargs="?") - update.add_argument("--from-stored", metavar="NAME") + update.add_argument( + "policy_inputs", + nargs="*", + metavar="POLICY_OR_FILE", + help="Policy reference and FILE in either order, or metadata-bearing FILE.", + ) + update.add_argument( + "--policy", + dest="explicit_policy", + help="Explicit policy name or ARN; use with --file to resolve ambiguity.", + ) + update.add_argument( + "--file", + dest="explicit_file", + help="Explicit JSON/YAML/TOML policy document, or '-' for stdin.", + ) + update.add_argument( + "--from-stored", + metavar="NAME", + help="Publish the named policy from Hacksaws' local policy store.", + ) _input_options(update) _selectors(update, mutation=True) @@ -295,14 +342,55 @@ def register(parser: argparse.ArgumentParser) -> None: _selectors(tag_remove, mutation=True) adopt = actions.add_parser("adopt", help="Adopt a customer-managed policy.") - adopt.add_argument("policy") + adopt.add_argument("policy", help="Existing customer-managed policy name or ARN.") _tag_options(adopt) _selectors(adopt, mutation=True) release = actions.add_parser("release", help="Release Hacksaws ownership tags.") - release.add_argument("policy") + release.add_argument("policy", help="Managed policy name or ARN to release.") _selectors(release, mutation=True) +def normalize_arguments(args: argparse.Namespace) -> None: + """Resolve policy mutation inputs before creating any AWS client.""" + action = getattr(args, "policy_action", None) + if action == "create" and hasattr(args, "policy_inputs"): + resolved = _resource_input.resolve_name_file( + args.policy_inputs, + explicit_name=getattr(args, "explicit_name", None), + explicit_file=getattr(args, "explicit_file", None), + require_name=False, + ) + args.name = resolved.name + args.file = str(resolved.file) if resolved.file is not None else None + return + if action != "update" or not hasattr(args, "policy_inputs"): + return + values = list(args.policy_inputs) + explicit_policy = getattr(args, "explicit_policy", None) + explicit_file = getattr(args, "explicit_file", None) + if getattr(args, "from_stored", None): + if explicit_file is not None: + raise OperationalError("--from-stored cannot be combined with --file.") + if len(values) + int(explicit_policy is not None) > 1: + raise OperationalError( + "--from-stored accepts at most one policy reference; use --policy " + "to make it explicit." + ) + args.policy_or_file = explicit_policy or (values[0] if values else None) + args.file = None + return + resolved = _resource_input.resolve_name_file( + values, + explicit_name=explicit_policy, + explicit_file=explicit_file, + require_name=False, + name_label="POLICY", + name_option="--policy", + ) + args.policy_or_file = resolved.name + args.file = str(resolved.file) if resolved.file is not None else None + + def _service(context: IamCommandContext) -> IamManagedPolicyService: config = _state.load_config() owned_path = str(config.get("iam", {}).get("path", "/hacksaws/")) @@ -1488,6 +1576,8 @@ def _stored_policy(stored_name: str) -> LoadedPolicyInput: def _loaded_update(args: argparse.Namespace) -> tuple[str, LoadedPolicyInput]: + if hasattr(args, "policy_inputs") and not hasattr(args, "policy_or_file"): + normalize_arguments(args) if args.from_stored: loaded = _stored_policy(args.from_stored) reference = args.policy_or_file or loaded.metadata.name @@ -1496,8 +1586,8 @@ def _loaded_update(args: argparse.Namespace) -> tuple[str, LoadedPolicyInput]: "--from-stored cannot be combined with a policy file." ) elif args.file is not None: - reference = args.policy_or_file loaded = _load_from_file(args, args.file) + reference = args.policy_or_file or loaded.metadata.name elif args.policy_or_file is not None: loaded = _load_from_file(args, args.policy_or_file) reference = loaded.metadata.name @@ -1671,6 +1761,474 @@ def _diagnostic_text(report: ValidationReport) -> str: ) +def _tag_map(tags: Sequence[Tag]) -> dict[str, str]: + """Return deterministic tag keys with non-reversible value references.""" + return dict( + sorted( + (tag.key, f"sha256:{_state.digest(tag.value.encode('utf-8'))[:12]}") + for tag in tags + ) + ) + + +def _tag_delta(before: Sequence[Tag], after: Sequence[Tag]) -> dict[str, object]: + """Return exact changed keys without exposing potentially sensitive values.""" + old = _tag_map(before) + new = _tag_map(after) + return { + "before": old, + "after": new, + "added": {key: new[key] for key in new.keys() - old.keys()}, + "removed": {key: old[key] for key in old.keys() - new.keys()}, + "changed": { + key: {"before": old[key], "after": new[key]} + for key in old.keys() & new.keys() + if old[key] != new[key] + }, + } + + +def _document_summary(document: Mapping[str, JsonValue] | None) -> dict[str, object]: + """Summarize policy semantics without exposing the policy document.""" + if document is None: + return { + "exists": False, + "sha256": None, + "minifiedBytes": 0, + "statements": 0, + "allowStatements": 0, + "denyStatements": 0, + "actions": 0, + "resources": 0, + } + raw_statements = document.get("Statement", []) + statements = ( + [raw_statements] + if isinstance(raw_statements, Mapping) + else raw_statements + if isinstance(raw_statements, list) + else [] + ) + valid = [item for item in statements if isinstance(item, Mapping)] + + def values(item: Mapping[str, object], key: str) -> int: + value = item.get(key) + if isinstance(value, list): + return len(value) + return int(value is not None) + + minified = canonical_policy_json(cast("dict[str, JsonValue]", document)) + return { + "exists": True, + "sha256": policy_digest(cast("dict[str, JsonValue]", document)), + "minifiedBytes": len(minified.encode("utf-8")), + "statements": len(valid), + "allowStatements": sum(item.get("Effect") == "Allow" for item in valid), + "denyStatements": sum(item.get("Effect") == "Deny" for item in valid), + "actions": sum(values(item, "Action") for item in valid), + "resources": sum(values(item, "Resource") for item in valid), + } + + +def _policy_step_data(step: OperationStep) -> dict[str, object]: + """Return one bounded operation row without policy documents or raw params.""" + operation = step + params = step.parameters + detail: dict[str, object] = {} + if isinstance(params, Mapping): + if isinstance(params.get("VersionId"), str): + detail["versionId"] = params["VersionId"] + raw_tags = params.get("Tags") + if isinstance(raw_tags, list): + detail["tagKeys"] = sorted( + str(item.get("Key")) + for item in raw_tags + if isinstance(item, Mapping) and item.get("Key") is not None + ) + raw_keys = params.get("TagKeys") + if isinstance(raw_keys, list): + detail["tagKeys"] = sorted(str(item) for item in raw_keys) + return { + "id": operation.step_id, + "action": operation.operation, + "destructive": bool(operation.destructive), + "reversible": operation.compensation is not None, + "detail": detail, + } + + +def _policy_plan_data( + plan: PolicyChangePlan, + context: IamCommandContext, + diagnostics: list[dict[str, object]], + warnings: list[str], +) -> dict[str, object]: + """Build the stable, document-free policy mutation review model.""" + before = plan.before + after = plan.after + arn = ( + after.arn + if after is not None + else before.arn + if before is not None + else plan.policy_arn.value + if plan.policy_arn is not None + else ( + f"arn:{context.partition}:iam::{context.account_id}:policy" + f"{plan.path}{plan.name}" + ) + ) + before_tags = before.tags if before is not None else () + after_tags = after.tags if after is not None else plan.tags + before_origin = ownership_origin(before_tags) + after_origin = ownership_origin(after_tags) + before_document = before.document if before is not None else None + after_document = after.document if after is not None else plan.document + document_before = _document_summary(before_document) + document_after = _document_summary(after_document) + document_delta = { + "before": document_before, + "after": document_after, + "changed": document_before["sha256"] != document_after["sha256"], + } + action = plan.operation.action.value + risk = ( + "none" + if plan.operation.action is ChangeAction.NOOP + else "high" + if any(step.destructive for step in plan.operation.steps) + else "moderate" + if plan.operation.action in {ChangeAction.UPDATE, ChangeAction.ROLLBACK} + else "low" + ) + return { + "classification": ( + "no-change" + if plan.operation.action is ChangeAction.NOOP + else "blocked" + if not plan.validation.valid + else "planned" + ), + "action": action, + "risk": risk, + "verifiedIdentity": { + "accountId": context.account_id, + "partition": context.partition, + "callerArn": getattr(context, "arn", None), + }, + "resource": { + "type": "managed-policy", + "name": plan.name, + "arn": arn, + "policyId": ( + before.policy_id + if before is not None + else after.policy_id + if after is not None + else None + ), + "path": plan.path, + "ownershipBefore": classify_ownership(before_tags).value, + "ownershipAfter": classify_ownership(after_tags).value, + "originBefore": before_origin.value if before_origin else None, + "originAfter": after_origin.value if after_origin else None, + }, + "changes": { + "tags": _tag_delta(before_tags, after_tags), + "document": document_delta, + "prunedVersion": plan.prune_version_id, + }, + "operations": [_policy_step_data(step) for step in plan.operation.steps], + "dependencies": {}, + "blockers": [item for item in diagnostics if item.get("severity") == "error"], + "warnings": warnings, + "journalExpected": plan.operation.action is not ChangeAction.NOOP, + "recovery": ( + "A credential-free journal will be written before the first AWS mutation." + if plan.operation.action is not ChangeAction.NOOP + else "No journal is needed because no mutation is planned." + ), + } + + +def _ownership_plan_data( + plan: TagChangePlan, + context: IamCommandContext, + action: str, +) -> dict[str, object]: + """Adapt an ownership-only plan to the stable policy review model.""" + before_tags = plan.before_tags or plan.policy.tags + if plan.after_tags: + after_tags = plan.after_tags + else: + values = {tag.key: tag.value for tag in before_tags} + values.update({tag.key: tag.value for tag in plan.add}) + for key in plan.remove: + values.pop(key, None) + after_tags = tuple(Tag(key, value) for key, value in sorted(values.items())) + before_origin = ownership_origin(before_tags) + after_origin = ownership_origin(after_tags) + return { + "classification": "planned" if plan.operation.steps else "no-change", + "action": action, + "risk": "low" if plan.operation.steps else "none", + "verifiedIdentity": { + "accountId": context.account_id, + "partition": context.partition, + "callerArn": getattr(context, "arn", None), + }, + "resource": { + "type": "managed-policy", + "name": plan.policy.name, + "arn": plan.policy.arn.value, + "policyId": plan.policy.policy_id, + "path": plan.policy.path, + "ownershipBefore": classify_ownership(before_tags).value, + "ownershipAfter": classify_ownership(after_tags).value, + "originBefore": before_origin.value if before_origin else None, + "originAfter": after_origin.value if after_origin else None, + }, + "changes": { + "tags": _tag_delta(before_tags, after_tags), + "document": { + "before": _document_summary(None), + "after": _document_summary(None), + "changed": False, + }, + "prunedVersion": None, + }, + "operations": [_policy_step_data(step) for step in plan.operation.steps], + "dependencies": {}, + "blockers": [], + "warnings": list(plan.operation.warnings), + "journalExpected": bool(plan.operation.steps), + "recovery": ( + "A credential-free journal will be written before the first AWS mutation." + if plan.operation.steps + else "No journal is needed because ownership already matches." + ), + } + + +def _policy_delete_plan_data( + plan: PolicyDeletionPlan, + context: IamCommandContext, + *, + allow_unmanaged: bool, + remove_boundaries: bool, +) -> dict[str, object]: + """Build a bounded deletion review including dependency blockers.""" + policy = plan.policy + dependencies = _dependency_data(plan) + blockers: list[dict[str, str]] = [] + if not policy.owned and not allow_unmanaged: + blockers.append( + { + "code": "UNMANAGED_POLICY", + "message": ( + "Policy is not Hacksaws-owned; --allow-unmanaged is required." + ), + } + ) + if ( + plan.dependencies.boundary_users or plan.dependencies.boundary_roles + ) and not remove_boundaries: + blockers.append( + { + "code": "BOUNDARY_OPT_IN_REQUIRED", + "message": "Permissions-boundary removal requires --remove-boundaries.", + } + ) + if not plan.executable: + blockers.append( + { + "code": "CASCADE_REQUIRED", + "message": "Policy dependencies require --cascade before deletion.", + } + ) + origin = policy.ownership_origin + return { + "classification": "blocked" if blockers else "planned", + "action": "delete", + "risk": "critical", + "verifiedIdentity": { + "accountId": context.account_id, + "partition": context.partition, + "callerArn": getattr(context, "arn", None), + }, + "resource": { + "type": "managed-policy", + "name": policy.name, + "arn": policy.arn.value, + "policyId": policy.policy_id, + "path": policy.path, + "ownershipBefore": policy.ownership_status.value, + "ownershipAfter": "absent", + "originBefore": origin.value if origin else None, + "originAfter": None, + }, + "changes": { + "tags": _tag_delta(policy.tags, ()), + "document": { + "before": _document_summary(policy.document), + "after": _document_summary(None), + "changed": policy.document is not None, + }, + "prunedVersion": None, + }, + "operations": [_policy_step_data(step) for step in plan.operation.steps], + "dependencies": dependencies, + "blockers": blockers, + "warnings": list(plan.operation.warnings), + "journalExpected": not blockers, + "recovery": ( + "Deletion is irreversible after AWS accepts DeletePolicy; the journal " + "retains a commit-point receipt but will not recreate the identity." + ), + } + + +def _short_hash(value: object) -> str: + """Render a compact hash while preserving absent-state clarity.""" + return str(value)[:12] if value else "absent" + + +def _policy_plan_text(data: Mapping[str, object]) -> str: + """Render a compact policy mutation review without raw JSON or documents.""" + identity = cast("Mapping[str, object]", data["verifiedIdentity"]) + resource = cast("Mapping[str, object]", data["resource"]) + changes = cast("Mapping[str, object]", data["changes"]) + tags = cast("Mapping[str, object]", changes["tags"]) + document = cast("Mapping[str, object]", changes["document"]) + before_doc = cast("Mapping[str, object]", document["before"]) + after_doc = cast("Mapping[str, object]", document["after"]) + lines = [ + f"PLAN — {str(data['action']).upper()} ({data['risk']} risk)", + f"Account: {identity['accountId']} ({identity['partition']})", + f"Caller: {identity.get('callerArn') or 'unknown'}", + f"Policy: {resource['name']}", + f"ARN: {resource['arn']}", + f"Policy ID: {resource.get('policyId') or 'assigned by AWS on create'}", + ( + "Ownership: " + f"{resource['ownershipBefore']}/{resource.get('originBefore') or '-'}" + " → " + f"{resource['ownershipAfter']}/{resource.get('originAfter') or '-'}" + ), + ( + "Document: " + f"{_short_hash(before_doc.get('sha256'))} → " + f"{_short_hash(after_doc.get('sha256'))} " + f"({after_doc.get('minifiedBytes', 0)} bytes; " + f"{after_doc.get('statements', 0)} statements, " + f"{after_doc.get('allowStatements', 0)} allow, " + f"{after_doc.get('denyStatements', 0)} deny)" + ), + ] + added = cast("Mapping[str, object]", tags["added"]) + removed = cast("Mapping[str, object]", tags["removed"]) + changed = cast("Mapping[str, Mapping[str, object]]", tags["changed"]) + if added or removed or changed: + lines.append("Tag changes:") + lines.extend(f" + {key}={value}" for key, value in sorted(added.items())) + lines.extend(f" - {key}={value}" for key, value in sorted(removed.items())) + lines.extend( + f" ~ {key}: {value['before']} → {value['after']}" + for key, value in sorted(changed.items()) + ) + else: + lines.append("Tag changes: none") + operations = cast("list[Mapping[str, object]]", data["operations"]) + if operations: + lines.append("Ordered AWS operations:") + lines.extend( + f" {index}. {item['action']} " + f"[{'reversible' if item['reversible'] else 'irreversible'}]" + for index, item in enumerate(operations, 1) + ) + blockers = cast("list[Mapping[str, object]]", data["blockers"]) + if blockers: + lines.append("Blockers:") + lines.extend(f" - {item.get('message', item)}" for item in blockers) + dependencies = cast("Mapping[str, object]", data["dependencies"]) + populated = { + key: value + for key, value in dependencies.items() + if isinstance(value, list) and value + } + if populated: + lines.append("Dependencies:") + lines.extend( + f" - {key}: {', '.join(str(item) for item in value)}" + for key, value in sorted(populated.items()) + ) + warnings = cast("list[str]", data["warnings"]) + if warnings: + lines.append("Warnings:") + lines.extend(f" - {item}" for item in warnings) + lines.append(str(data["recovery"])) + lines.append("No changes have been made.") + return "\n".join(_output.safe_terminal_text(line) for line in lines) + + +def _confirmation_unavailable() -> bool: + """Return whether a mutation needs explicit ``--yes`` in this process.""" + return _configs.json_output_enabled() or not bool( + getattr(sys.stdin, "isatty", lambda: False)() + ) + + +def _policy_success_text( + plan_data: Mapping[str, object], + policy: ManagedPolicyRecord, + *, + journal_id: str | None, + console_url: str, +) -> str: + """Render a definitive verified policy result with only applied deltas.""" + action = str(plan_data["action"]) + verb = { + "create": "Created", + "update": "Updated", + "rollback": "Rolled back", + "noop": "No change —", + }.get(action, action.replace("-", " ").title() + "d") + changes = cast("Mapping[str, object]", plan_data["changes"]) + tags = cast("Mapping[str, object]", changes["tags"]) + document = cast("Mapping[str, object]", changes["document"]) + lines = [f"{verb} managed policy {policy.name}."] + applied: list[str] = [] + if document.get("changed"): + before = cast("Mapping[str, object]", document["before"]) + after = cast("Mapping[str, object]", document["after"]) + applied.append( + f"document {_short_hash(before.get('sha256'))} → " + f"{_short_hash(after.get('sha256'))}" + ) + tag_count = sum( + len(cast("Mapping[str, object]", tags[key])) + for key in ("added", "removed", "changed") + ) + if tag_count: + applied.append(f"{tag_count} tag delta(s)") + lines.append("Applied: " + (", ".join(applied) if applied else "none")) + lines.extend( + ( + f"ARN: {policy.arn.value}", + f"Policy ID: {policy.policy_id}", + f"Verified: IAM read-back matched version {policy.default_version_id}.", + ( + f"Journal: {journal_id} (completed; use 'hacksaws iam recovery get " + f"{journal_id}' for the recovery receipt)." + if journal_id + else "Journal: none (no mutation was required)." + ), + f"AWS Console: {console_url}", + ) + ) + return "\n".join(_output.safe_terminal_text(line) for line in lines) + + def _semantic_diff( service: IamManagedPolicyService, plan: PolicyChangePlan ) -> list[str]: @@ -1770,6 +2328,11 @@ def _change_states( if ( before.default_version_id != plan.expected_default_version_id or policy_digest(before.document) != plan.expected_digest + or ( + plan.expected_tag_digest is not None + and service._tag_digest(before.tags) # noqa: SLF001 + != plan.expected_tag_digest + ) ): raise PolicyDriftError( "Policy changed after planning; review the operation again." @@ -1779,7 +2342,14 @@ def _change_states( for version in before.versions if version.version_id != plan.prune_version_id ] - if plan.operation.action is ChangeAction.UPDATE: + publishes_version = any( + step.operation == "CreatePolicyVersion" for step in plan.operation.steps + ) or ( + plan.operation.action is ChangeAction.UPDATE + and plan.before is None + and plan.after is None + ) + if plan.operation.action is ChangeAction.UPDATE and publishes_version: for version in after_versions: version["default"] = False after_versions.append( @@ -1791,6 +2361,7 @@ def _change_states( dependencies = service.policy_dependencies(plan.policy_arn.value) after = _policy_state(before, dependencies=dependencies) after["versions"] = after_versions + after["tags"] = [tag.as_request() for tag in plan.tags] return after, _policy_state(before, dependencies=dependencies) @@ -1841,6 +2412,7 @@ def _repair( plan.policy_arn.value, document, include_aws_validation=not bool(getattr(args, "local_validation_only", False)), + planned_tags=plan.tags, ) @@ -1852,39 +2424,63 @@ def _execute_plan( ) -> _configs.Result: plan = _repair(plan, args, service) diagnostics = _diagnostic_data(plan.validation) - if not plan.validation.valid: - return _error( - "IAM_POLICY_VALIDATION_FAILED", - _diagnostic_text(plan.validation), - _configs.EXIT_POLICY, - ) warnings = [ item.message for item in plan.validation.diagnostics if item.severity is not DiagnosticSeverity.ERROR ] warnings.extend(plan.operation.warnings) - diff = _semantic_diff(service, plan) - preview = _change_preview(plan, context, diagnostics, warnings, diff) + preview = _policy_plan_data(plan, context, diagnostics, warnings) + review = _policy_plan_text(preview) + if not plan.validation.valid: + return _configs.Result( + "IAM_POLICY_VALIDATION_FAILED", + review, + _configs.EXIT_POLICY, + "stderr", + {"plan": preview, "result": {"classification": "blocked"}}, + ) if bool(getattr(args, "dry_run", False)): - data = {**preview, "dryRun": True} - message = ( - f"DRY RUN — {plan.operation.summary}\nNo AWS or local state was changed." + return _configs.Result( + "IAM_POLICY_DRY_RUN", + "DRY RUN\n" + review, + data={ + "plan": preview, + "result": { + "classification": "dry-run", + "journalId": None, + "changed": False, + }, + }, ) - if diff: - message += "\n" + "\n".join(diff) - return _configs.Result("IAM_POLICY_DRY_RUN", message, data=data) - confirmation = f"{plan.operation.summary}\nReview:\n" + json.dumps( - preview, indent=2, sort_keys=True - ) - if plan.operation.action is not ChangeAction.NOOP and not _confirm( - args, confirmation + if plan.operation.action is not ChangeAction.NOOP and not bool( + getattr(args, "yes", False) ): - return _error( - "IAM_POLICY_CANCELLED", - "Policy change cancelled; no AWS changes were made.", - _configs.EXIT_CANCELLED, - ) + if _confirmation_unavailable(): + return _configs.Result( + "IAM_POLICY_CONFIRMATION_REQUIRED", + review + "\nConfirmation required: rerun with --yes.", + _configs.EXIT_CANCELLED, + "stderr", + { + "plan": preview, + "result": {"classification": "confirmation-required"}, + }, + ) + answer = input( + review + "\n\nType exactly 'yes' to apply this plan:\n> " + ).strip() + if answer.casefold() != "yes": + return _configs.Result( + "IAM_POLICY_CANCELLED", + "CANCELLED — Policy change declined. No changes have been made.", + _configs.EXIT_CANCELLED, + "stderr", + { + "plan": preview, + "result": {"classification": "cancelled"}, + }, + ) if plan.operation.action is ChangeAction.NOOP: policy = service.get_policy( cast("ManagedPolicyArn", plan.policy_arn).value, @@ -1905,29 +2501,34 @@ def _execute_plan( include_versions=True, include_tags=True, ) - data = { + console_url = _console_url(context, policy.arn.value) + applied = cast("Mapping[str, object]", preview["changes"]) + result_data = { + "classification": ( + "no-change" if plan.operation.action is ChangeAction.NOOP else "applied" + ), "action": plan.operation.action.value, "arn": policy.arn.value, "name": policy.name, + "policyId": policy.policy_id, "version": policy.default_version_id, - "warnings": warnings, - "diagnostics": diagnostics, - "prunedVersion": plan.prune_version_id, - "diff": diff, "journalId": journal_id, - "consoleUrl": _console_url(context, policy.arn.value), - "preview": preview, + "verified": True, + "recoveryAvailable": journal_id is not None, + "consoleUrl": console_url, } - message = plan.operation.summary - if diff: - message += "\n" + "\n".join(diff) - if warnings: - message += "\n" + "\n".join(f"Warning: {item}" for item in warnings) - message += ( - f"\nARN: {policy.arn.value}\nPolicy ID: {policy.policy_id}" - f"\nAWS Console: {data['consoleUrl']}" + return _configs.Result( + "IAM_POLICY_NO_CHANGE" + if plan.operation.action is ChangeAction.NOOP + else "IAM_POLICY_CHANGED", + _policy_success_text( + preview, + policy, + journal_id=journal_id, + console_url=console_url, + ), + data={"plan": preview, "applied": applied, "result": result_data}, ) - return _configs.Result("IAM_POLICY_CHANGED", message, data=data) def _create( @@ -1952,16 +2553,30 @@ def _create( include_versions=True, include_tags=True, ) - current_user_tags = { - tag.key: tag.value - for tag in target.tags - if not tag.key.casefold().startswith(_RESERVED_PREFIX) - } - desired_user_tags = {tag.key: tag.value for tag in tags} + if target.ownership_status is OwnershipStatus.UNOWNED: + return _error( + "IAM_POLICY_COLLISION", + f"Policy {policy_name!r} already exists at {target.arn.value} but " + "is not Hacksaws-owned. Review it and run 'iam policy adopt' " + "before creating or updating it by name; --replace never adopts or " + "rewrites ownership identity.", + _configs.EXIT_POLICY, + ) + if target.ownership_status is OwnershipStatus.UNSAFE: + return _error( + "IAM_POLICY_OWNERSHIP_UNSAFE", + f"Policy {policy_name!r} has conflicting or partial Hacksaws " + "ownership tags. Repair or release those tags explicitly; no " + "document or identity changes were planned.", + _configs.EXIT_POLICY, + ) + desired_tags = reconcile_owned_tags(target, tags) same_document = target.document is not None and policy_digest( target.document ) == policy_digest(loaded.document) - same_tags = current_user_tags == desired_user_tags + same_tags = tuple(sorted((tag.key, tag.value) for tag in target.tags)) == tuple( + sorted((tag.key, tag.value) for tag in desired_tags) + ) same_attributes = (selected_path is None or selected_path == target.path) and ( (args.description is None and loaded.metadata.description is None) or (args.description or loaded.metadata.description) == target.description @@ -1979,7 +2594,7 @@ def _create( "version": target.default_version_id, }, ) - if not args.replace: + if not args.replace and not (same_document and same_attributes): return _error( "IAM_POLICY_COLLISION", f"Policy {policy_name!r} already exists at {target.arn.value}; " @@ -1992,27 +2607,8 @@ def _create( target.arn.value, loaded.document, include_aws_validation=not args.local_validation_only, + planned_tags=desired_tags, ) - desired_tags = tuple( - [ - tag - for tag in target.tags - if tag.key.casefold().startswith(_RESERVED_PREFIX) - ] - + list(tags) - ) - if tuple(sorted((tag.key, tag.value) for tag in plan.tags)) != tuple( - sorted((tag.key, tag.value) for tag in desired_tags) - ): - plan = replace( - plan, - tags=desired_tags, - operation=replace( - plan.operation, - action=ChangeAction.UPDATE, - summary=f"Replace policy {target.arn.value} document and tags.", - ), - ) else: plan = service.plan_create( policy_name, @@ -2336,8 +2932,33 @@ def _update( ) -> _configs.Result: reference, loaded = _loaded_update(args) arn = _reference(service, reference) + current = service.get_policy( + arn, include_document=False, include_versions=False, include_tags=True + ) + planned_tags: tuple[Tag, ...] | None = None + if current.arn.kind is PolicyKind.CUSTOMER_MANAGED: + if current.ownership_status is OwnershipStatus.UNOWNED: + return _error( + "IAM_POLICY_UNMANAGED", + "Policy is not Hacksaws-owned; review and adopt it before update.", + _configs.EXIT_POLICY, + ) + if current.ownership_status is OwnershipStatus.UNSAFE: + return _error( + "IAM_POLICY_OWNERSHIP_UNSAFE", + "Policy has conflicting or partial Hacksaws ownership tags; repair " + "or release them before update.", + _configs.EXIT_POLICY, + ) + user_tags = tuple( + tag for tag in current.tags if tag.key.casefold() not in RESERVED_TAGS + ) + planned_tags = reconcile_owned_tags(current, user_tags) plan = service.plan_publish( - arn, loaded.document, include_aws_validation=not args.local_validation_only + arn, + loaded.document, + include_aws_validation=not args.local_validation_only, + planned_tags=planned_tags, ) return _execute_plan(service, plan, args, context) @@ -2349,6 +2970,20 @@ def _edit( ) -> _configs.Result: arn = _reference(service, args.policy) exported = service.export_policy(arn) + if exported.policy.arn.kind is PolicyKind.CUSTOMER_MANAGED: + if exported.policy.ownership_status is OwnershipStatus.UNOWNED: + return _error( + "IAM_POLICY_UNMANAGED", + "Policy is not Hacksaws-owned; review and adopt it before editing.", + _configs.EXIT_POLICY, + ) + if exported.policy.ownership_status is OwnershipStatus.UNSAFE: + return _error( + "IAM_POLICY_OWNERSHIP_UNSAFE", + "Policy has conflicting or partial Hacksaws ownership tags; repair " + "or release them before editing.", + _configs.EXIT_POLICY, + ) selected = PolicyFormat(args.format) suffix = ".yaml" if selected is PolicyFormat.YAML else f".{selected.value}" with tempfile.TemporaryDirectory(prefix="hacksaws-edit-") as directory: @@ -2377,7 +3012,22 @@ def _edit( "Policy changed while the editor was open; no update was made." ) plan = service.plan_publish( - arn, loaded.document, include_aws_validation=not args.local_validation_only + arn, + loaded.document, + include_aws_validation=not args.local_validation_only, + planned_tags=( + reconcile_owned_tags( + exported.policy, + tuple( + tag + for tag in exported.policy.tags + if tag.key.casefold() not in RESERVED_TAGS + ), + ) + if exported.policy.arn.kind is PolicyKind.CUSTOMER_MANAGED + and exported.policy.owned + else None + ), ) return _execute_plan(service, plan, args, context) @@ -2444,29 +3094,26 @@ def _delete( ) -> _configs.Result: arn = _reference(service, args.policy) plan = service.plan_delete(arn, cascade=args.cascade) - if not plan.policy.owned and not args.allow_unmanaged: - return _error( - "IAM_POLICY_UNMANAGED", - "Refusing to delete an unmanaged policy; inspect it and repeat with " - "--allow-unmanaged.", - _configs.EXIT_POLICY, - ) - boundary_names = ( - *plan.dependencies.boundary_users, - *plan.dependencies.boundary_roles, - ) - if boundary_names and not args.remove_boundaries: - return _error( - "IAM_POLICY_BOUNDARIES", - "Policy is used as a permissions boundary. Removing boundary assignments " - "requires both --cascade and --remove-boundaries after review.", - _configs.EXIT_POLICY, - ) - if not plan.executable: - return _error( - "IAM_POLICY_DEPENDENCIES", - "Policy still has dependencies; use --cascade only after reviewing them.", + preview = _policy_delete_plan_data( + plan, + context, + allow_unmanaged=bool(args.allow_unmanaged), + remove_boundaries=bool(args.remove_boundaries), + ) + review = _policy_plan_text(preview) + if preview["classification"] == "blocked": + first_blocker = cast("list[Mapping[str, object]]", preview["blockers"])[0] + blocked_code = { + "UNMANAGED_POLICY": "IAM_POLICY_UNMANAGED", + "BOUNDARY_OPT_IN_REQUIRED": "IAM_POLICY_BOUNDARIES", + "CASCADE_REQUIRED": "IAM_POLICY_DEPENDENCIES", + }.get(str(first_blocker.get("code")), "IAM_POLICY_DELETE_BLOCKED") + return _configs.Result( + blocked_code, + review, _configs.EXIT_POLICY, + "stderr", + {"plan": preview, "result": {"classification": "blocked"}}, ) policy = service.get_policy( arn, include_document=True, include_versions=True, include_tags=True @@ -2491,67 +3138,80 @@ def _delete( raise PolicyDriftError( "Policy or dependencies changed after deletion planning; review again." ) - preview = { - "attachments": { - "users": [item.name for item in dependencies.permission_users], - "groups": [item.name for item in dependencies.permission_groups], - "roles": [item.name for item in dependencies.permission_roles], - }, - "permissionBoundaries": { - "users": [item.name for item in dependencies.boundary_users], - "roles": [item.name for item in dependencies.boundary_roles], - }, - "versions": [ - { - "id": version.version_id, - "default": version.is_default, - "digest": ( - policy_digest(version.document) if version.document else None - ), - } - for version in policy.versions - ], - } - confirmation = ( - f"{plan.operation.summary}\nExact deletion preview:\n" - f"{json.dumps(preview, indent=2, sort_keys=True)}\n" - ) if bool(getattr(args, "dry_run", False)): return _configs.Result( "IAM_POLICY_DELETE_DRY_RUN", - f"DRY RUN — {confirmation.rstrip()}\nNo AWS or local state was changed.", + "DRY RUN\n" + review, data={ - "dryRun": True, - "classification": "planned", - "arn": arn, - "cascade": args.cascade, - "removeBoundaries": args.remove_boundaries, - "dependencies": _dependency_data(plan), - "preview": preview, + "plan": preview, + "result": { + "classification": "dry-run", + "journalId": None, + "changed": False, + }, }, ) - if not _confirm_exact(args, confirmation, policy.name): - return _error( - "IAM_POLICY_CANCELLED", - "Policy deletion cancelled; no AWS changes were made.", - _configs.EXIT_CANCELLED, - ) + if not bool(getattr(args, "yes", False)): + if _confirmation_unavailable(): + return _configs.Result( + "IAM_POLICY_CONFIRMATION_REQUIRED", + review + "\nConfirmation required: rerun with --yes.", + _configs.EXIT_CANCELLED, + "stderr", + { + "plan": preview, + "result": {"classification": "confirmation-required"}, + }, + ) + if ( + input(review + f"\n\nType exactly {policy.name!r} to delete:\n> ").strip() + != policy.name + ): + return _configs.Result( + "IAM_POLICY_CANCELLED", + "CANCELLED — Policy deletion declined. No changes have been made.", + _configs.EXIT_CANCELLED, + "stderr", + { + "plan": preview, + "result": {"classification": "cancelled"}, + }, + ) journal_id = _durable_reconcile( context, "delete", _absent_state(arn, policy.name, policy.path), _policy_state(policy, dependencies=dependencies), ) + operation_count = len(plan.operation.steps) + console_url = _console_url(context, arn) + message = ( + f"Deleted managed policy {policy.name}." + f"\nApplied: {operation_count} ordered AWS operation(s); resource is absent." + f"\nARN: {arn}\nPolicy ID: {policy.policy_id}" + "\nVerified: IAM reported the policy absent." + f"\nJournal: {journal_id} (completed; irreversible deletion receipt retained)." + f"\nAWS Console: {console_url}" + ) + result_data = { + "classification": "deleted", + "action": "delete", + "arn": arn, + "name": policy.name, + "policyId": policy.policy_id, + "operationsCompleted": operation_count, + "journalId": journal_id, + "verifiedAbsent": True, + "recoveryAvailable": True, + "consoleUrl": console_url, + } return _configs.Result( "IAM_POLICY_DELETED", - confirmation.rstrip(), + _output.safe_terminal_text(message), data={ - "arn": arn, - "cascade": args.cascade, - "removeBoundaries": args.remove_boundaries, - "dependencies": _dependency_data(plan), - "preview": preview, - "journalId": journal_id, + "plan": preview, + "applied": preview["changes"], + "result": result_data, }, ) @@ -2740,23 +3400,49 @@ def _ownership( plan = service.plan_adopt(arn, uuid.uuid4().hex, user_tags=_tags(args.tag)) else: plan = service.plan_release(arn) + preview = _ownership_plan_data(plan, context, args.policy_action) + review = _policy_plan_text(preview) if bool(getattr(args, "dry_run", False)): return _configs.Result( "IAM_POLICY_OWNERSHIP_DRY_RUN", - f"DRY RUN — {plan.operation.summary}\nNo AWS or local state was changed.", + "DRY RUN\n" + review, data={ - "dryRun": True, - "action": args.policy_action, - "arn": arn, - "classification": plan.operation.action.value, + "plan": preview, + "result": { + "classification": "dry-run", + "journalId": None, + "changed": False, + }, }, ) - if not _confirm(args, plan.operation.summary): - return _error( - "IAM_POLICY_CANCELLED", - "Ownership change cancelled.", - _configs.EXIT_CANCELLED, - ) + if plan.operation.steps and not bool(getattr(args, "yes", False)): + if _confirmation_unavailable(): + return _configs.Result( + "IAM_POLICY_CONFIRMATION_REQUIRED", + review + "\nConfirmation required: rerun with --yes.", + _configs.EXIT_CANCELLED, + "stderr", + { + "plan": preview, + "result": {"classification": "confirmation-required"}, + }, + ) + if ( + input(review + "\n\nType exactly 'yes' to apply this plan:\n> ") + .strip() + .casefold() + != "yes" + ): + return _configs.Result( + "IAM_POLICY_CANCELLED", + "CANCELLED — Ownership change declined. No changes have been made.", + _configs.EXIT_CANCELLED, + "stderr", + { + "plan": preview, + "result": {"classification": "cancelled"}, + }, + ) current = service.get_policy( arn, include_document=True, include_versions=True, include_tags=True ) @@ -2774,21 +3460,47 @@ def _ownership( current, tags=tuple(Tag(key, value) for key, value in sorted(values.items())), ) - dependencies = service.policy_dependencies(arn) - journal_id = _durable_reconcile( - context, - args.policy_action, - _policy_state(desired, dependencies=dependencies), - _policy_state(current, dependencies=dependencies), + journal_id = None + if plan.operation.steps: + dependencies = service.policy_dependencies(arn) + journal_id = _durable_reconcile( + context, + args.policy_action, + _policy_state(desired, dependencies=dependencies), + _policy_state(current, dependencies=dependencies), + ) + result_data = { + "classification": "applied" if journal_id else "no-change", + "action": args.policy_action, + "arn": arn, + "policyId": desired.policy_id, + "owned": desired.owned, + "journalId": journal_id, + "verified": True, + "recoveryAvailable": journal_id is not None, + } + ownership_verb = {"adopt": "Adopted", "release": "Released"}[args.policy_action] + message = ( + f"{ownership_verb} ownership for managed policy {desired.name}." + if journal_id + else f"No change — managed policy {desired.name} ownership already matches." + ) + message += ( + f"\nARN: {arn}\nPolicy ID: {desired.policy_id}" + f"\nVerified: IAM read-back matched the planned tags." + + ( + f"\nJournal: {journal_id} (completed; recovery receipt available)." + if journal_id + else "\nJournal: none (no mutation was required)." + ) ) return _configs.Result( - "IAM_POLICY_OWNERSHIP_CHANGED", - plan.operation.summary, + "IAM_POLICY_OWNERSHIP_CHANGED" if journal_id else "IAM_POLICY_NO_CHANGE", + _output.safe_terminal_text(message), data={ - "arn": arn, - "action": args.policy_action, - "owned": desired.owned, - "journalId": journal_id, + "plan": preview, + "applied": preview["changes"], + "result": result_data, }, ) @@ -2797,6 +3509,7 @@ def dispatch( args: argparse.Namespace, context: IamCommandContext ) -> _configs.Result | None: """Dispatch one managed-policy leaf and normalize failures for JSON envelopes.""" + normalize_arguments(args) action = getattr(args, "policy_action", None) if action is None: return None diff --git a/hacksaws/_iam_role_cli.py b/hacksaws/_iam_role_cli.py index f5a1d7a..7969850 100644 --- a/hacksaws/_iam_role_cli.py +++ b/hacksaws/_iam_role_cli.py @@ -25,12 +25,15 @@ from botocore.exceptions import BotoCoreError from botocore.exceptions import ClientError +from hacksaws import _audit from hacksaws import _duration as duration_parser from hacksaws import _iam_managed_policies as managed from hacksaws import _iam_policy_cli as policy_cli from hacksaws import _iam_policy_documents as documents from hacksaws import _iam_recovery as recovery from hacksaws import _iam_roles as roles +from hacksaws import _mutation_view +from hacksaws import _resource_input from hacksaws import _state from hacksaws._configs import EXIT_CANCELLED from hacksaws._configs import EXIT_USAGE @@ -48,6 +51,7 @@ _ROLE_NAME = re.compile(r"^[\w+=,.@-]{1,64}$", re.ASCII) _PATHLIKE = re.compile(r"^(?:[A-Za-z]:[\\/]|[.~][\\/]|.*[\\/])") _POLICY_EXTENSIONS = {".json", ".yaml", ".yml", ".toml"} +_ARN_PART_COUNT = 6 _RECOVERY_SERVICE = "iam-role" _CREATE_ROLE_HANDLER = "create-role-with-receipt" @@ -121,13 +125,16 @@ def _add_selector_arguments( "--dry-run", action="store_true", default=argparse.SUPPRESS, - help="Validate and show the plan without changing AWS or local state.", + help=( + "Show credential-free identity, before/after, actions, dependencies, " + "warnings, and confirmation without changing AWS or local state." + ), ) safety.add_argument( "--yes", action="store_true", default=argparse.SUPPRESS, - help="Approve the displayed plan without prompting.", + help="Approve the exact displayed plan without prompting.", ) @@ -145,17 +152,34 @@ def _leaf( def _duration_arguments(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--duration", "--ttl") - parser.add_argument("--htl") - parser.add_argument("--mtl") - parser.add_argument("--stl") + parser.add_argument( + "--duration", + "--ttl", + help="Session lifetime, such as 15m, 1hour, or 600seconds.", + ) + parser.add_argument( + "--htl", help="Session lifetime in hours; decimals are allowed." + ) + parser.add_argument( + "--mtl", help="Session lifetime in minutes; decimals are allowed." + ) + parser.add_argument( + "--stl", help="Session lifetime in seconds; decimals are rounded to a second." + ) def _metadata_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( - "--metadata", choices=("nested", "sidecar", "none"), default="none" + "--metadata", + choices=("nested", "sidecar", "none"), + default="none", + help="Document metadata layout: embedded, separate sidecar, or omitted.", + ) + parser.add_argument( + "--sidecar", + type=Path, + help="Metadata sidecar path when --metadata=sidecar is selected.", ) - parser.add_argument("--sidecar", type=Path) def _export_arguments(parser: argparse.ArgumentParser) -> None: @@ -261,27 +285,56 @@ def register(parser: argparse.ArgumentParser) -> None: attach = _leaf( actions, "attach", help_text="Attach or publish a role policy.", mutation=True ) - attach.add_argument("role") - attach.add_argument("policy") - attach.add_argument("--inline", action="store_true") - attach.add_argument("--policy-name") - attach.add_argument("--path") + attach.add_argument("role", help="IAM role name or same-account role ARN.") + attach.add_argument( + "policy_input", + nargs="?", + help="Managed policy name/ARN or a local JSON/YAML/TOML policy file.", + ) + attach_input = attach.add_mutually_exclusive_group() + attach_input.add_argument( + "--policy", + dest="policy_reference", + metavar="NAME_OR_ARN", + help="Explicit managed policy name or ARN to attach.", + ) + attach_input.add_argument( + "--file", + dest="policy_file", + type=Path, + help="Explicit local policy file to publish and attach.", + ) + attach.add_argument( + "--inline", + action="store_true", + help="Store a local policy file inline on the role instead of publishing it.", + ) + attach.add_argument( + "--policy-name", + metavar="NAME", + help="Name derived from a local file when publishing or storing it inline.", + ) + attach.add_argument( + "--path", help="IAM path used when publishing a local managed policy." + ) _metadata_arguments(attach) detach = _leaf( actions, "detach", help_text="Detach a managed role policy.", mutation=True ) - detach.add_argument("role") - detach.add_argument("policy") + detach.add_argument("role", help="IAM role name or same-account role ARN.") + detach.add_argument("policy", help="Managed policy name or ARN to detach.") adopt = _leaf(actions, "adopt", help_text="Adopt an existing role.", mutation=True) - adopt.add_argument("role") - adopt.add_argument("--owner") - adopt.add_argument("--audit-id") + adopt.add_argument("role", help="Existing IAM role name or same-account ARN.") + adopt.add_argument("--owner", help="Hacksaws ownership label recorded as a tag.") + adopt.add_argument( + "--audit-id", help="External audit identifier recorded as a tag." + ) release = _leaf( actions, "release", help_text="Release a managed role.", mutation=True ) - release.add_argument("role") + release.add_argument("role", help="Managed IAM role name or same-account ARN.") tag = _leaf(actions, "tag", help_text="Manage role tags.") tag_actions = tag.add_subparsers(dest="role_tag_action") @@ -312,9 +365,21 @@ def register(parser: argparse.ArgumentParser) -> None: _export_arguments(inline_export) inline_put = inline_actions.add_parser("put") _add_selector_arguments(inline_put, mutation=True) - inline_put.add_argument("role") - inline_put.add_argument("policy") - inline_put.add_argument("file", type=Path) + inline_put.add_argument("role", help="IAM role name or same-account role ARN.") + inline_put.add_argument( + "policy_inputs", + nargs="*", + metavar="NAME_OR_FILE", + help="Inline policy name and document path, in either unambiguous order.", + ) + inline_put.add_argument( + "--policy-name", + dest="explicit_policy_name", + help="Explicit inline policy name; use with --file to resolve ambiguity.", + ) + inline_put.add_argument( + "--file", dest="explicit_file", type=Path, help="Explicit policy document." + ) _metadata_arguments(inline_put) inline_edit = inline_actions.add_parser("edit") _add_selector_arguments(inline_edit, mutation=True) @@ -330,10 +395,27 @@ def register(parser: argparse.ArgumentParser) -> None: for action in ("get", "set", "edit", "check"): item = trust_actions.add_parser(action) _add_selector_arguments(item, mutation=action in {"set", "edit"}) - item.add_argument("role") if action == "set": - item.add_argument("file", type=Path) + item.add_argument( + "trust_inputs", + nargs="*", + metavar="ROLE_OR_FILE", + help="Role name/ARN and trust document, in either unambiguous order.", + ) + item.add_argument( + "--role", + dest="explicit_role", + help="Explicit role name or ARN; use with --file to resolve ambiguity.", + ) + item.add_argument( + "--file", + dest="explicit_file", + type=Path, + help="Explicit JSON/YAML/TOML trust-policy document.", + ) _metadata_arguments(item) + else: + item.add_argument("role") if action == "check": item.add_argument("--probe", action="store_true") trust_export = trust_actions.add_parser("export") @@ -377,6 +459,61 @@ def register(parser: argparse.ArgumentParser) -> None: group.add_argument("group") +def normalize_arguments(args: argparse.Namespace) -> None: + """Resolve ambiguous role mutation inputs before creating any AWS client.""" + command = getattr(args, "role_command", None) + if command == "attach" and hasattr(args, "policy_input"): + positional = (args.policy_input,) if getattr(args, "policy_input", None) else () + reference_input = _resource_input.resolve_reference_or_file( + positional, + explicit_reference=getattr(args, "policy_reference", None), + explicit_file=getattr(args, "policy_file", None), + ) + args.policy = ( + str(reference_input.file) + if reference_input.file is not None + else reference_input.reference + ) + if reference_input.file is not None: + _require_local_file(reference_input.file, label="Policy") + elif ( + command == "inline-policy" + and getattr(args, "role_inline_action", None) == "put" + and hasattr(args, "policy_inputs") + ): + name_file_input = _resource_input.resolve_name_file( + args.policy_inputs, + explicit_name=getattr(args, "explicit_policy_name", None), + explicit_file=getattr(args, "explicit_file", None), + name_label="POLICY_NAME", + name_option="--policy-name", + ) + args.policy = name_file_input.name + args.file = name_file_input.file + _require_local_file(name_file_input.file, label="Inline policy") + elif ( + command == "trust" + and getattr(args, "role_trust_action", None) == "set" + and hasattr(args, "trust_inputs") + ): + trust_input = _resource_input.resolve_name_file( + args.trust_inputs, + explicit_name=getattr(args, "explicit_role", None), + explicit_file=getattr(args, "explicit_file", None), + name_label="ROLE", + name_option="--role", + ) + args.role = trust_input.name + args.file = trust_input.file + _require_local_file(trust_input.file, label="Trust policy") + + +def _require_local_file(path: Path | None, *, label: str) -> None: + if path is None or not path.is_file(): + message = f"{label} file {path!s} does not exist or is not a file." + raise OperationalError(message) + + def _service(context: IamCommandContext) -> roles.IamRoleService: return roles.IamRoleService(context.iam) @@ -820,37 +957,274 @@ def compensate( def _preview(plan: roles.MutationPlan) -> str: - lines = [f"Plan: {plan.kind}", "Resources:"] - lines.extend(f" - {resource}" for resource in plan.resources) - lines.append("AWS mutations:") - lines.extend( - f" - {operation.client}:{operation.action}" for operation in plan.operations + return _mutation_view.change_text(_role_plan_view(plan)) + + +def _role_plan_view(plan: roles.MutationPlan) -> _mutation_view.ChangeView: + """Adapt an executable role plan into the shared credential-free review view.""" + before = plan.before + after = plan.after + identity = after or before + resource = plan.resources[0] if plan.resources else "unknown" + arn = before.arn if before is not None else None + name = identity.name if identity is not None else resource.rsplit("/", 1)[-1] + if arn is None and resource.startswith("arn:") and ":role/" in resource: + arn = resource + account_id: str | None = None + partition: str | None = None + if arn is not None: + arn_parts = arn.split(":", 5) + if len(arn_parts) == _ARN_PART_COUNT: + partition = arn_parts[1] + account_id = arn_parts[4] + ownership: str | None = None + origin: str | None = None + if isinstance(after, roles.RoleSpec): + ownership = "managed" + origin = after.ownership_origin + elif isinstance(after, roles.RoleSnapshot): + ownership = after.ownership_status.value + origin = after.tags.get(roles.ORIGIN_TAG, "legacy" if after.owned else None) + elif before is not None: + ownership = before.ownership_status.value + origin = before.tags.get(roles.ORIGIN_TAG, "legacy" if before.owned else None) + + changes = list(_state_changes(before, after)) + changes.extend(_operation_changes(plan)) + changes = list(dict.fromkeys(changes)) + actions = tuple( + _mutation_view.ActionView( + operation.client, + operation.action, + _action_summary(operation, name), + operation.action.startswith(("delete_", "detach_", "untag_", "remove_")), + ) + for operation in plan.operations ) - lines.extend(f"Warning: {warning}" for warning in plan.warnings) - return "\n".join(lines) + dependencies = _role_dependencies(plan) + confirmation = "not required" + if plan.operations: + confirmation = ( + f"type role name {name!r}" + if plan.kind == "role-delete" + else "type exactly 'yes'" + ) + return _mutation_view.ChangeView( + operation=plan.kind, + resource_type="IAM role", + name=name, + arn=arn, + account_id=account_id, + partition=partition, + path=identity.path if identity is not None else None, + ownership=ownership, + origin=origin, + classification="planned" if plan.operations else "no-change", + before_exists=before is not None or plan.kind != "role-create", + after_exists=plan.kind != "role-delete", + changes=tuple(changes), + actions=actions, + dependencies=dependencies, + warnings=plan.warnings, + confirmation=confirmation, + ) + + +def _state_changes( + before: roles.RoleSnapshot | None, + after: roles.RoleSpec | roles.RoleSnapshot | None, +) -> tuple[_mutation_view.FieldChange, ...]: + if before is None and after is None: + return () + before_values = _role_state_values(before) + after_values = _role_state_values(after) + fields = tuple(dict.fromkeys((*before_values, *after_values))) + return tuple( + _mutation_view.FieldChange( + field, before_values.get(field), after_values.get(field) + ) + for field in fields + if before_values.get(field) != after_values.get(field) + ) + + +def _role_state_values( + state: roles.RoleSpec | roles.RoleSnapshot | None, +) -> dict[str, _mutation_view.Scalar]: + if state is None: + return {} + tags = dict(state.tags) + if isinstance(state, roles.RoleSpec): + tags.update(roles.ownership_tags(state)) + values: dict[str, _mutation_view.Scalar] = { + "path": state.path, + "description": state.description, + "maximum session seconds": state.max_session_duration, + "permissions boundary": state.permissions_boundary, + "trust document SHA-256": roles.document_hash(state.trust), + "tags": ", ".join( + f"{key}=sha256:{_state.digest(tags[key].encode('utf-8'))}" + for key in sorted(tags) + ), + } + if isinstance(state, roles.RoleSnapshot): + values.update( + { + "attached policies": ", ".join(sorted(state.attached_policies)), + "inline policies": ", ".join(sorted(state.inline_policies)), + "instance profiles": ", ".join(sorted(state.instance_profiles)), + } + ) + return values + + +def _operation_changes( + plan: roles.MutationPlan, +) -> tuple[_mutation_view.FieldChange, ...]: + """Expose exact scalar operation effects when a plan has no typed snapshots.""" + changes: list[_mutation_view.FieldChange] = [] + for operation in plan.operations: + params = operation.params + action = operation.action + if action in {"attach_role_policy", "detach_role_policy"}: + policy = str(params.get("PolicyArn", "unknown")) + changes.append( + _mutation_view.FieldChange( + f"attached policy {policy}", + action == "detach_role_policy", + action == "attach_role_policy", + ) + ) + elif action in {"put_role_policy", "delete_role_policy"}: + policy = str(params.get("PolicyName", "unknown")) + changes.append( + _mutation_view.FieldChange( + f"inline policy {policy}", + action == "delete_role_policy", + action == "put_role_policy", + ) + ) + elif action == "update_assume_role_policy": + document = params.get("PolicyDocument") + digest = ( + roles.document_hash(json.loads(document)) + if isinstance(document, str) + else "updated" + ) + changes.append( + _mutation_view.FieldChange( + "trust document SHA-256", plan.expected.get("trust"), digest + ) + ) + elif action == "tag_role": + for item in params.get("Tags", []): + if isinstance(item, Mapping): + key = str(item.get("Key", "unknown")) + changes.append( + _mutation_view.FieldChange( + f"tag {key} value SHA-256", + None, + _state.digest(str(item.get("Value", "")).encode("utf-8")), + ) + ) + elif action == "untag_role": + changes.extend( + _mutation_view.FieldChange(f"tag {key}", "set", None) + for key in params.get("TagKeys", []) + ) + return tuple(changes) + + +def _action_summary(operation: roles.Operation, role_name: str) -> str: + label = operation.action.replace("_", " ") + params = operation.params + target = next( + ( + str(params[key]) + for key in ( + "PolicyArn", + "PolicyName", + "GroupName", + "UserName", + "InstanceProfileName", + ) + if params.get(key) + ), + role_name, + ) + return f"{label} for {target}" + + +def _role_dependencies( + plan: roles.MutationPlan, +) -> tuple[_mutation_view.DependencyView, ...]: + dependencies: list[_mutation_view.DependencyView] = [] + before = plan.before + if before is not None: + treatment = ( + "removed before role deletion" + if plan.kind == "role-delete" + else "preserved" + ) + dependencies.extend( + _mutation_view.DependencyView("attached policy", item, treatment) + for item in before.attached_policies + ) + dependencies.extend( + _mutation_view.DependencyView("inline policy", item, treatment) + for item in before.inline_policies + ) + dependencies.extend( + _mutation_view.DependencyView("instance profile", item, treatment) + for item in before.instance_profiles + ) + if before.permissions_boundary: + dependencies.append( + _mutation_view.DependencyView( + "permissions boundary", before.permissions_boundary, treatment + ) + ) + dependencies.extend( + _mutation_view.DependencyView("related resource", item, "used by this plan") + for item in plan.resources[1:] + ) + return tuple(dependencies) ensure_role_recovery_handlers() def _confirm_plan(args: argparse.Namespace, plan: roles.MutationPlan) -> bool: - if not plan.operations or bool(getattr(args, "yes", False)): + if not plan.operations: + return True + if bool(getattr(args, "yes", False)): + _audit.note_confirmation("yes-flag", "bypassed") return True if bool(getattr(args, "json", False)) or not sys.stdin.isatty(): + if plan.kind == "role-delete": + _audit.note_confirmation("resource-name", "unavailable") + else: + _audit.note_confirmation("exact-yes", "unavailable") return False if plan.kind == "role-delete": role_name = plan.resources[0].rsplit("/", maxsplit=1)[-1] - return ( + accepted = ( _input( f"{_preview(plan)}\nType the role name {role_name!r} to confirm the " "irreversible delete commit: " ).strip() == role_name ) - return ( + _audit.note_confirmation( + "resource-name", "accepted" if accepted else "declined" + ) + return accepted + accepted = ( _input(f"{_preview(plan)}\nType 'yes' to apply this exact plan: ").strip() == "yes" ) + _audit.note_confirmation("exact-yes", "accepted" if accepted else "declined") + return accepted def _assert_preconditions(plan: roles.MutationPlan, context: IamCommandContext) -> None: @@ -907,6 +1281,7 @@ def _execute( args: argparse.Namespace, ) -> recovery.IamJournal | None: plan = _materialize_managed_operations(plan, context) + args._mutation_plan = plan # noqa: SLF001 if bool(getattr(args, "dry_run", False)): raise _DryRunCompletedError(plan) if not _confirm_plan(args, plan): @@ -944,6 +1319,7 @@ def _execute( plan.kind, partition=context.partition, ) + args._mutation_journal_id = journal.id # noqa: SLF001 for handler, forward, compensation in prepared: journal.record_before_mutation( handler, forward=forward, compensation=compensation @@ -1996,8 +2372,46 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | if current is None: plan = roles.plan_create_role(spec) else: + if current.ownership_status is roles.OwnershipStatus.UNOWNED: + warning = ( + "The existing role is not Hacksaws-owned; adopt it before " + "requesting managed changes." + ) + args._mutation_plan = roles.MutationPlan( # noqa: SLF001 + "role-create", + (current.arn,), + (), + before=current, + after=spec, + warnings=(warning,), + ) + return Result( + "IAM_ROLE_COLLISION", + f"CONFLICT — IAM role {role_name} already exists but is not " + "Hacksaws-owned. Review it and run 'iam role adopt' first; " + "--replace never adopts or rewrites ownership identity.", + EXIT_USAGE, + "stderr", + { + "classification": "conflict", + "role": role_name, + "arn": current.arn, + }, + ) + if current.ownership_status is roles.OwnershipStatus.UNSAFE: + raise OperationalError( + f"Role {role_name!r} has conflicting or partial Hacksaws " + "ownership tags; repair or release them explicitly." + ) + spec = replace( + spec, + owner=current.tags[roles.OWNER_TAG], + audit_id=current.tags.get(roles.AUDIT_TAG), + ownership_origin=current.tags.get(roles.ORIGIN_TAG, "legacy"), + ) plan = roles.plan_update_role(current, spec) if not plan.operations: + args._mutation_plan = plan # noqa: SLF001 return Result( "IAM_ROLE_NO_CHANGE", f"NO CHANGE — IAM role {role_name} already matches {current.arn}.", @@ -2008,7 +2422,10 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | "roleId": current.role_id, }, ) - if not args.replace: + tag_actions = {"tag_role", "untag_role"} + tag_only = all(item.action in tag_actions for item in plan.operations) + if not args.replace and not tag_only: + args._mutation_plan = plan # noqa: SLF001 return Result( "IAM_ROLE_COLLISION", f"CONFLICT — IAM role {role_name} already exists and differs. " @@ -2062,10 +2479,15 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | ) if command == "update": current = service.get_role(_role_name(args.role, context)) - if current.tags.get(roles.MANAGED_TAG) != "true": + if current.ownership_status is roles.OwnershipStatus.UNOWNED: raise OperationalError( "Role is not Hacksaws-owned; adopt it before updating managed fields." ) + if current.ownership_status is roles.OwnershipStatus.UNSAFE: + raise OperationalError( + "Role has conflicting or partial Hacksaws ownership tags; repair or " + "release them before updating managed fields." + ) description = ( None if args.clear_description @@ -2095,11 +2517,17 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | tags={ key: value for key, value in current.tags.items() - if key not in {roles.MANAGED_TAG, roles.OWNER_TAG, roles.AUDIT_TAG} + if key + not in { + roles.MANAGED_TAG, + roles.OWNER_TAG, + roles.AUDIT_TAG, + roles.ORIGIN_TAG, + } }, owner=current.tags.get(roles.OWNER_TAG, "hacksaws"), audit_id=current.tags.get(roles.AUDIT_TAG), - ownership_origin=current.tags.get(roles.ORIGIN_TAG, "created"), + ownership_origin=current.tags.get(roles.ORIGIN_TAG, "legacy"), ) plan = roles.plan_update_role(current, desired) _execute(plan, context, args) @@ -2123,6 +2551,7 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | remove_from_instance_profiles=args.remove_from_instance_profiles, allow_unmanaged=args.unmanaged, ) + plan = replace(plan, before=current) _execute(plan, context, args) return Result( "IAM_ROLE_DELETED", @@ -2173,6 +2602,23 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | else roles.plan_detach_policy(role_name, arn, current=current_role) ) reference = arn + attached = set(current_role.attached_policies) + inline_names = set(current_role.inline_policies) + if command == "attach" and getattr(args, "inline", False): + inline_names.add(str(args.policy_name or Path(args.policy).stem)) + elif command == "attach": + attached.add(reference) + else: + attached.discard(reference) + plan = replace( + plan, + before=current_role, + after=replace( + current_role, + attached_policies=tuple(sorted(attached)), + inline_policies=tuple(sorted(inline_names)), + ), + ) _execute(plan, context, args) return Result( f"IAM_ROLE_POLICY_{command.upper()}", @@ -2189,6 +2635,7 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | "IAM_ROLE_TAG_LIST", _mapping_text(values), data={"tags": values} ) if action == "set": + desired_tags = {**current_role.tags, **_parse_tags(args.tags)} plan = roles.plan_put_tags( role_name, _parse_tags(args.tags), @@ -2206,6 +2653,16 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | current=current_role.tags, expected_role=current_role, ) + desired_tags = { + key: value + for key, value in current_role.tags.items() + if key not in args.keys + } + plan = replace( + plan, + before=current_role, + after=replace(current_role, tags=desired_tags), + ) _execute(plan, context, args) return Result( "IAM_ROLE_TAG_MUTATED", @@ -2223,6 +2680,25 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | if command == "adopt" else roles.plan_release_role(current) ) + if command == "release": + protected = { + roles.MANAGED_TAG, + roles.OWNER_TAG, + roles.AUDIT_TAG, + roles.ORIGIN_TAG, + } + plan = replace( + plan, + before=current, + after=replace( + current, + tags={ + key: value + for key, value in current.tags.items() + if key not in protected + }, + ), + ) _execute(plan, context, args) return Result( "IAM_ROLE_OWNERSHIP", @@ -2250,12 +2726,18 @@ def _dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | def dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | None: """Dispatch a parsed role leaf and normalize expected operational failures.""" try: - return _dispatch(args, context) + normalize_arguments(args) + result = _dispatch(args, context) + if result is None: + return None + plan = getattr(args, "_mutation_plan", None) + return _present_role_result(result, plan, args, context) except _DryRunCompletedError as completed: plan = completed.plan + view = _role_plan_view(plan) data = { "dryRun": True, - "classification": "planned" if plan.operations else "no-change", + "classification": view.classification, "kind": plan.kind, "resources": list(plan.resources), "operations": [ @@ -2263,23 +2745,111 @@ def dispatch(args: argparse.Namespace, context: IamCommandContext) -> Result | N for item in plan.operations ], "warnings": list(plan.warnings), + "plan": _mutation_view.change_data(view), } return Result( "IAM_ROLE_DRY_RUN", - f"DRY RUN — {_preview(plan)}\nNo AWS or local state was changed.", + f"DRY RUN\n\n{_mutation_view.change_text(view)}\n\n" + "No AWS or local state changed.", data=data, ) except _MutationCancelledError as error: - return Result( + result = Result( "IAM_ROLE_MUTATION_CANCELLED", f"Mutation cancelled; no AWS changes were made.\n{error}", EXIT_CANCELLED, "stderr", {"preview": str(error)}, ) + plan = getattr(args, "_mutation_plan", None) + return _present_role_result(result, plan, args, context) except OperationalError: raise except (roles.IamRoleError, documents.PolicyInputError) as error: raise OperationalError(str(error)) from error except (BotoCoreError, ClientError) as error: raise OperationalError(f"AWS IAM role operation failed: {error}") from error + + +def _present_role_result( + result: Result, + plan: roles.MutationPlan | None, + args: argparse.Namespace, + context: IamCommandContext, +) -> Result: + """Render every mutating role result through the shared stable contract.""" + if plan is None: + return result + plan_view = _role_plan_view(plan) + classification = _result_classification(result, plan) + old_data = result.data if isinstance(result.data, Mapping) else {} + arn_value = old_data.get("arn") or old_data.get("role") or plan_view.arn + arn = ( + str(arn_value) + if isinstance(arn_value, str) and arn_value.startswith("arn:") + else plan_view.arn + ) + role_id_value = old_data.get("roleId") + resource_id = str(role_id_value) if role_id_value else None + console_value = old_data.get("consoleUrl") + console_url = ( + str(console_value) + if isinstance(console_value, str) + else _console_url(context, plan_view.name) + ) + warning_value = old_data.get("warning") + warnings = tuple( + (*plan.warnings, str(warning_value)) if warning_value else plan.warnings + ) + view = _mutation_view.MutationResultView( + classification=classification, + resource_type=plan_view.resource_type, + name=plan_view.name, + arn=arn, + resource_id=resource_id, + console_url=console_url, + journal_id=getattr(args, "_mutation_journal_id", None), + applied_actions=( + () + if classification in {"cancelled", "conflict", "no-change"} + else tuple( + f"{operation.client}:{operation.action}" + for operation in plan.operations + ) + ), + warnings=warnings, + plan=plan_view, + details={"resultCode": result.code}, + ) + return Result( + result.code, + _mutation_view.result_text(view), + result.exit_code, + result.stream, + _mutation_view.result_data(view), + result.details, + result.repairs, + result.kind, + ) + + +def _result_classification( + result: Result, plan: roles.MutationPlan +) -> _mutation_view.Classification: + code = result.code + if "COLLISION" in code: + return "conflict" + if "CANCELLED" in code: + return "cancelled" + if not plan.operations or "NO_CHANGE" in code: + return "no-change" + if "CREATED" in code: + return "created" + if "DELETED" in code: + return "deleted" + if any( + token in code + for token in ("UPDATED", "REPLACED", "MUTATED", "POLICY_", "OWNERSHIP") + ): + return "updated" + return "applied" diff --git a/hacksaws/_iam_roles.py b/hacksaws/_iam_roles.py index b5e22b4..f31c90d 100644 --- a/hacksaws/_iam_roles.py +++ b/hacksaws/_iam_roles.py @@ -13,9 +13,12 @@ import json import re import time +import uuid from collections.abc import Mapping from dataclasses import dataclass from dataclasses import field +from dataclasses import replace +from enum import StrEnum from typing import TYPE_CHECKING from typing import Any from typing import Literal @@ -64,6 +67,15 @@ class AmbiguousTrustError(IamRoleError): """Raised when a logical trust mutation cannot preserve a complex statement.""" +class OwnershipStatus(StrEnum): + """Safety classification for legacy and current role ownership tags.""" + + CURRENT = "current" + LEGACY = "legacy" + UNOWNED = "unowned" + UNSAFE = "unsafe" + + def canonical_json(value: object) -> str: """Return deterministic compact JSON.""" return json.dumps(value, separators=(",", ":"), sort_keys=True) @@ -227,6 +239,8 @@ class MutationPlan: operations: tuple[Operation, ...] expected: Mapping[str, str] = field(default_factory=dict) warnings: tuple[str, ...] = () + before: RoleSnapshot | None = None + after: RoleSpec | RoleSnapshot | None = None @dataclass @@ -301,6 +315,43 @@ class RoleSnapshot: ) role_id: str = "" + @property + def ownership_status(self) -> OwnershipStatus: + """Return the complete role ownership safety classification.""" + return classify_ownership(self.tags) + + @property + def owned(self) -> bool: + """Return whether this role has safe legacy or current ownership.""" + return self.ownership_status in { + OwnershipStatus.CURRENT, + OwnershipStatus.LEGACY, + } + + +def classify_ownership(tags: Mapping[str, str]) -> OwnershipStatus: + """Classify the complete protected role tag domain conservatively.""" + protected_keys = {MANAGED_TAG, OWNER_TAG, AUDIT_TAG, ORIGIN_TAG} + values = {key: value for key, value in tags.items() if key in protected_keys} + if not values: + return OwnershipStatus.UNOWNED + if values.get(MANAGED_TAG) != "true" or not values.get(OWNER_TAG): + return OwnershipStatus.UNSAFE + allowed_legacy = ( + frozenset({MANAGED_TAG, OWNER_TAG}), + frozenset({MANAGED_TAG, OWNER_TAG, AUDIT_TAG}), + ) + if set(values) in allowed_legacy: + return OwnershipStatus.LEGACY + if frozenset(values) not in { + frozenset({MANAGED_TAG, OWNER_TAG, ORIGIN_TAG}), + frozenset(protected_keys), + }: + return OwnershipStatus.UNSAFE + if values.get(ORIGIN_TAG) not in {"created", "adopted", "legacy"}: + return OwnershipStatus.UNSAFE + return OwnershipStatus.CURRENT + def ownership_tags(spec: RoleSpec) -> dict[str, str]: """Merge explicit naming/audit tags with required ownership markers.""" @@ -317,29 +368,33 @@ def ownership_tags(spec: RoleSpec) -> dict[str, str]: def plan_create_role(spec: RoleSpec, *, client: str = "iam") -> MutationPlan: """Plan role creation under the safe Hacksaws path by default.""" - normalize_path(spec.path) - validate_trust_document(spec.trust) + planned = spec + normalize_path(planned.path) + validate_trust_document(planned.trust) params: dict[str, Any] = { - "RoleName": spec.name, - "Path": spec.path, - "AssumeRolePolicyDocument": canonical_json(spec.trust), - "MaxSessionDuration": spec.max_session_duration, + "RoleName": planned.name, + "Path": planned.path, + "AssumeRolePolicyDocument": canonical_json(planned.trust), + "MaxSessionDuration": planned.max_session_duration, "Tags": [ - {"Key": key, "Value": value} for key, value in ownership_tags(spec).items() + {"Key": key, "Value": value} + for key, value in ownership_tags(planned).items() ], } - if spec.description is not None: - params["Description"] = spec.description - if spec.permissions_boundary: - params["PermissionsBoundary"] = spec.permissions_boundary + if planned.description is not None: + params["Description"] = planned.description + if planned.permissions_boundary: + params["PermissionsBoundary"] = planned.permissions_boundary operation = Operation( client, "create_role", params, "delete_role", - {"RoleName": spec.name}, + {"RoleName": planned.name}, + ) + return MutationPlan( + "role-create", (planned.name,), (operation,), before=None, after=planned ) - return MutationPlan("role-create", (spec.name,), (operation,)) def plan_update_role(current: RoleSnapshot, desired: RoleSpec) -> MutationPlan: @@ -431,6 +486,8 @@ def plan_update_role(current: RoleSnapshot, desired: RoleSpec) -> MutationPlan: (current.arn,), tuple(operations), expected={"role": role_snapshot_hash(current)}, + before=current, + after=desired, ) @@ -457,17 +514,52 @@ def plan_adopt_role( role: RoleSnapshot, owner: str, audit_id: str | None = None ) -> MutationPlan: """Plan explicit adoption without changing role permissions or trust.""" + status = role.ownership_status current_owner = role.tags.get(OWNER_TAG) - if role.tags.get(MANAGED_TAG) == "true" and current_owner not in {None, owner}: + if status is OwnershipStatus.UNSAFE: + if role.tags.get(MANAGED_TAG) == "true" and current_owner not in { + None, + owner, + }: + raise ConflictError( + f"Role is already managed by {current_owner!r}; release it before " + "adoption." + ) + raise ConflictError( + "Role has conflicting or partial Hacksaws ownership tags; repair or " + "release them before adoption." + ) + if status is OwnershipStatus.UNOWNED: + tags = { + MANAGED_TAG: "true", + OWNER_TAG: owner, + AUDIT_TAG: audit_id or uuid.uuid4().hex, + ORIGIN_TAG: "adopted", + } + elif status is OwnershipStatus.LEGACY: + tags = {ORIGIN_TAG: "legacy"} + if audit_id and AUDIT_TAG not in role.tags: + tags[AUDIT_TAG] = audit_id + else: + if current_owner != owner: + raise ConflictError( + f"Role is already managed by {current_owner!r}; release it before " + "changing ownership." + ) + tags = {} + if status is OwnershipStatus.CURRENT and audit_id not in { + None, + role.tags.get(AUDIT_TAG), + }: raise ConflictError( - f"Role is already managed by {current_owner!r}; release it before adoption." + "Role already has a durable Hacksaws audit identity; adoption cannot " + "rewrite it." ) - tags = {MANAGED_TAG: "true", OWNER_TAG: owner, ORIGIN_TAG: "adopted"} - if audit_id: - tags[AUDIT_TAG] = audit_id - return plan_put_tags( + plan = plan_put_tags( role.name, tags, current=role.tags, kind="role-adopt", expected_role=role ) + desired_tags = {**role.tags, **tags} + return replace(plan, before=role, after=replace(role, tags=desired_tags)) def plan_release_role(role: RoleSnapshot) -> MutationPlan: @@ -510,6 +602,7 @@ def plan_put_tags( ), ) for key, value in tags.items() + if current is None or current.get(key) != value ) expected = ( {"role": role_snapshot_hash(expected_role)} if expected_role is not None else {} diff --git a/hacksaws/_mutation_view.py b/hacksaws/_mutation_view.py new file mode 100644 index 0000000..b39b5ab --- /dev/null +++ b/hacksaws/_mutation_view.py @@ -0,0 +1,237 @@ +"""Shared credential-free mutation plans and results for human and JSON output.""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import Literal + +from hacksaws import _output + +Scalar = str | int | bool | None +Classification = Literal[ + "planned", + "created", + "updated", + "deleted", + "no-change", + "conflict", + "cancelled", + "failed", + "applied", +] + + +@dataclass(frozen=True, slots=True) +class FieldChange: + """One exact scalar before-to-after resource field change.""" + + field: str + before: Scalar + after: Scalar + + +@dataclass(frozen=True, slots=True) +class ActionView: + """One ordered remote action without request parameters or documents.""" + + service: str + action: str + summary: str + destructive: bool = False + + +@dataclass(frozen=True, slots=True) +class DependencyView: + """One relevant resource dependency and its planned treatment.""" + + relation: str + resource: str + treatment: str + + +@dataclass(frozen=True, slots=True) +class ChangeView: + """One deterministic review contract shared by every IAM mutation family.""" + + operation: str + resource_type: str + name: str + classification: Classification + arn: str | None = None + account_id: str | None = None + partition: str | None = None + path: str | None = None + ownership: str | None = None + origin: str | None = None + before_exists: bool = False + after_exists: bool = False + changes: tuple[FieldChange, ...] = () + actions: tuple[ActionView, ...] = () + dependencies: tuple[DependencyView, ...] = () + warnings: tuple[str, ...] = () + confirmation: str = "not required" + + +@dataclass(frozen=True, slots=True) +class MutationResultView: + """One explicit post-mutation outcome linked to its reviewed plan.""" + + classification: Classification + resource_type: str + name: str + arn: str | None = None + resource_id: str | None = None + console_url: str | None = None + journal_id: str | None = None + applied_actions: tuple[str, ...] = () + warnings: tuple[str, ...] = () + plan: ChangeView | None = None + details: dict[str, object] = field(default_factory=dict) + + +def change_data(view: ChangeView) -> dict[str, object]: + """Return a stable JSON-ready mutation plan without raw AWS parameters.""" + return { + "classification": view.classification, + "operation": view.operation, + "resource": { + "type": view.resource_type, + "name": view.name, + "arn": view.arn, + "accountId": view.account_id, + "partition": view.partition, + "path": view.path, + }, + "ownership": {"status": view.ownership, "origin": view.origin}, + "before": {"exists": view.before_exists}, + "after": {"exists": view.after_exists}, + "changes": [ + {"field": item.field, "before": item.before, "after": item.after} + for item in view.changes + ], + "actions": [ + { + "service": item.service, + "action": item.action, + "summary": item.summary, + "destructive": item.destructive, + } + for item in view.actions + ], + "dependencies": [ + { + "relation": item.relation, + "resource": item.resource, + "treatment": item.treatment, + } + for item in view.dependencies + ], + "warnings": list(view.warnings), + "confirmation": view.confirmation, + } + + +def change_text(view: ChangeView) -> str: + """Render the complete review contract as compact, self-educating text.""" + heading = f"{view.classification.upper()} — {view.operation} {view.resource_type}" + lines = [heading, "", "Identity", f" Name: {_safe(view.name)}"] + for label, value in ( + ("ARN", view.arn), + ("Account", view.account_id), + ("Partition", view.partition), + ("Path", view.path), + ): + if value is not None: + lines.append(f" {label}: {_safe(value)}") + if view.ownership is not None or view.origin is not None: + lines.extend( + ( + "", + "Ownership", + f" Status: {_safe(view.ownership or 'unknown')}", + f" Origin: {_safe(view.origin or 'unknown')}", + ) + ) + lines.extend(("", "Before → After")) + if view.changes: + lines.extend( + f" {_safe(item.field)}: {_value(item.before)} → {_value(item.after)}" + for item in view.changes + ) + else: + lines.append(" No field changes.") + lines.extend(("", "AWS actions")) + if view.actions: + lines.extend( + f" {index}. {_safe(item.service)}:{_safe(item.action)} — " + f"{_safe(item.summary)}" + for index, item in enumerate(view.actions, start=1) + ) + else: + lines.append(" None.") + if view.dependencies: + lines.extend(("", "Dependencies")) + lines.extend( + f" {_safe(item.relation)}: {_safe(item.resource)} — " + f"{_safe(item.treatment)}" + for item in view.dependencies + ) + if view.warnings: + lines.extend(("", "Warnings")) + lines.extend(f" ! {_safe(item)}" for item in view.warnings) + lines.extend(("", f"Confirmation: {_safe(view.confirmation)}")) + return "\n".join(lines) + + +def result_data(view: MutationResultView) -> dict[str, object]: + """Return a stable JSON-ready mutation outcome and its reviewed plan.""" + return { + "classification": view.classification, + "resource": { + "type": view.resource_type, + "name": view.name, + "arn": view.arn, + "id": view.resource_id, + "consoleUrl": view.console_url, + }, + "journalId": view.journal_id, + "appliedActions": list(view.applied_actions), + "warnings": list(view.warnings), + "plan": change_data(view.plan) if view.plan is not None else None, + "details": view.details, + } + + +def result_text(view: MutationResultView) -> str: + """Render an outcome that makes success, no-change, and identity explicit.""" + heading = f"{view.classification.upper()} — {view.resource_type} {_safe(view.name)}" + lines = [heading] + for label, value in ( + ("ARN", view.arn), + ("Resource ID", view.resource_id), + ("Recovery journal", view.journal_id), + ("AWS Console", view.console_url), + ): + if value is not None: + lines.append(f"{label}: {_safe(value)}") + if view.applied_actions: + applied = ", ".join(_safe(item) for item in view.applied_actions) + lines.append(f"Applied: {applied}") + elif view.classification == "no-change": + lines.append("Applied: none; remote state already matched the requested state.") + if view.warnings: + lines.extend(f"Warning: {_safe(item)}" for item in view.warnings) + return "\n".join(lines) + + +def _safe(value: object) -> str: + return _output.safe_terminal_text(value) + + +def _value(value: Scalar) -> str: + if value is None: + return "∅" + if isinstance(value, bool): + return "yes" if value else "no" + return _safe(value) diff --git a/hacksaws/_output.py b/hacksaws/_output.py index 0ff9f45..a114027 100644 --- a/hacksaws/_output.py +++ b/hacksaws/_output.py @@ -19,6 +19,8 @@ from rich.table import Table from rich.text import Text +from hacksaws import _audit + if TYPE_CHECKING: from collections.abc import Iterable @@ -249,10 +251,15 @@ def confirm( ) -> bool: """Ask a safe default-no confirmation without allowing noninteractive hangs.""" if assume_yes: + _audit.note_confirmation("yes-flag", "bypassed") return True if not interactive: + _audit.note_confirmation("yes-no", "unavailable") return False source = sys.stdin if stdin is None else stdin if not bool(getattr(source, "isatty", lambda: False)()): + _audit.note_confirmation("yes-no", "unavailable") return False - return input(f"{prompt} [y/N] ").strip().casefold() in {"y", "yes"} + accepted = input(f"{prompt} [y/N] ").strip().casefold() in {"y", "yes"} + _audit.note_confirmation("yes-no", "accepted" if accepted else "declined") + return accepted diff --git a/hacksaws/_resource_input.py b/hacksaws/_resource_input.py new file mode 100644 index 0000000..57110cd --- /dev/null +++ b/hacksaws/_resource_input.py @@ -0,0 +1,141 @@ +"""Deterministic NAME/file disambiguation shared by IAM mutation commands.""" + +# ruff: noqa: C901, PLR0913, PLR2004, TRY003 + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from hacksaws._configs import OperationalError + +if TYPE_CHECKING: + from collections.abc import Sequence + +_PATHLIKE = re.compile(r"^(?:[A-Za-z]:[\\/]|[.~][\\/]|[\\/])") +_DOCUMENT_EXTENSIONS = {".json", ".yaml", ".yml", ".toml"} + + +@dataclass(frozen=True, slots=True) +class NameFileInput: + """One resolved resource name and local document input.""" + + name: str | None + file: Path | None + + +@dataclass(frozen=True, slots=True) +class ReferenceFileInput: + """Exactly one remote reference or local document input.""" + + reference: str | None + file: Path | None + + +def looks_like_file(value: str) -> bool: + """Return whether syntax or local state identifies a document path.""" + expanded = Path(value).expanduser() + return ( + value == "-" + or bool(_PATHLIKE.match(value)) + or "/" in value + or "\\" in value + or expanded.suffix.casefold() in _DOCUMENT_EXTENSIONS + or expanded.is_file() + ) + + +def resolve_name_file( + positional: Sequence[str], + *, + explicit_name: str | None = None, + explicit_file: str | Path | None = None, + require_name: bool = True, + require_file: bool = True, + name_label: str = "NAME", + file_label: str = "FILE", + name_option: str = "--name", + file_option: str = "--file", +) -> NameFileInput: + """Resolve up to two order-independent positional values before AWS access.""" + values = list(positional) + if len(values) > 2: + raise OperationalError( + f"Expected at most {name_label} and {file_label}; use {name_option} " + f"and {file_option} to make the intended values explicit." + ) + name = explicit_name + file = Path(explicit_file).expanduser() if explicit_file is not None else None + if name is not None and file is not None and values: + raise OperationalError( + f"{name_label} and {file_label} were already supplied by flags; remove " + "the extra positional value." + ) + if len(values) == 2: + if name is not None or file is not None: + raise OperationalError( + f"Two positional values cannot be combined with {name_option} or " + f"{file_option}." + ) + first_file = looks_like_file(values[0]) + second_file = looks_like_file(values[1]) + if first_file == second_file: + raise OperationalError( + f"Unable to distinguish {name_label} from {file_label}; use " + f"{name_option} {name_label} {file_option} {file_label}." + ) + name = values[1] if first_file else values[0] + file = Path(values[0] if first_file else values[1]).expanduser() + elif values: + value = values[0] + if name is not None: + file = Path(value).expanduser() + elif file is not None: + name = value + elif looks_like_file(value): + file = Path(value).expanduser() + else: + name = value + if require_name and name is None: + raise OperationalError( + f"Missing {name_label}; provide it positionally or with {name_option}." + ) + if require_file and file is None: + raise OperationalError( + f"Missing {file_label}; provide a path positionally or with {file_option}." + ) + return NameFileInput(name=name, file=file) + + +def resolve_reference_or_file( + positional: Sequence[str], + *, + explicit_reference: str | None = None, + explicit_file: str | Path | None = None, + reference_label: str = "POLICY", + reference_option: str = "--policy", + file_option: str = "--file", +) -> ReferenceFileInput: + """Resolve one remote reference or local file with explicit conflict checks.""" + values = list(positional) + supplied = ( + len(values) + + int(explicit_reference is not None) + + int(explicit_file is not None) + ) + if supplied != 1: + raise OperationalError( + f"Specify exactly one {reference_label} reference or policy file; use " + f"{reference_option} {reference_label} or {file_option} FILE to " + "disambiguate." + ) + if explicit_reference is not None: + return ReferenceFileInput(reference=explicit_reference, file=None) + if explicit_file is not None: + return ReferenceFileInput(reference=None, file=Path(explicit_file).expanduser()) + value = values[0] + if looks_like_file(value): + return ReferenceFileInput(reference=None, file=Path(value).expanduser()) + return ReferenceFileInput(reference=value, file=None) diff --git a/hacksaws/_sessions.py b/hacksaws/_sessions.py index d00e946..85513be 100644 --- a/hacksaws/_sessions.py +++ b/hacksaws/_sessions.py @@ -121,45 +121,14 @@ def _restore(snapshot: dict[str, Any]) -> None: path.unlink(missing_ok=True) -def _begin( - paths: list[Path], *, cache_roots: list[Path] | None = None -) -> dict[str, Any]: - cache_snapshots = [] - for cache_root in cache_roots or []: - root_exists = cache_root.exists() - existing = ( - [ - _snapshot(path.absolute()) - for path in cache_root.rglob("*") - if path.is_file() - ] - if cache_root.exists() - else [] - ) - directories = ( - [ - str(path.absolute()) - for path in [cache_root, *cache_root.rglob("*")] - if path.is_dir() - ] - if root_exists - else [] - ) - cache_snapshots.append( - { - "root": str(cache_root.absolute()), - "root_exists": root_exists, - "directories": directories, - "files": existing, - } - ) +def _begin(paths: list[Path]) -> dict[str, Any]: journal = { "schema_version": 1, "started_at": _state.iso_now(), "files": [_snapshot(path) for path in paths], "safe_to_rollback": True, "ecr_created": [], - "cache_snapshots": cache_snapshots, + "browser_cache_claims": [], } _state.atomic_write( _journal_path(), (json.dumps(journal, indent=2) + "\n").encode() @@ -187,39 +156,11 @@ def _rollback(journal: dict[str, Any]) -> None: ) except _configs.OperationalError as error: failures.append(f"ECR {registry}: {error}") - for snapshot in journal.get("cache_snapshots", []): - cache_root = Path(snapshot["root"]).absolute() - before = {item["path"] for item in snapshot.get("files", [])} - if cache_root.exists(): - for cache_file in cache_root.rglob("*"): - if cache_file.is_file() and str(cache_file.absolute()) not in before: - try: - cache_file.unlink() - except OSError as error: - failures.append(f"cache {cache_file}: {error}") - for cache_file_snapshot in snapshot.get("files", []): - try: - _restore(cache_file_snapshot) - except OSError as error: - failures.append(f"cache {cache_file_snapshot['path']}: {error}") - before_directories = { - str(Path(value).absolute()) for value in snapshot.get("directories", []) - } - if "directories" in snapshot and cache_root.exists(): - current_directories = sorted( - (path for path in cache_root.rglob("*") if path.is_dir()), - key=lambda path: len(path.parts), - reverse=True, - ) - if not snapshot.get("root_exists", True): - current_directories.append(cache_root) - for directory in current_directories: - if str(directory.absolute()) in before_directories: - continue - try: - directory.rmdir() - except OSError as error: - failures.append(f"cache directory {directory}: {error}") + for claim in journal.get("browser_cache_claims", []): + try: + _remove_browser_cache_claim(claim, strict=True) + except _configs.OperationalError as error: + failures.append(str(error)) for snapshot in reversed(journal["files"]): try: _restore(snapshot) @@ -260,33 +201,201 @@ def _commit() -> None: _journal_path().unlink(missing_ok=True) -def _changed_cache_files(snapshot: dict[str, Any]) -> list[str]: - """Return cache files created or changed by the current transaction.""" - cache_root = Path(snapshot["root"]).absolute() - before = { - item["path"]: base64.b64decode(item["data"]) - for item in snapshot.get("files", []) +def _canonical_path(path: Path) -> Path: + """Return one stable absolute path without requiring the target to exist.""" + return path.expanduser().resolve(strict=False) + + +def _lineage_hash(value: str) -> str: + """Hash a normalized, high-entropy lineage component without retaining it.""" + return _state.digest(value.strip().encode("utf-8")) + + +def _dpop_generation_hash(value: str) -> str: + """Hash the DER payload so PEM whitespace cannot create a false generation.""" + payload = "".join( + line.strip() + for line in value.splitlines() + if not line.strip().startswith("-----BEGIN") + and not line.strip().startswith("-----END") + ) + return _lineage_hash(payload) + + +def _login_session_value(config: Path, profile: str) -> str: + parser = _read_ini(config) + section = _section(profile, config=True) + value = parser.get(section, "login_session", fallback="").strip() + if not value: + raise _configs.OperationalError( + f"Browser profile {profile!r} has no login_session after AWS login." + ) + return value + + +def _browser_cache_lineage( + config: Path, + profile: str, + root: Path, + *, + identity: tuple[str, str, str] | None = None, +) -> dict[str, Any]: + """Describe one AWS login cache generation without retaining token material.""" + canonical_root = _canonical_path(root) + login_session = _login_session_value(config, profile) + cache_key = _state.digest(login_session.encode("utf-8")) + path = _canonical_path(canonical_root / f"{cache_key}.json") + if path.parent != canonical_root: + raise _configs.OperationalError("Derived browser cache path escaped its root.") + content = _browser_cache_content_lineage(path, identity=identity) + return { + "schema_version": 1, + "root": str(canonical_root), + "path": str(path), + "cache_key": cache_key, + "login_session_hash": _lineage_hash(login_session), + **content, + } + + +def _browser_cache_content_lineage( + path: Path, *, identity: tuple[str, str, str] | None = None +) -> dict[str, Any]: + """Read stable generation fields from one already-derived cache path.""" + try: + raw = path.read_bytes() + token = json.loads(raw) + except (OSError, json.JSONDecodeError) as error: + raise _configs.OperationalError( + f"Unable to read the derived AWS browser login cache {path}: {error}" + ) from error + if not isinstance(token, dict): + raise _configs.OperationalError("AWS browser login cache is not a JSON object.") + client_id = token.get("clientId") + dpop_key = token.get("dpopKey") + access_token = token.get("accessToken") + if ( + not isinstance(client_id, str) + or not isinstance(dpop_key, str) + or not isinstance(access_token, dict) + ): + raise _configs.OperationalError( + "AWS browser login cache is missing stable lineage fields." + ) + token_account = str(access_token.get("accountId", "")).strip() + if identity is None: + account = token_account + partition = "" + principal = "" + else: + account, partition, principal = identity + if token_account and token_account != account: + raise _configs.OperationalError( + "AWS browser cache account does not match GetCallerIdentity." + ) + return { + "client_id_hash": _lineage_hash(client_id), + "dpop_generation_hash": _dpop_generation_hash(dpop_key), + "account": account, + "partition": partition, + "principal": principal, + "whole_digest": _state.digest(raw), } - if not cache_root.exists(): - return [] - changed = [] - for path in cache_root.rglob("*"): - if not path.is_file(): - continue - absolute = str(path.absolute()) - if absolute not in before or path.read_bytes() != before[absolute]: - changed.append(absolute) - return sorted(changed) -def _cache_fingerprints(paths: list[str]) -> dict[str, str]: - """Fingerprint tracked cache content so logout cannot remove replacements.""" +_BROWSER_STABLE_LINEAGE = ( + "root", + "path", + "cache_key", + "login_session_hash", + "client_id_hash", + "dpop_generation_hash", +) + + +def _same_browser_lineage(expected: dict[str, Any], current: dict[str, Any]) -> bool: + return all(expected.get(key) == current.get(key) for key in _BROWSER_STABLE_LINEAGE) + + +def _record_browser_cache_claim( + journal: dict[str, Any], lineage: dict[str, Any], config: Path, profile: str +) -> None: + claim = { + **lineage, + "config": str(_canonical_path(config)), + "profile": profile, + } + journal.setdefault("browser_cache_claims", []).append(claim) + _state.atomic_write( + _journal_path(), (json.dumps(journal, indent=2) + "\n").encode() + ) + + +def _current_browser_cache_claim(claim: dict[str, Any]) -> dict[str, Any]: + identity = None + if all(claim.get(key) for key in ("account", "partition", "principal")): + identity = ( + str(claim["account"]), + str(claim["partition"]), + str(claim["principal"]), + ) + return _browser_cache_lineage( + Path(str(claim["config"])), + str(claim["profile"]), + Path(str(claim["root"])), + identity=identity, + ) + + +def _current_browser_cache_content(claim: dict[str, Any]) -> dict[str, Any]: + """Re-read a claimed file after its profile config may have been removed.""" + path = _canonical_path(Path(str(claim["path"]))) + identity = None + if all(claim.get(key) for key in ("account", "partition", "principal")): + identity = ( + str(claim["account"]), + str(claim["partition"]), + str(claim["principal"]), + ) return { - str(Path(value).absolute()): _state.digest(Path(value).read_bytes()) - for value in paths + **{key: claim.get(key) for key in _BROWSER_STABLE_LINEAGE[:4]}, + **_browser_cache_content_lineage(path, identity=identity), } +def _remove_browser_cache_claim( + claim: dict[str, Any], *, strict: bool +) -> dict[str, str] | None: + path = _canonical_path(Path(str(claim["path"]))) + if not path.exists(): + return None + try: + current = _current_browser_cache_claim(claim) + except _configs.OperationalError as error: + if strict: + raise + return {"path": str(path), "reason": str(error)} + if not _same_browser_lineage(claim, current): + reason = "browser cache belongs to a different login generation" + if strict: + raise _configs.OperationalError(f"{reason}: {path}") + return {"path": str(path), "reason": reason} + expected_digest = current.get("whole_digest") + try: + if _state.digest(path.read_bytes()) != expected_digest: + raise _configs.OperationalError( + f"Browser cache changed during compare-and-delete: {path}" + ) + path.unlink() + except OSError as error: + if strict: + raise _configs.OperationalError( + f"Unable to remove browser cache {path}: {error}" + ) from error + return {"path": str(path), "reason": f"remove failed: {error}"} + return None + + def _read_ini(path: Path) -> configparser.ConfigParser: parser = configparser.ConfigParser(interpolation=None) if path.exists(): @@ -788,37 +897,38 @@ def _record( else: original_backup = destination_backup if retain_file_backup else [] previous_ecr = previous.get("ecr", []) if previous and inherit_runtime_state else [] - previous_cache = ( - previous.get("login_cache_files", []) - if previous and inherit_runtime_state - else [] - ) - current_cache = metadata.get("login_cache_files", []) - if previous_cache or current_cache: - metadata["login_cache_files"] = list( - dict.fromkeys([*previous_cache, *current_cache]) + if "login_cache_lineage" not in metadata: + previous_cache = ( + previous.get("login_cache_files", []) + if previous and inherit_runtime_state + else [] ) - previous_cache_directories = ( - previous.get("login_cache_directories", []) - if previous and inherit_runtime_state - else [] - ) - current_cache_directories = metadata.get("login_cache_directories", []) - if previous_cache_directories or current_cache_directories: - metadata["login_cache_directories"] = list( - dict.fromkeys([*previous_cache_directories, *current_cache_directories]) + current_cache = metadata.get("login_cache_files", []) + if previous_cache or current_cache: + metadata["login_cache_files"] = list( + dict.fromkeys([*previous_cache, *current_cache]) + ) + previous_cache_directories = ( + previous.get("login_cache_directories", []) + if previous and inherit_runtime_state + else [] ) - previous_fingerprints = ( - previous.get("login_cache_fingerprints", {}) - if previous and inherit_runtime_state - else {} - ) - current_fingerprints = metadata.get("login_cache_fingerprints", {}) - if previous_fingerprints or current_fingerprints: - metadata["login_cache_fingerprints"] = { - **previous_fingerprints, - **current_fingerprints, - } + current_cache_directories = metadata.get("login_cache_directories", []) + if previous_cache_directories or current_cache_directories: + metadata["login_cache_directories"] = list( + dict.fromkeys([*previous_cache_directories, *current_cache_directories]) + ) + previous_fingerprints = ( + previous.get("login_cache_fingerprints", {}) + if previous and inherit_runtime_state + else {} + ) + current_fingerprints = metadata.get("login_cache_fingerprints", {}) + if previous_fingerprints or current_fingerprints: + metadata["login_cache_fingerprints"] = { + **previous_fingerprints, + **current_fingerprints, + } sessions[key] = { **metadata, "destination": str(destination.absolute()), @@ -1168,8 +1278,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: destination_dir / "config", destination_dir / "credentials", _state.sessions_path(), - ], - cache_roots=[native_cache], + ] ) ecr_registries: list[str] = [] login_completed = False @@ -1182,13 +1291,34 @@ def browser_login(context: _configs.Context) -> _configs.Result: login_cache=native_cache, ) login_completed = True + initial_lineage = _browser_cache_lineage( + destination_dir / "config", + destination_profile, + native_cache, + ) + _record_browser_cache_claim( + journal, + initial_lineage, + destination_dir / "config", + destination_profile, + ) with _aws_environment( destination_dir / "config", destination_dir / "credentials", native_cache, ): native = boto3.Session(profile_name=destination_profile) - account, partition, _ = _identity(native, label="browser login") + account, partition, principal = _identity(native, label="browser login") + lineage = _browser_cache_lineage( + destination_dir / "config", + destination_profile, + native_cache, + identity=(account, partition, principal), + ) + journal["browser_cache_claims"][-1].update(lineage) + _state.atomic_write( + _journal_path(), (json.dumps(journal, indent=2) + "\n").encode() + ) target = _target_details(args, account, partition) if args.ecr: aws_account = _configs.AwsAccount( @@ -1207,7 +1337,6 @@ def browser_login(context: _configs.Context) -> _configs.Result: journal, context.container_engine, registry ), ) - changed_cache = _changed_cache_files(journal["cache_snapshots"][0]) _record( destination_dir, destination_profile, @@ -1222,9 +1351,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: "policy": None, "policy_provenance": "AWS-native login_session", "expires_at": None, - "login_cache_files": changed_cache, - "login_cache_directories": [str(native_cache.absolute())], - "login_cache_fingerprints": _cache_fingerprints(changed_cache), + "login_cache_lineage": lineage, }, journal, method="browser-native", @@ -1254,8 +1381,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: destination_dir / "config", destination_dir / "credentials", _state.sessions_path(), - ], - cache_roots=[staging], + ] ) ecr_registries = [] login_completed = False @@ -1268,11 +1394,27 @@ def browser_login(context: _configs.Context) -> _configs.Result: login_cache=staging_cache, ) login_completed = True + initial_lineage = _browser_cache_lineage( + staging_config, source_profile, staging_cache + ) + _record_browser_cache_claim( + journal, initial_lineage, staging_config, source_profile + ) with _aws_environment(staging_config, staging_credentials, staging_cache): intermediate = boto3.Session(profile_name=source_profile) - source_account, partition, _ = _identity( + source_account, partition, source_principal = _identity( intermediate, label="browser staging login" ) + lineage = _browser_cache_lineage( + staging_config, + source_profile, + staging_cache, + identity=(source_account, partition, source_principal), + ) + journal["browser_cache_claims"][-1].update(lineage) + _state.atomic_write( + _journal_path(), (json.dumps(journal, indent=2) + "\n").encode() + ) target = _target_details(args, source_account, partition) role, policy, external_id, boundary_name = _role_details( args, target, source_account, partition @@ -1334,6 +1476,8 @@ def browser_login(context: _configs.Context) -> _configs.Result: ecr=ecr_registries, ecr_engine=context.container_engine if ecr_registries else None, ) + _remove_browser_cache_claim(journal["browser_cache_claims"][-1], strict=True) + journal["browser_cache_claims"] = [] _commit() except Exception as error: _rollback(journal) @@ -1478,25 +1622,40 @@ def _assume_preflight(context: _configs.Context) -> dict[str, Any]: ) force = bool(getattr(args, "force", False)) source_plans: list[tuple[Path, str, dict[str, Any]]] = [] - source_cache: tuple[list[Path], list[Path], list[dict[str, str]]] = ([], [], []) - if source_record and not keep_source: - source_plans = _profile_section_plans( + source_cache: tuple[ + list[Path], list[dict[str, Any]], list[dict[str, str]], dict[str, Any] | None + ] = ([], [], [], None) + if source_record: + if not keep_source: + source_plans = _profile_section_plans( + source_record, source, source_profile, force=force + ) + source_cache = _tracked_login_cache_plan( source_record, source, source_profile, force=force ) - source_cache = _tracked_login_cache_plan(source_record, source, force=force) + if keep_source: + source_cache = ( + source_cache[0], + [], + source_cache[2], + source_cache[3], + ) destination_record = sessions.get(destination_key) - destination_cache: tuple[list[Path], list[Path], list[dict[str, str]]] = ( + destination_cache: tuple[ + list[Path], list[dict[str, Any]], list[dict[str, str]], dict[str, Any] | None + ] = ( [], [], [], + None, ) if destination_record and not same_key: _profile_section_plans( destination_record, destination, destination_profile, force=False ) destination_cache = _tracked_login_cache_plan( - destination_record, destination, force=False + destination_record, destination, destination_profile, force=False ) destination_exists = _profile_exists(destination, destination_profile) if ( @@ -1511,7 +1670,12 @@ def _assume_preflight(context: _configs.Context) -> dict[str, Any]: ) if source_record: - configured = source_record.get("login_cache_directories", []) + stored_lineage = source_record.get("login_cache_lineage") + configured = ( + [stored_lineage["root"]] + if isinstance(stored_lineage, dict) and stored_lineage.get("root") + else source_record.get("login_cache_directories", []) + ) source_login_cache = ( Path(str(configured[0])).absolute() if configured else _native_login_cache() ) @@ -1652,13 +1816,11 @@ def _assume_arguments_fingerprint(args: Any) -> str: def _owned_cache_state(plan: dict[str, Any]) -> dict[str, str | None]: - paths = [*plan["source_cache"][1], *plan["destination_cache"][1]] + claims = [*plan["source_cache"][1], *plan["destination_cache"][1]] result: dict[str, str | None] = {} - for path in paths: - absolute = path.absolute() - result[str(absolute)] = ( - _state.digest(absolute.read_bytes()) if absolute.exists() else None - ) + for claim in claims: + path = _canonical_path(Path(str(claim["path"]))) + result[str(path)] = _state.digest(path.read_bytes()) if path.exists() else None return result @@ -1866,7 +2028,32 @@ def _revalidate_assume_plan( ), } changed = changed or current != data[f"{prefix}_expected"] - changed = changed or _owned_cache_state(data) != data["cache_expected"] + if _owned_cache_state(data) != data["cache_expected"]: + source_record = data.get("source_record") + if source_record: + source_cache = _tracked_login_cache_plan( + source_record, + data["source"], + data["source_profile"], + force=False, + ) + if data["keep_source"]: + source_cache = ( + source_cache[0], + [], + source_cache[2], + source_cache[3], + ) + data["source_cache"] = source_cache + destination_record = data.get("destination_record") + if destination_record and not data["same_key"]: + data["destination_cache"] = _tracked_login_cache_plan( + destination_record, + data["destination"], + data["destination_profile"], + force=False, + ) + data["cache_expected"] = _owned_cache_state(data) changed = ( changed or _file_fingerprint(_state.root() / "config.json") @@ -1988,8 +2175,12 @@ def _build_assume_journal( "ecr_engine": inherited_engine if inherited_ecr else None, } cache = [ - {"path": path, "fingerprint": fingerprint} - for path, fingerprint in data["cache_expected"].items() + {**copy.deepcopy(claim), "owner": owner} + for owner, claims in ( + ("source", data["source_cache"][1]), + ("destination", data["destination_cache"][1]), + ) + for claim in claims ] source_record = data["source_record"] or {} source_runtime = { @@ -2000,7 +2191,19 @@ def _build_assume_journal( if data["same_key"]: source_session_final = _session_final_state(destination_session) elif data["keep_source"]: - source_session_final = copy.deepcopy(data["source_session_expected"]) + upgraded_source = copy.deepcopy(data["source_record"]) + upgraded_lineage = data["source_cache"][3] + if upgraded_source and upgraded_lineage: + upgraded_source["login_cache_lineage"] = copy.deepcopy(upgraded_lineage) + for legacy_name in ( + "login_cache_files", + "login_cache_directories", + "login_cache_fingerprints", + ): + upgraded_source.pop(legacy_name, None) + source_session_final = _session_final_state(upgraded_source) + else: + source_session_final = copy.deepcopy(data["source_session_expected"]) elif source_runtime["ecr"]: source_session_final = _session_final_state( { @@ -2093,11 +2296,32 @@ def _validate_assume_cache(journal: dict[str, Any], *, allow_missing: bool) -> N if allow_missing: continue _assume_recovery_error(f"browser login cache {path}") + if item.get("legacy_cas_only") or ( + "fingerprint" in item and "schema_version" not in item + ): + expected_digest = item.get("whole_digest", item.get("fingerprint")) + try: + current_digest = _state.digest(path.read_bytes()) + except OSError: + _assume_recovery_error(f"browser login cache {path}") + if current_digest != expected_digest: + if allow_missing: + item["residue_reason"] = "legacy cache fingerprint changed" + continue + _assume_recovery_error(f"browser login cache {path}") + continue try: - current = _state.digest(path.read_bytes()) - except OSError: + current = _current_browser_cache_content(item) + except _configs.OperationalError: _assume_recovery_error(f"browser login cache {path}") - if current != item.get("fingerprint"): + if not _same_browser_lineage(item, current): + if allow_missing: + item["residue_reason"] = "different browser login generation" + continue + _assume_recovery_error(f"browser login cache {path}") + if allow_missing: + item["whole_digest"] = current["whole_digest"] + elif current["whole_digest"] != item.get("whole_digest"): _assume_recovery_error(f"browser login cache {path}") @@ -2199,17 +2423,69 @@ def _remove_assume_cache( path = Path(str(item["path"])).absolute() if not path.exists(): continue + if item.get("residue_reason"): + residue.append({"path": str(path), "reason": str(item["residue_reason"])}) + continue + if item.get("legacy_cas_only") or ( + "fingerprint" in item and "schema_version" not in item + ): + expected_digest = item.get("whole_digest", item.get("fingerprint")) + try: + current_digest = _state.digest(path.read_bytes()) + except OSError as error: + if strict: + _assume_recovery_error(f"browser login cache {path}") + residue.append({"path": str(path), "reason": f"unreadable: {error}"}) + continue + if current_digest != expected_digest: + if strict: + _assume_recovery_error(f"browser login cache {path}") + residue.append( + {"path": str(path), "reason": "legacy cache fingerprint changed"} + ) + continue + try: + path.unlink() + except OSError as error: + if strict: + raise _configs.OperationalError( + f"Unable to remove owned browser login cache {path}; the " + f"AssumeRole recovery journal was retained: {error}" + ) from error + residue.append({"path": str(path), "reason": f"remove failed: {error}"}) + continue try: - current = _state.digest(path.read_bytes()) - except OSError as error: + current = _current_browser_cache_content(item) + except _configs.OperationalError as error: if strict: _assume_recovery_error(f"browser login cache {path}") residue.append({"path": str(path), "reason": f"unreadable: {error}"}) continue - if current != item.get("fingerprint"): + if not _same_browser_lineage(item, current): if strict: _assume_recovery_error(f"browser login cache {path}") - residue.append({"path": str(path), "reason": "fingerprint changed"}) + residue.append( + {"path": str(path), "reason": "different browser login generation"} + ) + continue + try: + unchanged = _state.digest(path.read_bytes()) == current["whole_digest"] + except OSError as error: + if strict: + raise _configs.OperationalError( + f"Unable to read owned browser login cache {path}: {error}" + ) from error + residue.append({"path": str(path), "reason": f"unreadable: {error}"}) + continue + if not unchanged: + if strict: + _assume_recovery_error(f"browser login cache {path}") + residue.append( + { + "path": str(path), + "reason": "cache changed during compare-and-delete", + } + ) continue try: path.unlink() @@ -2294,7 +2570,7 @@ def _install_assume_destination(journal: dict[str, Any]) -> None: ) -def _finish_assume_source(journal: dict[str, Any]) -> None: +def _finish_assume_source(journal: dict[str, Any]) -> list[dict[str, str]]: _validate_assume_recovery(journal, roll_forward=True) source = journal["source"] same_key = bool(source["same_key"]) @@ -2318,7 +2594,53 @@ def _finish_assume_source(journal: dict[str, Any]) -> None: if _file_fingerprint(path) != legacy.get("fingerprint"): _assume_recovery_error(f"legacy credential backup {path}") path.unlink() - _remove_assume_cache(journal, strict=True) + cache_residue = _remove_assume_cache(journal, strict=False) + residue_paths = {item["path"] for item in cache_residue} + source_residue = [ + item + for item in cache_residue + if any( + str(claim.get("path")) == item["path"] and claim.get("owner") == "source" + for claim in journal.get("cache", []) + ) + ] + destination_residue = [ + item + for item in cache_residue + if item["path"] not in {value["path"] for value in source_residue} + ] + if source_residue and not same_key and not source["keep"]: + runtime = source.get("runtime", {}) + source_claim = next( + ( + claim + for claim in journal.get("cache", []) + if str(claim.get("path")) in residue_paths + and claim.get("owner") == "source" + ), + None, + ) + residual = { + "destination": source["directory"], + "profile": source["profile"], + "auth_method": ( + "logout-residue" if runtime.get("ecr") else "browser-cache-residue" + ), + "started_at": runtime.get("started_at"), + "backup": [], + "section_backup": {}, + "ecr": list(runtime.get("ecr", [])), + "ecr_engine": runtime.get("ecr_engine"), + "login_cache_residue": source_residue, + } + if source_claim: + residual["login_cache_lineage"] = { + key: value + for key, value in source_claim.items() + if key not in {"owner", "residue_reason", "config", "profile"} + } + source["session_final"] = _session_final_state(residual) + _write_assume_journal(journal) if not same_key: _write_session_cas( source["key"], @@ -2326,6 +2648,23 @@ def _finish_assume_source(journal: dict[str, Any]) -> None: final=source["session_final"], label="source session metadata", ) + if destination_residue: + destination = journal["destination"] + current_final = copy.deepcopy(destination["session_final"]) + values = current_final.get("values") + if isinstance(values, dict): + values["login_cache_residue"] = destination_residue + updated = _session_final_state(values) + _write_session_cas( + destination["key"], + expected=destination["session_final"], + final=updated, + label="destination session metadata residue", + ) + destination["session"] = values + destination["session_final"] = updated + _write_assume_journal(journal) + return cache_residue def _recover_assume_journal(journal: dict[str, Any]) -> None: @@ -2345,8 +2684,16 @@ def _recover_assume_journal(journal: dict[str, Any]) -> None: _validate_assume_recovery(journal, roll_forward=installed) if installed: _install_assume_destination(journal) - _finish_assume_source(journal) + residue = _finish_assume_source(journal) + else: + residue = [] _commit() + if residue: + raise _configs.OperationalError( + "AssumeRole recovery installed the restricted destination and removed " + "the broad source credentials, but preserved an unowned browser cache " + "generation as browser-cache-residue." + ) def assume_role( @@ -2386,6 +2733,7 @@ def assume_role( _write_assume_journal(journal) try: destination = cast("Path", data["destination"]) + _validate_assume_recovery(journal, roll_forward=False) credential_values = { "aws_access_key_id": credentials["AccessKeyId"], "aws_secret_access_key": credentials["SecretAccessKey"], @@ -2402,7 +2750,7 @@ def assume_role( _install_assume_destination(journal) journal["phase"] = "destination-installed" _write_assume_journal(journal) - _finish_assume_source(journal) + cache_residue = _finish_assume_source(journal) journal["phase"] = "source-removed" _write_assume_journal(journal) _commit() @@ -2434,6 +2782,17 @@ def assume_role( policyProvenance=metadata.get("policy_provenance"), ecrResidue=failures, ) + if cache_residue: + public["browserCacheResidue"] = cache_residue + return _configs.Result( + "ASSUME_ROLE_BROWSER_CACHE_RESIDUE", + "Role credentials were installed and the broad source credentials were " + "removed, but a browser cache with unknown ownership was preserved.", + 1, + "stderr", + public, + kind="warning", + ) if failures: return _configs.Result( "ASSUME_ROLE_ECR_RESIDUE", @@ -3161,73 +3520,294 @@ def _apply_profile_section_plans( _write_ini(path, parser) -def _tracked_login_cache_plan( - session: dict[str, Any], destination: Path, *, force: bool -) -> tuple[list[Path], list[Path], list[dict[str, str]]]: +def _upgrade_legacy_browser_lineage( + destination: Path, + profile: str, + root: Path, + removals: list[dict[str, Any]], +) -> dict[str, Any] | None: + """Bind an exact legacy cache claim to stable lineage when AWS can verify it.""" + config = destination / "config" + try: + candidate = _browser_cache_lineage(config, profile, root) + except _configs.OperationalError: + return None + owned = next( + ( + claim + for claim in removals + if _canonical_path(Path(str(claim.get("path", "")))) + == _canonical_path(Path(str(candidate["path"]))) + and claim.get("whole_digest") == candidate.get("whole_digest") + ), + None, + ) + if owned is None: + return None + try: + with _aws_environment( + destination / "config", destination / "credentials", root + ): + active = boto3.Session(profile_name=profile) + identity = _identity(active, label="legacy browser login cache ownership") + current = _browser_cache_lineage(config, profile, root, identity=identity) + except _configs.OperationalError: + return None + if not _same_browser_lineage(candidate, current) or current.get( + "whole_digest" + ) != owned.get("whole_digest"): + return None + return current + + +def _tracked_login_cache_plan( # noqa: PLR0911 + session: dict[str, Any], + destination: Path, + profile: str = "default", + *, + force: bool, +) -> tuple[ + list[Path], list[dict[str, Any]], list[dict[str, str]], dict[str, Any] | None +]: if session.get("auth_method") not in { "browser-native", "browser-cache-residue", "logout-residue", }: - return [], [], [] - configured_roots = session.get("login_cache_directories") or [ - str((destination / "login" / "cache").absolute()) - ] - allowed_roots = [Path(str(value)).absolute() for value in configured_roots] - fingerprints = session.get("login_cache_fingerprints", {}) - removals: list[Path] = [] - residue: list[dict[str, str]] = [] - for value in session.get("login_cache_files", []): - cache_file = Path(str(value)).absolute() - expected = fingerprints.get(str(cache_file)) - in_scope = any( - root == cache_file.parent or root in cache_file.parents - for root in allowed_roots - ) - if not in_scope: - residue.append( - {"path": str(cache_file), "reason": "outside tracked cache roots"} - ) - continue - if not cache_file.exists(): - continue + return [], [], [], None + stored = session.get("login_cache_lineage") + if isinstance(stored, dict) and stored.get("legacy_cas_only"): + path = _canonical_path(Path(str(stored.get("path", "")))) + roots = [_canonical_path(Path(str(stored.get("root", path.parent))))] + if not path.exists(): + return roots, [], [], None try: - current = _state.digest(cache_file.read_bytes()) + current_digest = _state.digest(path.read_bytes()) except OSError as error: - if force: - removals.append(cache_file) + legacy_residue = [{"path": str(path), "reason": f"unreadable: {error}"}] + else: + if current_digest == stored.get("whole_digest"): + return roots, [copy.deepcopy(stored)], [], None + legacy_residue = [ + { + "path": str(path), + "reason": "legacy cache fingerprint changed after login", + } + ] + if not force: + raise _configs.OperationalError( + "Tracked browser login cache changed after login; no logout changes " + "were made. Review the cache or retry with --force: " + f"{legacy_residue[0]['reason']}" + ) + return roots, [], legacy_residue, None + if isinstance(stored, dict) and stored.get("schema_version") == 1: + allowed_roots = [_canonical_path(Path(str(stored.get("root", ""))))] + else: + configured_roots = session.get("login_cache_directories") or [ + str((destination / "login" / "cache").absolute()) + ] + allowed_roots = [ + _canonical_path(Path(str(value))) for value in configured_roots + ] + removals: list[dict[str, Any]] = [] + residue: list[dict[str, str]] = [] + legacy_files = session.get("login_cache_files", []) + legacy_fingerprints = session.get("login_cache_fingerprints", {}) + if not isinstance(stored, dict) and isinstance(legacy_files, list): + for value in legacy_files: + path = _canonical_path(Path(str(value))) + in_scope = any( + root == path.parent or root in path.parents for root in allowed_roots + ) + expected = ( + legacy_fingerprints.get(str(path)) + if isinstance(legacy_fingerprints, dict) + else None + ) + if not in_scope: + residue.append( + {"path": str(path), "reason": "outside tracked cache roots"} + ) continue - residue.append({"path": str(cache_file), "reason": f"unreadable: {error}"}) - continue - if not isinstance(expected, str) or current != expected: - if force: - removals.append(cache_file) + if not path.exists(): + continue + try: + current_digest = _state.digest(path.read_bytes()) + except OSError as error: + residue.append({"path": str(path), "reason": f"unreadable: {error}"}) continue + if not isinstance(expected, str) or current_digest != expected: + residue.append( + { + "path": str(path), + "reason": "legacy cache fingerprint changed after login", + } + ) + continue + removals.append( + { + "schema_version": 0, + "legacy_cas_only": True, + "root": str(path.parent), + "path": str(path), + "whole_digest": current_digest, + } + ) + if residue and not force: + details = "; ".join(f"{item['path']}: {item['reason']}" for item in residue) + raise _configs.OperationalError( + "Tracked browser login cache changed after login; no logout changes " + f"were made. Review the cache or retry with --force: {details}" + ) + if legacy_files: + upgraded = _upgrade_legacy_browser_lineage( + destination, profile, allowed_roots[0], removals + ) + if upgraded is None: + return allowed_roots, removals, residue, None + upgraded_path = _canonical_path(Path(str(upgraded["path"]))) + removals = [ + claim + for claim in removals + if _canonical_path(Path(str(claim["path"]))) != upgraded_path + ] + removals.append(upgraded) + return allowed_roots, removals, residue, upgraded + config = destination / "config" + try: + root = allowed_roots[0] + current = _browser_cache_lineage(config, profile, root) + except _configs.OperationalError as error: + if isinstance(stored, dict): + try: + current = _current_browser_cache_content(stored) + except _configs.OperationalError: + residue.append({"path": str(allowed_roots[0]), "reason": str(error)}) + current = None + else: + if current.get("whole_digest") != stored.get("whole_digest"): + residue.append( + { + "path": str(current["path"]), + "reason": ( + "browser cache rotated after its profile was removed; " + "ownership cannot be reverified" + ), + } + ) + current = None + else: + residue.append({"path": str(allowed_roots[0]), "reason": str(error)}) + current = None + if current is not None: + if isinstance(stored, dict) and not _same_browser_lineage(stored, current): residue.append( - {"path": str(cache_file), "reason": "fingerprint changed after login"} + { + "path": str(current["path"]), + "reason": "browser cache belongs to a different login generation", + } ) - continue - removals.append(cache_file) + else: + # A whole-file digest change is expected during refresh. Establish the + # current caller before accepting the rotated bytes as the same owner. + needs_identity = not isinstance(stored, dict) or ( + current.get("whole_digest") != stored.get("whole_digest") + ) + identity: tuple[str, str, str] | None = None + if needs_identity: + try: + with _aws_environment( + destination / "config", + destination / "credentials", + root, + ): + active = boto3.Session(profile_name=profile) + identity = _identity( + active, label="browser login cache ownership" + ) + current = _browser_cache_lineage( + config, profile, root, identity=identity + ) + except _configs.OperationalError as error: + residue.append({"path": str(current["path"]), "reason": str(error)}) + current = None + if current is not None: + account = str( + (stored or {}).get("account") + or session.get("source_account") + or session.get("target_account") + or "" + ) + partition = str( + (stored or {}).get("partition") + or session.get("source_partition") + or session.get("target_partition") + or "" + ) + principal = str((stored or {}).get("principal") or "") + if identity is not None and ( + (account and identity[0] != account) + or (partition and identity[1] != partition) + or (principal and identity[2] != principal) + ): + residue.append( + { + "path": str(current["path"]), + "reason": "GetCallerIdentity does not match tracked browser lineage", + } + ) + current = None + elif isinstance(stored, dict) and not _same_browser_lineage( + stored, current + ): + residue.append( + { + "path": str(current["path"]), + "reason": "browser cache changed generation during verification", + } + ) + current = None + if current is not None: + if identity is not None: + current.update( + account=identity[0], + partition=identity[1], + principal=identity[2], + ) + elif isinstance(stored, dict): + current.update( + account=stored.get("account", ""), + partition=stored.get("partition", ""), + principal=stored.get("principal", ""), + ) + removals.append(current) if residue and not force: details = "; ".join(f"{item['path']}: {item['reason']}" for item in residue) raise _configs.OperationalError( "Tracked browser login cache changed after login; no logout changes were " f"made. Review the cache or retry with --force: {details}" ) - return allowed_roots, removals, residue + return allowed_roots, removals, residue, current def _remove_tracked_login_cache( - removals: list[Path], residue: list[dict[str, str]], *, force: bool + removals: list[dict[str, Any]], residue: list[dict[str, str]], *, force: bool ) -> list[dict[str, str]]: - for cache_file in removals: + del force # Force never authorizes deleting an unknown or different generation. + for claim in removals: + cache_file = Path(str(claim["path"])) try: + if _state.digest(cache_file.read_bytes()) != claim.get("whole_digest"): + residue.append( + { + "path": str(cache_file), + "reason": "browser cache changed during compare-and-delete", + } + ) + continue cache_file.unlink() except OSError as error: - if not force: - raise _configs.OperationalError( - f"Unable to remove tracked browser login cache {cache_file}: {error}" - ) from error residue.append( {"path": str(cache_file), "reason": f"remove failed: {error}"} ) @@ -3297,12 +3877,11 @@ def _logout_key(key: str, args: Any) -> dict[str, Any]: force = bool(getattr(args, "force", False)) registries = list(session.get("ecr", [])) plans = _profile_section_plans(session, destination, profile, force=force) - cache_roots, cache_removals, cache_residue = _tracked_login_cache_plan( - session, destination, force=force + _cache_roots, cache_removals, cache_residue, _ = _tracked_login_cache_plan( + session, destination, profile, force=force ) journal = _begin( - [destination / "credentials", destination / "config", _state.sessions_path()], - cache_roots=cache_roots, + [destination / "credentials", destination / "config", _state.sessions_path()] ) residual: dict[str, Any] = { "destination": str(destination), @@ -3314,20 +3893,27 @@ def _logout_key(key: str, args: Any) -> dict[str, Any]: "ecr": registries, "ecr_engine": session.get("ecr_engine"), } + lineage = session.get("login_cache_lineage") + pending_cache = [ + *cache_residue, + *( + { + "path": str(claim["path"]), + "reason": "browser cache cleanup pending", + } + for claim in cache_removals + ), + ] + if pending_cache: + residual.update( + auth_method="logout-residue" if registries else "browser-cache-residue", + login_cache_residue=pending_cache, + ) + if isinstance(lineage, dict): + residual["login_cache_lineage"] = copy.deepcopy(lineage) try: _apply_profile_section_plans(plans) - cache_residue = _remove_tracked_login_cache( - cache_removals, cache_residue, force=force - ) - if cache_residue: - residual.update( - auth_method="logout-residue" if registries else "browser-cache-residue", - login_cache_residue=cache_residue, - login_cache_directories=[str(path) for path in cache_roots], - login_cache_files=[item["path"] for item in cache_residue], - login_cache_fingerprints={}, - ) - if registries or cache_residue: + if registries or pending_cache: sessions[key] = residual else: del sessions[key] @@ -3336,6 +3922,26 @@ def _logout_key(key: str, args: Any) -> dict[str, Any]: except Exception: _rollback(journal) raise + cache_residue = _remove_tracked_login_cache( + cache_removals, cache_residue, force=force + ) + sessions = _state.load_sessions() + if cache_residue: + residual.update( + auth_method="logout-residue" if registries else "browser-cache-residue", + login_cache_residue=cache_residue, + ) + if isinstance(lineage, dict): + residual["login_cache_lineage"] = copy.deepcopy(lineage) + sessions[key] = residual + elif registries: + residual.pop("login_cache_residue", None) + residual.pop("login_cache_lineage", None) + residual["auth_method"] = "ecr-only" + sessions[key] = residual + else: + sessions.pop(key, None) + _state.save_sessions(sessions) if registries and not keep_ecr: engine = cast( "_configs.ContainerEngine", diff --git a/hacksaws/_state.py b/hacksaws/_state.py index 4e30add..a8c68aa 100644 --- a/hacksaws/_state.py +++ b/hacksaws/_state.py @@ -33,6 +33,7 @@ "iam", "session", "output", + "history", } NAMING_FIELDS = {"case", "prefix", "suffix", "enforcement"} NAMING_CASES = {"Pascal", "camel", "snake", "kebab"} @@ -78,6 +79,12 @@ def default_config() -> dict[str, Any]: "iam": {"path": "/hacksaws/"}, "session": {"packed_policy_warning": 80, "packed_policy_enforcement": "off"}, "output": {"color": "auto"}, + "history": { + "enabled": True, + "max_age": 90 * 24 * 60 * 60, + "max_entries": 10_000, + "max_bytes": 50 * 1024 * 1024, + }, } @@ -146,7 +153,7 @@ def _validate_config(data: object) -> dict[str, Any]: if type(data) is not dict: raise OperationalError("Hacksaws config must be a JSON object.") defaults = default_config() - for key in ("naming", "iam", "session", "output"): + for key in ("naming", "iam", "session", "output", "history"): data.setdefault(key, deepcopy(defaults[key])) unknown = set(data) - TOP_LEVEL if unknown: @@ -251,6 +258,21 @@ def _validate_foundation_settings(data: dict[str, Any]) -> None: or output["color"] not in COLOR_MODES ): raise OperationalError("Config output.color must be auto, always, or never.") + history = data["history"] + if type(history) is not dict or set(history) != { + "enabled", + "max_age", + "max_entries", + "max_bytes", + }: + raise OperationalError("Config history has an unsupported shape.") + if type(history["enabled"]) is not bool: + raise OperationalError("Config history.enabled must be true or false.") + for field in ("max_age", "max_entries", "max_bytes"): + if type(history[field]) is not int or history[field] < 1: + raise OperationalError( + f"Config history.{field} must be a positive integer." + ) def _validate_resources(data: dict[str, Any]) -> None: @@ -469,6 +491,22 @@ def _validate_resources(data: dict[str, Any]) -> None: "description": "Color mode: auto, always, or never.", "default": "auto", }, + "history.enabled": { + "description": "Record redacted command outcomes in local history.", + "default": True, + }, + "history.max_age": { + "description": "Maximum history age in seconds before routine pruning.", + "default": 90 * 24 * 60 * 60, + }, + "history.max_entries": { + "description": "Maximum retained resolved command records.", + "default": 10_000, + }, + "history.max_bytes": { + "description": "Maximum logical history size in bytes.", + "default": 50 * 1024 * 1024, + }, "accounts..credential_target": { "description": "Per-account credential target used only when explicitly selected.", }, diff --git a/hacksaws/tests/test_assume_role.py b/hacksaws/tests/test_assume_role.py index 39352f5..6a0cc47 100644 --- a/hacksaws/tests/test_assume_role.py +++ b/hacksaws/tests/test_assume_role.py @@ -325,6 +325,94 @@ def test_distinct_profile_in_same_aws_files_does_not_back_up_source_session_secr assert "authenticated-token" not in persisted +def test_keep_source_upgrades_valid_legacy_browser_cache_lineage_after_commit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = _home(tmp_path, monkeypatch) + source = home / ".aws-source" + destination = home / ".aws-agent" + login_session = f"arn:aws:iam::{ACCOUNT}:user/browser-user" + cache_root = source / "login" / "cache" + cache = cache_root / f"{_state.digest(login_session.encode())}.json" + journal = _sessions._begin( + [source / "credentials", source / "config", _state.sessions_path()] + ) + _ini( + source / "config", + { + "profile admin": { + "login_session": login_session, + "region": "us-west-2", + } + }, + ) + cache.parent.mkdir(parents=True) + cache.write_text( + json.dumps( + { + "accessToken": { + "accountId": ACCOUNT, + "accessKeyId": "access", + "secretAccessKey": "secret", + "sessionToken": "token", + "expiresAt": "2030-01-01T00:00:00Z", + }, + "refreshToken": "refresh", + "clientId": "client-generation", + "dpopKey": "dpop-generation", + } + ), + encoding="utf-8", + ) + _sessions._record( + source, + "admin", + { + "source_account": ACCOUNT, + "target_account": ACCOUNT, + "role": None, + "boundary": None, + "policy": None, + "policy_provenance": "legacy AWS-native login_session", + "expires_at": None, + "login_cache_files": [str(cache.absolute())], + "login_cache_directories": [str(cache_root.absolute())], + "login_cache_fingerprints": { + str(cache.absolute()): _state.digest(cache.read_bytes()) + }, + }, + journal, + method="browser-native", + ) + _sessions._commit() + + args = _args( + source, + to_directory=str(destination), + to_profile="debug", + keep_source=True, + ) + session_patch, identity_patch = _identity_patches() + with ( + session_patch, + identity_patch, + patch("hacksaws._sessions._assume", return_value=_final()), + ): + result = _sessions.assume_role(_configs.Context(args)) + + assert result.code == "ASSUME_ROLE" + assert cache.exists() + source_session = _state.load_sessions()[f"{source.absolute()}::admin"] + assert source_session["login_cache_lineage"]["schema_version"] == 1 + assert source_session["login_cache_lineage"]["path"] == str(cache.absolute()) + for legacy_key in ( + "login_cache_files", + "login_cache_directories", + "login_cache_fingerprints", + ): + assert legacy_key not in source_session + + def test_self_assume_preserves_original_chain_and_never_backs_up_authenticated_tier( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -580,6 +668,133 @@ def change_boundary( assert not _sessions._journal_path().exists() +def test_revalidation_refreshes_both_browser_cache_plans_without_stale_deletion( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _home(tmp_path, monkeypatch) + source = tmp_path / "source" + destination = tmp_path / "destination" + source_record = {"auth_method": "browser-native", "profile": "admin"} + destination_record = {"auth_method": "browser-native", "profile": "debug"} + source_key = f"{source.absolute()}::admin" + destination_key = f"{destination.absolute()}::debug" + _state.save_sessions( + {source_key: source_record, destination_key: destination_record} + ) + args = _args( + source, + to_directory=str(destination), + to_profile="debug", + keep_source=True, + ) + absent = { + "exists": False, + "fingerprint": _state.digest(b"hacksaws:absent-section"), + } + old_source = tmp_path / "old-source-cache.json" + old_destination = tmp_path / "old-destination-cache.json" + old_source.write_bytes(b"source-refreshed-after-preview") + old_destination.write_bytes(b"destination-refreshed-after-preview") + new_source = tmp_path / "new-source-cache.json" + new_destination = tmp_path / "new-destination-cache.json" + new_source.write_bytes(b"source-current") + new_destination.write_bytes(b"destination-current") + source_claim = { + "path": str(new_source), + "whole_digest": _state.digest(new_source.read_bytes()), + } + destination_claim = { + "path": str(new_destination), + "whole_digest": _state.digest(new_destination.read_bytes()), + } + data: dict[str, Any] = { + "source": source, + "destination": destination, + "source_profile": "admin", + "destination_profile": "debug", + "source_expected": {"credentials": absent, "config": absent}, + "destination_expected": {"credentials": absent, "config": absent}, + "source_record": source_record, + "destination_record": destination_record, + "source_key": source_key, + "destination_key": destination_key, + "source_session_expected": _sessions._session_record_state(source_record), + "destination_session_expected": _sessions._session_record_state( + destination_record + ), + "source_cache": ( + [old_source.parent], + [ + { + "path": str(old_source), + "whole_digest": "preview-source-digest", + } + ], + [], + None, + ), + "destination_cache": ( + [old_destination.parent], + [ + { + "path": str(old_destination), + "whole_digest": "preview-destination-digest", + } + ], + [], + None, + ), + "cache_expected": {"preview": "stale"}, + "keep_source": True, + "same_key": False, + "hacksaws_config_expected": _sessions._file_fingerprint( + _state.root() / "config.json" + ), + "policy_source_expected": None, + } + prepared = _sessions.AssumeRolePlan( + data, _sessions._assume_arguments_fingerprint(args) + ) + refreshed_source: tuple[ + list[Path], list[dict[str, Any]], list[dict[str, str]], dict[str, Any] + ] = ( + [new_source.parent], + [source_claim], + [], + {"schema_version": 1, **source_claim}, + ) + refreshed_destination: tuple[ + list[Path], list[dict[str, Any]], list[dict[str, str]], dict[str, Any] + ] = ( + [new_destination.parent], + [destination_claim], + [], + {"schema_version": 1, **destination_claim}, + ) + with ( + patch( + "hacksaws._sessions._tracked_login_cache_plan", + side_effect=[refreshed_source, refreshed_destination], + ) as refresh, + patch( + "hacksaws._sessions._state.load_sessions", + return_value={ + source_key: source_record, + destination_key: destination_record, + }, + ), + ): + _sessions._revalidate_assume_plan(_configs.Context(args), prepared) + + assert refresh.call_count == 2 + assert data["source_cache"][1] == [] + assert data["source_cache"][3] == refreshed_source[3] + assert data["destination_cache"] == refreshed_destination + assert data["cache_expected"] == { + str(new_destination.resolve()): _state.digest(new_destination.read_bytes()) + } + + def test_prepared_local_policy_rejects_file_change_after_sts( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -825,13 +1040,13 @@ def test_installed_recovery_rejects_browser_cache_drift( _install_crash_destination(journal) cache.write_text("externally-changed-token", encoding="utf-8") - with pytest.raises(_configs.OperationalError, match="manual review"): + with pytest.raises(_configs.OperationalError, match="browser-cache-residue"): _sessions._recover_assume_journal(journal) assert cache.read_text(encoding="utf-8") == "externally-changed-token" assert _sessions._read_ini(source / "credentials")["admin"][ "aws_access_key_id" - ] == ("AUTHENTICATED") - assert _sessions._journal_path().exists() + ] == ("ORIGINAL") + assert not _sessions._journal_path().exists() def test_installed_recovery_retries_owned_cache_removal_without_restoring_auth( @@ -869,16 +1084,16 @@ def fail_cache_unlink(path: Path, *args: object, **kwargs: object) -> None: with ( patch.object(Path, "unlink", fail_cache_unlink), - pytest.raises(_configs.OperationalError, match="journal was retained"), + pytest.raises(_configs.OperationalError, match="browser-cache-residue"), ): _sessions._recover_assume_journal(journal) assert cache.exists() assert _sessions._read_ini(source / "credentials")["admin"][ "aws_access_key_id" ] == ("ORIGINAL") - assert _sessions._journal_path().exists() + assert not _sessions._journal_path().exists() - _sessions._recover_assume_journal(journal) + _sessions._logout_key(source_key, _args(source, force=True)) assert not cache.exists() assert not _sessions._journal_path().exists() assert _sessions._read_ini(source / "credentials")["admin"][ @@ -1227,7 +1442,7 @@ def fail_locked(path: Path, *args: object, **kwargs: object) -> None: } ) assert {item["reason"] for item in residue} == { - "fingerprint changed", + "legacy cache fingerprint changed", "remove failed: locked by another process", } assert changed.exists() diff --git a/hacksaws/tests/test_browser_lineage.py b/hacksaws/tests/test_browser_lineage.py new file mode 100644 index 0000000..08e9dd5 --- /dev/null +++ b/hacksaws/tests/test_browser_lineage.py @@ -0,0 +1,891 @@ +"""Security coverage for browser cache lineage and secret-safe auth input.""" + +from __future__ import annotations + +import argparse +import io +import json +from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _history +from hacksaws import _sessions +from hacksaws import _state + +ACCOUNT = "123456789012" +PRINCIPAL = f"arn:aws:iam::{ACCOUNT}:user/browser-user" + + +def _cache( + directory: Path, + profile: str = "dev", + *, + login_session: str = PRINCIPAL, + client_id: str = "client-generation-one", + dpop: str = ( + "-----BEGIN PRIVATE KEY-----\ndpop-generation-one\n-----END PRIVATE KEY-----" + ), + access_key: str = "ACCESS-SENTINEL", + refresh: str = "REFRESH-SENTINEL", +) -> tuple[Path, dict[str, object]]: + config = _sessions._read_ini(directory / "config") + config[_sessions._section(profile, config=True)] = { + "login_session": login_session, + "region": "us-east-1", + } + _sessions._write_ini(directory / "config", config) + root = directory / "login" / "cache" + root.mkdir(parents=True, exist_ok=True) + path = root / f"{_state.digest(login_session.encode())}.json" + token: dict[str, object] = { + "accessToken": { + "accessKeyId": access_key, + "secretAccessKey": "SECRET-SENTINEL", + "sessionToken": "SESSION-SENTINEL", + "accountId": ACCOUNT, + "expiresAt": "2030-01-01T00:00:00Z", + }, + "refreshToken": refresh, + "clientId": client_id, + "dpopKey": dpop, + } + path.write_text(json.dumps(token), encoding="utf-8") + return path, token + + +def _browser_session(directory: Path, lineage: dict[str, object]) -> dict[str, object]: + return { + "destination": str(directory.absolute()), + "profile": "dev", + "auth_method": "browser-native", + "source_account": ACCOUNT, + "source_partition": "aws", + "login_cache_lineage": lineage, + "backup": [], + "section_backup": {}, + "ecr": [], + } + + +def test_lineage_is_exact_and_contains_no_browser_secrets(tmp_path: Path) -> None: + path, _ = _cache(tmp_path) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + encoded = json.dumps(lineage) + assert lineage["path"] == str(path.resolve()) + assert lineage["cache_key"] == path.stem + for sentinel in ( + "ACCESS-SENTINEL", + "SECRET-SENTINEL", + "SESSION-SENTINEL", + "REFRESH-SENTINEL", + "dpop-generation-one", + "client-generation-one", + ): + assert sentinel not in encoded + + +def test_refresh_rotation_is_accepted_after_identity_verification( + tmp_path: Path, +) -> None: + path, token = _cache(tmp_path) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + token["refreshToken"] = "ROTATED-REFRESH-SENTINEL" + token["accessToken"] = { + **token["accessToken"], # type: ignore[dict-item] + "accessKeyId": "ROTATED-ACCESS-SENTINEL", + "expiresAt": "2031-01-01T00:00:00Z", + } + path.write_text(json.dumps(token), encoding="utf-8") + with ( + patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), + patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", PRINCIPAL)), + ): + _roots, removals, residue, upgraded = _sessions._tracked_login_cache_plan( + _browser_session(tmp_path, lineage), tmp_path, "dev", force=False + ) + assert residue == [] + assert removals[0]["whole_digest"] == _state.digest(path.read_bytes()) + assert upgraded == removals[0] + + +def test_force_preserves_a_demonstrably_different_generation(tmp_path: Path) -> None: + path, _ = _cache(tmp_path) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + _cache(tmp_path, dpop="different-DPoP-generation") + _roots, removals, residue, _ = _sessions._tracked_login_cache_plan( + _browser_session(tmp_path, lineage), tmp_path, "dev", force=True + ) + assert removals == [] + assert "different login generation" in residue[0]["reason"] + _sessions._remove_tracked_login_cache(removals, residue, force=True) + assert path.exists() + + +def test_compare_and_delete_preserves_concurrent_refresh(tmp_path: Path) -> None: + path, token = _cache(tmp_path) + claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + claim.update(config=str((tmp_path / "config").resolve()), profile="dev") + token["refreshToken"] = "CONCURRENT-ROTATION" + path.write_text(json.dumps(token), encoding="utf-8") + residue = _sessions._remove_tracked_login_cache([claim], [], force=True) + assert residue[0]["reason"] == "browser cache changed during compare-and-delete" + assert path.exists() + + +def test_exact_claim_removal_handles_missing_refresh_and_replacement( + tmp_path: Path, +) -> None: + path, token = _cache(tmp_path) + claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + claim.update(config=str((tmp_path / "config").resolve()), profile="dev") + + path.unlink() + assert _sessions._remove_browser_cache_claim(claim, strict=True) is None + + path.write_text(json.dumps(token), encoding="utf-8") + token["refreshToken"] = "REFRESH-ROTATED-BEFORE-ROLLBACK" + path.write_text(json.dumps(token), encoding="utf-8") + assert _sessions._remove_browser_cache_claim(claim, strict=True) is None + assert not path.exists() + + path, _ = _cache(tmp_path) + replacement_claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + replacement_claim.update(config=str((tmp_path / "config").resolve()), profile="dev") + _cache(tmp_path, dpop="replacement") + with pytest.raises(_configs.OperationalError, match="different login generation"): + _sessions._remove_browser_cache_claim(replacement_claim, strict=True) + residue = _sessions._remove_browser_cache_claim(replacement_claim, strict=False) + assert residue is not None + assert residue["reason"] == "browser cache belongs to a different login generation" + assert path.exists() + + +def test_exact_claim_removal_reports_missing_config_and_unlink_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path, _ = _cache(tmp_path) + claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + claim.update(config=str((tmp_path / "config").resolve()), profile="dev") + (tmp_path / "config").unlink() + residue = _sessions._remove_browser_cache_claim(claim, strict=False) + assert residue is not None + assert "no login_session" in residue["reason"] + with pytest.raises(_configs.OperationalError, match="no login_session"): + _sessions._remove_browser_cache_claim(claim, strict=True) + + _cache(tmp_path) + + def locked(_path: Path) -> None: + raise PermissionError("locked") + + monkeypatch.setattr(Path, "unlink", locked) + residue = _sessions._remove_browser_cache_claim(claim, strict=False) + assert residue is not None + assert residue["reason"] == "remove failed: locked" + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("missing-session", "no login_session"), + ("invalid-json", "Unable to read"), + ("non-object", "not a JSON object"), + ("missing-fields", "missing stable lineage"), + ("wrong-account", "does not match GetCallerIdentity"), + ], +) +def test_lineage_rejects_incomplete_or_cross_account_cache( + tmp_path: Path, mutation: str, message: str +) -> None: + path, token = _cache(tmp_path) + if mutation == "missing-session": + config = _sessions._read_ini(tmp_path / "config") + config["profile dev"].pop("login_session") + _sessions._write_ini(tmp_path / "config", config) + elif mutation == "invalid-json": + path.write_text("{broken", encoding="utf-8") + elif mutation == "non-object": + path.write_text("[]", encoding="utf-8") + elif mutation == "missing-fields": + path.write_text(json.dumps({"accessToken": {}}), encoding="utf-8") + else: + token["accessToken"] = { + **token["accessToken"], # type: ignore[dict-item] + "accountId": "210987654321", + } + path.write_text(json.dumps(token), encoding="utf-8") + with pytest.raises(_configs.OperationalError, match=message): + _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + + +def test_browser_rollback_claim_is_secret_free_and_exact( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "hacksaws-home")) + path, _ = _cache(tmp_path) + original_config = (tmp_path / "config").read_bytes() + journal = _sessions._begin([tmp_path / "config"]) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + _sessions._record_browser_cache_claim(journal, lineage, tmp_path / "config", "dev") + serialized = _sessions._journal_path().read_text(encoding="utf-8") + assert "REFRESH-SENTINEL" not in serialized + assert "SESSION-SENTINEL" not in serialized + (tmp_path / "config").write_text("[profile dev]\nregion=x\n", encoding="utf-8") + # Rollback evaluates the claim before restoring the config, so retain the + # login_session until the exact owned file is removed. + (tmp_path / "config").write_bytes(original_config) + _sessions._rollback(journal) + assert not path.exists() + assert (tmp_path / "config").read_bytes() == original_config + + +def test_browser_rollback_preserves_a_replaced_generation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "hacksaws-home")) + path, _ = _cache(tmp_path) + journal = _sessions._begin([tmp_path / "config"]) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + _sessions._record_browser_cache_claim(journal, lineage, tmp_path / "config", "dev") + _cache(tmp_path, dpop="replacement-generation") + with pytest.raises(_configs.OperationalError, match="different login generation"): + _sessions._rollback(journal) + assert path.exists() + assert _sessions._journal_path().exists() + + +def test_unchanged_lineage_does_not_require_network_identity(tmp_path: Path) -> None: + path, _ = _cache(tmp_path) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + with patch("hacksaws._sessions._identity") as identity: + _roots, removals, residue, _ = _sessions._tracked_login_cache_plan( + _browser_session(tmp_path, lineage), tmp_path, "dev", force=False + ) + identity.assert_not_called() + assert removals == [lineage] + assert residue == [] + + +def test_refresh_with_wrong_sts_identity_becomes_residue(tmp_path: Path) -> None: + path, token = _cache(tmp_path) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + token["refreshToken"] = "rotated" + path.write_text(json.dumps(token), encoding="utf-8") + with ( + patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), + patch( + "hacksaws._sessions._identity", + return_value=(ACCOUNT, "aws", f"arn:aws:iam::{ACCOUNT}:user/other"), + ), + ): + _roots, removals, residue, _ = _sessions._tracked_login_cache_plan( + _browser_session(tmp_path, lineage), tmp_path, "dev", force=True + ) + assert removals == [] + assert "GetCallerIdentity" in residue[0]["reason"] + assert path.exists() + + +def test_assume_cache_validation_accepts_refresh_but_rejects_generation_change( + tmp_path: Path, +) -> None: + path, token = _cache(tmp_path) + claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + original_digest = claim["whole_digest"] + token["refreshToken"] = "ROTATED-DURING-ASSUME" + path.write_text(json.dumps(token), encoding="utf-8") + journal = {"cache": [claim]} + _sessions._validate_assume_cache(journal, allow_missing=True) + assert claim["whole_digest"] != original_digest + stale = {**claim, "whole_digest": str(original_digest)} + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._validate_assume_cache({"cache": [stale]}, allow_missing=False) + + _cache(tmp_path, dpop="different-assume-generation") + changed = {"cache": [claim]} + _sessions._validate_assume_cache(changed, allow_missing=True) + assert claim["residue_reason"] == "different browser login generation" + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._validate_assume_cache( + {"cache": [{**claim, "residue_reason": None}]}, allow_missing=False + ) + + +def test_assume_cache_removal_deletes_same_lineage_and_preserves_unknown( + tmp_path: Path, +) -> None: + path, token = _cache(tmp_path) + claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + token["refreshToken"] = "ROTATED-BEFORE-FINAL-CLEANUP" + path.write_text(json.dumps(token), encoding="utf-8") + assert _sessions._remove_assume_cache({"cache": [claim]}) == [] + assert not path.exists() + + path, _ = _cache(tmp_path) + different = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + _cache(tmp_path, dpop="different-final-generation") + residue = _sessions._remove_assume_cache({"cache": [different]}) + assert residue == [ + {"path": str(path.absolute()), "reason": "different browser login generation"} + ] + assert path.exists() + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._remove_assume_cache({"cache": [different]}, strict=True) + + already_residue = {**different, "residue_reason": "ownership uncertain"} + assert _sessions._remove_assume_cache({"cache": [already_residue]}) == [ + {"path": str(path.absolute()), "reason": "ownership uncertain"} + ] + + +def test_exact_claim_and_assume_cleanup_close_compare_delete_races( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path, token = _cache(tmp_path) + claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + claim.update(config=str((tmp_path / "config").resolve()), profile="dev") + original_current_claim = _sessions._current_browser_cache_claim + + def rotate_after_claim_read(value: dict[str, object]) -> dict[str, object]: + current = original_current_claim(value) + token["refreshToken"] = "RACE-AFTER-CLAIM-READ" + path.write_text(json.dumps(token), encoding="utf-8") + return current + + monkeypatch.setattr( + _sessions, "_current_browser_cache_claim", rotate_after_claim_read + ) + with pytest.raises(_configs.OperationalError, match="compare-and-delete"): + _sessions._remove_browser_cache_claim(claim, strict=True) + assert path.exists() + + monkeypatch.setattr( + _sessions, "_current_browser_cache_claim", original_current_claim + ) + current_claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + original_content = _sessions._current_browser_cache_content + rotation = 0 + + def rotate_after_content_read(value: dict[str, object]) -> dict[str, object]: + nonlocal rotation + current = original_content(value) + rotation += 1 + token["refreshToken"] = f"RACE-AFTER-CONTENT-READ-{rotation}" + path.write_text(json.dumps(token), encoding="utf-8") + return current + + monkeypatch.setattr( + _sessions, "_current_browser_cache_content", rotate_after_content_read + ) + residue = _sessions._remove_assume_cache({"cache": [current_claim]}) + assert residue[0]["reason"] == "cache changed during compare-and-delete" + assert path.exists() + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._remove_assume_cache({"cache": [current_claim]}, strict=True) + + +def test_strict_cache_removal_reports_unlink_and_content_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path, _ = _cache(tmp_path) + claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + claim.update(config=str((tmp_path / "config").resolve()), profile="dev") + + def locked(_path: Path) -> None: + raise PermissionError("locked") + + monkeypatch.setattr(Path, "unlink", locked) + with pytest.raises( + _configs.OperationalError, match="Unable to remove browser cache" + ): + _sessions._remove_browser_cache_claim(claim, strict=True) + with pytest.raises( + _configs.OperationalError, match="Unable to remove owned browser login cache" + ): + _sessions._remove_assume_cache({"cache": [claim]}, strict=True) + + monkeypatch.setattr( + _sessions, + "_current_browser_cache_content", + lambda _claim: (_ for _ in ()).throw( + _configs.OperationalError("content unreadable") + ), + ) + residue = _sessions._remove_assume_cache({"cache": [claim]}) + assert residue[0]["reason"] == "unreadable: content unreadable" + with pytest.raises(_configs.OperationalError, match="manual review"): + _sessions._remove_assume_cache({"cache": [claim]}, strict=True) + + +def test_legacy_lineage_plan_is_cas_only_even_with_force(tmp_path: Path) -> None: + path = tmp_path / "legacy.json" + root = path.parent + session = { + "auth_method": "browser-cache-residue", + "login_cache_lineage": { + "schema_version": 0, + "legacy_cas_only": True, + "root": str(root), + "path": str(path), + "whole_digest": _state.digest(b"owned"), + }, + } + roots, removals, residue, upgraded = _sessions._tracked_login_cache_plan( + session, tmp_path, "dev", force=False + ) + assert roots == [root.resolve()] + assert removals == residue == [] + assert upgraded is None + + path.write_bytes(b"changed") + with pytest.raises(_configs.OperationalError, match="changed after login"): + _sessions._tracked_login_cache_plan(session, tmp_path, "dev", force=False) + _roots, removals, residue, _ = _sessions._tracked_login_cache_plan( + session, tmp_path, "dev", force=True + ) + assert removals == [] + assert residue[0]["reason"] == "legacy cache fingerprint changed after login" + + path.write_bytes(b"owned") + _roots, removals, residue, _ = _sessions._tracked_login_cache_plan( + session, tmp_path, "dev", force=False + ) + assert removals[0]["legacy_cas_only"] is True + assert residue == [] + + +def test_removed_profile_and_failed_or_racing_identity_preserve_cache( + tmp_path: Path, +) -> None: + path, token = _cache(tmp_path) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + session = _browser_session(tmp_path, lineage) + (tmp_path / "config").unlink() + token["refreshToken"] = "ROTATED-WITHOUT-PROFILE" + path.write_text(json.dumps(token), encoding="utf-8") + _roots, removals, residue, _ = _sessions._tracked_login_cache_plan( + session, tmp_path, "dev", force=True + ) + assert removals == [] + assert "ownership cannot be reverified" in residue[0]["reason"] + + path, token = _cache(tmp_path) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + session = _browser_session(tmp_path, lineage) + token["refreshToken"] = "ROTATED-BEFORE-FAILED-IDENTITY" + path.write_text(json.dumps(token), encoding="utf-8") + with ( + patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), + patch( + "hacksaws._sessions._identity", + side_effect=_configs.OperationalError("identity unavailable"), + ), + ): + _roots, removals, residue, _ = _sessions._tracked_login_cache_plan( + session, tmp_path, "dev", force=True + ) + assert removals == [] + assert residue[0]["reason"] == "identity unavailable" + + def change_generation_during_identity( + _session: object, *, label: str + ) -> tuple[str, str, str]: + assert label == "browser login cache ownership" + _cache(tmp_path, dpop="GENERATION-CHANGED-DURING-IDENTITY") + return ACCOUNT, "aws", PRINCIPAL + + path, token = _cache(tmp_path) + lineage = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + token["refreshToken"] = "ROTATED-BEFORE-RACING-IDENTITY" + path.write_text(json.dumps(token), encoding="utf-8") + with ( + patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), + patch( + "hacksaws._sessions._identity", + side_effect=change_generation_during_identity, + ), + ): + _roots, removals, residue, _ = _sessions._tracked_login_cache_plan( + _browser_session(tmp_path, lineage), tmp_path, "dev", force=True + ) + assert removals == [] + assert ( + residue[0]["reason"] == "browser cache changed generation during verification" + ) + + +def test_final_tracked_cache_unlink_failure_is_residue( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path, _ = _cache(tmp_path) + claim = _sessions._browser_cache_lineage( + tmp_path / "config", + "dev", + path.parent, + identity=(ACCOUNT, "aws", PRINCIPAL), + ) + + def locked(_path: Path) -> None: + raise PermissionError("locked") + + monkeypatch.setattr(Path, "unlink", locked) + residue = _sessions._remove_tracked_login_cache([claim], [], force=True) + assert residue == [{"path": str(path), "reason": "remove failed: locked"}] + + +def test_original_file_reconstructs_only_persistent_managed_section( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + aws = tmp_path / "aws" + config = aws / "config" + parser = _sessions._read_ini(config) + parser["profile dev"] = { + "region": "temporary-region", + "login_session": "temporary-browser-session", + } + parser["profile untouched"] = {"region": "eu-west-1"} + _sessions._write_ini(config, parser) + key = f"{aws.absolute()}::dev" + session = { + "section_backup": { + "config": { + "original": { + "exists": True, + "values": {"region": "us-west-2"}, + } + } + }, + "backup": [], + } + _state.save_sessions({key: session}) + + reconstructed = _sessions._original_file(config, "dev") + assert reconstructed is not None + assert b"temporary-browser-session" not in reconstructed + assert b"region = us-west-2" in reconstructed + assert b"profile untouched" in reconstructed + + session["section_backup"]["config"]["original"] = { # type: ignore[index] + "exists": False, + "values": {}, + } + _state.save_sessions({key: session}) + absent = _sessions._original_file(config, "dev") + assert absent is not None + assert b"profile dev" not in absent + assert b"profile untouched" in absent + + session["section_backup"]["config"]["original"] = { # type: ignore[index] + "exists": True, + "values": "invalid", + } + _state.save_sessions({key: session}) + with pytest.raises(_configs.OperationalError, match="original section values"): + _sessions._original_file(config, "dev") + + +def test_logout_exclusions_resolve_saved_target_directory_and_location_forms( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + direct_destination = tmp_path / "direct-destination" + same_destination = tmp_path / "same-destination" + location_source = tmp_path / "location-source" + location_destination = tmp_path / "location-destination" + config = { + "targets": { + "Direct": { + "source_directory": str(source), + "destination_directory": str(direct_destination), + "source_profile": "admin", + "destination_profile": "agent", + }, + "Located": { + "source_location": "source-location", + "destination_location": "destination-location", + "source_profile": "admin", + "destination_profile": "located-agent", + }, + "Same": { + "source_directory": str(same_destination), + "source_profile": "same-agent", + }, + } + } + + def aws_directory(location: object) -> Path: + return { + "source-location": location_source, + "destination-location": location_destination, + }[str(location)] + + with ( + patch("hacksaws._sessions._state.load_config", return_value=config), + patch("hacksaws._sessions._state.aws_directory", side_effect=aws_directory), + ): + assert _sessions.matches_logout_exclusion( + destination=str(direct_destination), + profile="agent", + excluded={"+direct"}, + ) + assert _sessions.matches_logout_exclusion( + destination=str(location_destination), + profile="located-agent", + excluded={"+LOCATED"}, + ) + assert _sessions.matches_logout_exclusion( + destination=str(same_destination), + profile="same-agent", + excluded={"+Same"}, + ) + + with patch( + "hacksaws._sessions._state.load_config", + side_effect=_configs.OperationalError("configuration unavailable"), + ): + assert _sessions.matches_logout_exclusion( + destination=str(direct_destination), + profile="agent", + excluded={"agent"}, + ) + + +def test_assume_second_positional_and_locked_conflicts() -> None: + parser = _cli._create_parser() + parsed = parser.parse_args(["assume", "admin", ".", "--role", "AgentSession"]) + _cli._validate_assume(parsed) + assert parsed.to_profile == "default" + for arguments in ( + ["assume", "admin", "agent", "--self", "--role", "AgentSession"], + ["assume", "admin", "agent", "--to-profile", "other", "--role", "AgentSession"], + ["assume", "admin", "agent", "--target", "Saved"], + ): + with pytest.raises(_configs.OperationalError, match="Positional DEST"): + _cli._validate_assume(parser.parse_args(arguments)) + + +def _mfa_args(**overrides: object) -> argparse.Namespace: + values: dict[str, object] = { + "action": "in", + "profile": "dev", + "target": None, + "mfa_code": None, + "mfa_code_stdin": False, + "json": False, + "directory": "~/.aws", + "aws_account_name": None, + "to": None, + "to_directory": None, + "to_profile": None, + "boundary": None, + "role": None, + "policy": None, + "external_id": None, + "account": None, + "session_name": None, + "region": None, + "duration": None, + "htl": None, + "mtl": None, + "stl": None, + "ecr": False, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def test_mfa_prompt_and_stdin_sources_never_require_argv_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_sessions, "is_expanded_login", lambda _args: True) + monkeypatch.setattr( + _sessions, + "mfa_login", + lambda context: _configs.Result( + "MFA_LOGIN", f"{context.args.mfa_code_source}:{context.args.mfa_code}" + ), + ) + terminal = MagicMock() + terminal.isatty.return_value = True + monkeypatch.setattr(_cli.sys, "stdin", terminal) + monkeypatch.setattr(_cli.getpass, "getpass", lambda _prompt: "123456") + prompted = _cli._run_mfa(_configs.Context(_mfa_args())) + assert prompted.message == "prompt:123456" + + monkeypatch.setattr(_cli.sys, "stdin", io.StringIO("654321\n")) + streamed = _cli._run_mfa(_configs.Context(_mfa_args(mfa_code_stdin=True))) + assert streamed.message == "stdin:654321" + + +def test_mfa_argument_stdin_and_prompt_history_retains_only_safe_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "history-home")) + _state.save_config(_state.default_config()) + monkeypatch.setattr(_sessions, "is_expanded_login", lambda _args: True) + monkeypatch.setattr( + _sessions, + "mfa_login", + lambda _context: _configs.Result("MFA_LOGIN", "authenticated"), + ) + terminal = MagicMock() + terminal.isatty.return_value = True + canaries = { + "argument": "111111-ARGUMENT-CANARY", + "stdin": "222222-STDIN-CANARY", + "prompt": "333333-PROMPT-CANARY", + } + + for source, canary in canaries.items(): + args = _mfa_args( + access_type="mfa", + mfa_code=canary if source == "argument" else None, + mfa_code_stdin=source == "stdin", + ) + handle = _history.begin(json_mode=False, interactive=source == "prompt") + _history.enrich(handle, args) + if source == "stdin": + monkeypatch.setattr(_cli.sys, "stdin", io.StringIO(f"{canary}\n")) + else: + monkeypatch.setattr(_cli.sys, "stdin", terminal) + monkeypatch.setattr( + _cli.getpass, "getpass", lambda _prompt, value=canary: value + ) + result = _cli._run_mfa(_configs.Context(args)) + _history.finish(handle, result) + + records = _history.list_records(limit=10) + safe_sources: set[str] = set() + for record in records: + safe = record["safe"] + assert isinstance(safe, dict) + safe_source_value = safe.get("mfaCodeSource") + assert isinstance(safe_source_value, str) + safe_sources.add(safe_source_value) + assert safe_sources == {"argument", "stdin", "prompt"} + raw_history = _history.database_path().read_bytes() + for canary in canaries.values(): + assert canary.encode() not in raw_history + + +def test_mfa_rejects_conflicting_or_empty_protected_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(_configs.OperationalError, match="not both"): + _cli._run_mfa( + _configs.Context(_mfa_args(mfa_code="123456", mfa_code_stdin=True)) + ) + monkeypatch.setattr(_cli.sys, "stdin", io.StringIO("\n")) + with pytest.raises(_configs.OperationalError, match="cannot be empty"): + _cli._run_mfa(_configs.Context(_mfa_args(mfa_code_stdin=True))) diff --git a/hacksaws/tests/test_coverage_closure.py b/hacksaws/tests/test_coverage_closure.py index 6ea7497..968a040 100644 --- a/hacksaws/tests/test_coverage_closure.py +++ b/hacksaws/tests/test_coverage_closure.py @@ -129,7 +129,9 @@ def test_typed_marker_and_build_metadata() -> None: def test_prettier_wrapper_forwards_paths_without_scanning_ignored_cache() -> None: git_result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( - ["git"], 0, b"README.md\0CHEATSHEET.md\0" + ["git"], + 0, + b"README.md\0CHEATSHEET.md\0.tmp/pytest-cache/inaccessible\0", ) prettier_result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( ["npx"], 0 @@ -155,6 +157,11 @@ def test_prettier_wrapper_forwards_paths_without_scanning_ignored_cache() -> Non ] assert not any(".cache" in argument for argument in run.call_args_list[1].args[0]) + ignored = ( + Path(__file__).parents[2].joinpath(".gitignore").read_text(encoding="utf-8") + ) + assert ".tmp/" in ignored.splitlines() + def test_prettier_wrapper_terminates_options_before_git_filenames() -> None: git_result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( @@ -169,6 +176,7 @@ def test_prettier_wrapper_terminates_options_before_git_filenames() -> None: side_effect=[git_result, prettier_result], ) as run, patch("scripts.prettier.shutil.which", side_effect=["git", "npx"]), + patch("scripts.prettier.os.path.isfile", return_value=True), ): assert prettier.main(["write", "."]) == 0 diff --git a/hacksaws/tests/test_history.py b/hacksaws/tests/test_history.py new file mode 100644 index 0000000..a5c5124 --- /dev/null +++ b/hacksaws/tests/test_history.py @@ -0,0 +1,693 @@ +"""Security and lifecycle tests for local command history.""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from typing import TYPE_CHECKING +from typing import cast + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _history +from hacksaws import _state + +if TYPE_CHECKING: + from collections.abc import Iterator + + +def _isolate(monkeypatch: pytest.MonkeyPatch, path: Path) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(path)) + + +def _result( + *, + code: str = "TEST_OK", + exit_code: int = 0, + data: object | None = None, + message: str = "", +) -> _configs.Result: + return _configs.Result(code, message, exit_code=exit_code, data=data) + + +def test_schema_is_versioned_two_table_wal( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + + handle = _history.begin(json_mode=False, interactive=False) + _history.finish(handle, _result()) + + with closing(sqlite3.connect(_history.database_path())) as connection: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) + if not str(row[0]).startswith("sqlite_") + } + version = connection.execute("PRAGMA user_version").fetchone()[0] + journal = connection.execute("PRAGMA journal_mode").fetchone()[0] + assert tables == {"invocations", "events"} + assert version == _history.SCHEMA_VERSION + assert str(journal).casefold() == "wal" + + +def test_allowlist_never_persists_sensitive_or_free_form_values( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + canary = "SUPER-SECRET-CANARY" + handle = _history.begin(json_mode=True, interactive=False) + _history.enrich( + handle, + argparse.Namespace( + access_type="mfa", + action="in", + profile="admin", + aws_account_name="horizon", + policy=f"C:\\private\\{canary}.yaml", + file=f"C:\\private\\{canary}.json", + mfa_code=canary, + external_id=canary, + tag=[f"secret={canary}"], + role=f"/private/{canary}", + resource_name=f"/private/{canary}", + dry_run=False, + yes=False, + ), + ) + _history.finish( + handle, + _result( + code=canary, + message=canary, + data={ + "message": canary, + "document": {"secret": canary}, + "role": f"/private/{canary}", + "name": f"/private/{canary}", + }, + ), + ) + + raw_database = _history.database_path().read_bytes() + record = _history.list_records()[0] + assert canary.encode() not in raw_database + assert record["command"] == "mfa.in" + assert record["profile"] == "admin" + assert record["location"] == "horizon" + assert record["resultCode"] == "UNKNOWN" + safe = cast("dict[str, object]", record["safe"]) + assert safe["secretPresence"] == { + "externalId": True, + "mfaCode": True, + } + assert safe["inputKinds"] == [ + {"format": "json", "role": "file"}, + {"format": "yaml", "role": "policy-file"}, + ] + + +def test_finish_preserves_safe_parser_metadata_and_result_metrics( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=True) + _history.enrich( + handle, + argparse.Namespace( + access_type="iam", + iam_action="policy", + policy_action="list", + profile="admin", + account="production", + dry_run=True, + wide=True, + ), + ) + _history.finish( + handle, + _result(data={"count": 3, "name": "CloudWatchReadOnlyAccess"}), + ) + + record = _history.list_records()[0] + assert record["command"] == "iam.policy.list" + assert record["dryRun"] is True + safe = cast("dict[str, object]", record["safe"]) + assert safe["flags"] == ["dry-run", "wide"] + assert safe["metrics"] == {"count": 3} + assert record["resourceName"] == "CloudWatchReadOnlyAccess" + + +def test_post_prompt_mfa_enrichment_records_presence_and_source_only( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=True) + _history.enrich( + handle, + argparse.Namespace( + access_type="mfa", action="in", profile="admin", mfa_code=None + ), + ) + _history.note_mfa_code(source="not-allowed") + _history.note_mfa_code(source="prompt") + _history.finish(handle, _result()) + + safe = cast("dict[str, object]", _history.list_records()[0]["safe"]) + assert safe["mfaCodeProvided"] is True + assert safe["mfaCodeSource"] == "prompt" + assert "mfaCode" not in safe + + +@pytest.mark.parametrize( + ("error", "state", "outcome", "exit_code"), + [ + (RuntimeError("do not store this"), "crashed", "crashed", 1), + (KeyboardInterrupt(), "interrupted", "interrupted", 130), + ], +) +def test_failure_lifecycle_stores_no_exception_detail( # noqa: PLR0917 + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + error: BaseException, + state: str, + outcome: str, + exit_code: int, +) -> None: + _isolate(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=False) + _history.fail(handle, error) + + record = _history.list_records()[0] + assert record["state"] == state + assert record["outcome"] == outcome + assert record["exitCode"] == exit_code + assert record["safe"] == {} + assert b"do not store this" not in _history.database_path().read_bytes() + + +def test_concurrent_writers_complete_atomically( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + + def record_one(index: int) -> None: + handle = _history.begin(json_mode=bool(index % 2), interactive=False) + _history.finish(handle, _result(data={"count": index})) + + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(record_one, range(32))) + + records = _history.list_records(limit=100) + assert len(records) == 32 + assert {record["state"] for record in records} == {"completed"} + + +def test_retention_abandons_old_runs_and_preserves_unresolved_recovery( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + old = (datetime.now(UTC) - timedelta(days=100)).isoformat() + stale_running = (datetime.now(UTC) - timedelta(days=2)).isoformat() + handle = _history.begin(json_mode=False, interactive=False) + assert handle.id is not None + with closing(sqlite3.connect(_history.database_path())) as connection, connection: + connection.execute( + "UPDATE invocations SET started_at = ?, updated_at = ? WHERE id = ?", + (stale_running, stale_running, handle.id), + ) + connection.execute( + "INSERT INTO invocations (id, started_at, ended_at, state, command, " + "json_mode, interactive, outcome, safe_json, recovery_unresolved, " + "updated_at) VALUES ('resolved-old', ?, ?, 'completed', 'iam.cleanup', " + "0, 0, 'success', '{}', 0, ?)", + (old, old, old), + ) + connection.execute( + "INSERT INTO invocations (id, started_at, ended_at, state, command, " + "json_mode, interactive, outcome, safe_json, recovery_unresolved, " + "updated_at) VALUES ('recovery-old', ?, ?, 'completed', 'iam.cleanup', " + "0, 0, 'operational-error', '{}', 1, ?)", + (old, old, old), + ) + _history._maintain() + + with closing(sqlite3.connect(_history.database_path())) as connection: + rows = { + row[0]: row[1] + for row in connection.execute("SELECT id, state FROM invocations") + } + assert rows[handle.id] == "abandoned" + assert "resolved-old" not in rows + assert rows["recovery-old"] == "completed" + + +def test_clear_never_removes_running_or_unresolved_records( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + running = _history.begin(json_mode=False, interactive=False) + unresolved = _history.begin(json_mode=False, interactive=False) + _history.finish( + unresolved, + _result( + code="IAM_RECOVERY_REQUIRED", + exit_code=1, + data={"classification": "recovery-required"}, + ), + ) + resolved = _history.begin(json_mode=False, interactive=False) + _history.finish(resolved, _result()) + + plan = _history.clear(before=None, all_records=True, apply=False) + applied = _history.clear(before=None, all_records=True, apply=True) + + assert plan["count"] == 1 + assert cast("int", plan["logicalBytes"]) > 0 + assert plan["applied"] is False + assert applied["count"] == 1 + assert {record["id"] for record in _history.list_records(include_running=True)} == { + running.id, + unresolved.id, + } + + +def test_export_and_time_parsing_are_deterministic( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=False) + _history.finish(handle, _result()) + records = _history.list_records() + + exported = _history.export_records(records, format_name="jsonl") + assert json.loads(exported) == records[0] + anchor = datetime(2026, 8, 2, 12, tzinfo=UTC) + assert _history.parse_time("15m", now=anchor) == anchor - timedelta(minutes=15) + assert _history.parse_time("7d", now=anchor) == anchor - timedelta(days=7) + assert _history.parse_time("2026-08-01T12:00:00Z") == datetime( + 2026, 8, 1, 12, tzinfo=UTC + ) + + +def test_history_config_defaults_and_validation() -> None: + config = _state.default_config() + assert config["history"] == { + "enabled": True, + "max_age": _history.DEFAULT_MAX_AGE, + "max_entries": _history.DEFAULT_MAX_ENTRIES, + "max_bytes": _history.DEFAULT_MAX_BYTES, + } + config["history"]["max_entries"] = 0 + with pytest.raises(_configs.OperationalError, match="positive integer"): + _state._validate_config(config) + + +def test_universal_cli_interception_preserves_one_json_envelope( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolate(monkeypatch, tmp_path) + + result = _cli.console_main(["config", "options", "--json"]) + + payload = json.loads(capsys.readouterr().out) + assert result.exit_code == 0 + assert payload["code"] == "CONFIG_OPTIONS" + records = _history.list_records() + assert len(records) == 1 + assert records[0]["command"] == "config.options" + assert records[0]["outcome"] == "success" + + +def test_parse_failure_is_recorded_without_raw_arguments( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolate(monkeypatch, tmp_path) + canary = "DO-NOT-STORE-THIS" + + result = _cli.console_main([canary, "--json"]) + + assert result.exit_code == _configs.EXIT_USAGE + assert json.loads(capsys.readouterr().err)["code"] == "ARGUMENT_ERROR" + assert canary.encode() not in _history.database_path().read_bytes() + assert _history.list_records()[0]["command"] == "unknown" + + +def test_history_list_show_report_status_and_check_are_human_friendly( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolate(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=False) + _history.enrich( + handle, + argparse.Namespace( + access_type="iam", + iam_action="role", + role_command="get", + profile="admin", + role="AgentSession", + ), + ) + _history.finish(handle, _result(data={"name": "AgentSession"})) + identifier = str(_history.list_records()[0]["id"]) + + assert _cli.console_main(["history", "list"]).exit_code == 0 + list_output = capsys.readouterr().out + assert "ID" in list_output + assert "iam.role.get" in list_output + assert "Key: ✓ success" in list_output + + assert _cli.console_main(["history", "show", identifier[:8]]).exit_code == 0 + show_output = capsys.readouterr().out + assert "Safe template: hacksaws iam role get" in show_output + assert "profile=admin" in show_output + + assert _cli.console_main(["history", "report"]).exit_code == 0 + report_output = capsys.readouterr().out + assert "Outcomes" in report_output + assert "Command families" in report_output + + assert _cli.console_main(["history", "status"]).exit_code == 0 + status_output = capsys.readouterr().out + assert "History database:" in status_output + assert "Retention:" in status_output + + assert _cli.console_main(["history", "check"]).exit_code == 0 + assert "valid" in capsys.readouterr().out + + +def test_history_search_and_export_support_safe_machine_workflows( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolate(monkeypatch, tmp_path) + for command in ("policy", "role"): + handle = _history.begin(json_mode=False, interactive=False) + command_fields = ( + {"policy_action": "list"} + if command == "policy" + else {"role_command": "list"} + ) + _history.enrich( + handle, + argparse.Namespace(access_type="iam", iam_action=command, **command_fields), + ) + _history.finish(handle, _result()) + + search = _cli.console_main( + ["history", "search", "*policy*", "--json", "--limit", "10"] + ) + payload = json.loads(capsys.readouterr().out) + assert search.exit_code == 0 + assert payload["data"]["count"] == 1 + assert payload["data"]["records"][0]["command"] == "iam.policy.list" + + destination = tmp_path / "safe-history.jsonl" + exported = _cli.console_main( + [ + "history", + "export", + "--format", + "jsonl", + "--output", + str(destination), + ] + ) + capsys.readouterr() + assert exported.exit_code == 0 + lines = destination.read_text(encoding="utf-8").splitlines() + assert lines + assert all(isinstance(json.loads(line), dict) for line in lines) + + +class _InteractiveInput: + def isatty(self) -> bool: + return True + + +def test_history_clear_requires_exact_yes_and_records_semantics( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolate(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=False) + _history.finish(handle, _result()) + monkeypatch.setattr(_cli.sys, "stdin", _InteractiveInput()) + monkeypatch.setattr("builtins.input", lambda _prompt: "y") + + declined = _cli.console_main(["history", "clear", "--all"]) + + assert declined.exit_code == _configs.EXIT_CANCELLED + assert "cancelled" in capsys.readouterr().err.casefold() + records = _history.list_records(limit=10) + assert any(record["confirmation"] == "exact-yes:declined" for record in records) + + monkeypatch.setattr("builtins.input", lambda _prompt: "yes") + accepted = _cli.console_main(["history", "clear", "--all"]) + assert accepted.exit_code == 0 + assert "Removed" in capsys.readouterr().out + + +def test_history_storage_failure_never_changes_command_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolate(monkeypatch, tmp_path) + + unavailable_error = sqlite3.OperationalError("history unavailable") + + def unavailable() -> sqlite3.Connection: + raise unavailable_error + + monkeypatch.setattr(_history, "_connect", unavailable) + result = _cli.console_main(["config", "options", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert result.exit_code == 0 + assert payload["code"] == "CONFIG_OPTIONS" + + +def test_unexpected_cli_exception_is_finalized_as_crashed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + + crash_error = RuntimeError("private crash detail") + + def crash(*_args: object, **_kwargs: object) -> _configs.Result: + raise crash_error + + monkeypatch.setattr(_cli, "_console_main_invocation", crash) + with pytest.raises(RuntimeError, match="private crash detail"): + _cli.console_main(["config", "options"]) + record = _history.list_records()[0] + assert record["state"] == "crashed" + assert b"private crash detail" not in _history.database_path().read_bytes() + + +def test_defensive_schema_settings_alias_and_disabled_paths( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + monkeypatch.setattr( + _state, + "load_config", + lambda: { + "history": { + "enabled": "invalid", + "max_age": "invalid", + "max_entries": "invalid", + "max_bytes": "invalid", + } + }, + ) + assert _history._settings() == { + "enabled": True, + "max_age": _history.DEFAULT_MAX_AGE, + "max_entries": _history.DEFAULT_MAX_ENTRIES, + "max_bytes": _history.DEFAULT_MAX_BYTES, + } + monkeypatch.setattr( + _state, + "load_config", + lambda: (_ for _ in ()).throw(_configs.OperationalError("invalid config")), + ) + assert _history._settings()["enabled"] is True + monkeypatch.setattr( + _state, + "load_config", + lambda: {"history": {"enabled": False}}, + ) + assert _history.begin(json_mode=False, interactive=False).enabled is False + with _history.disabled(): + assert _history.begin(json_mode=False, interactive=False).enabled is False + + assert _history._canonical_command( + argparse.Namespace(access_type="remote", iam_action="list") + ) == ("iam.list", "remote") + assert _history._canonical_command( + argparse.Namespace(access_type="web", action="in") + ) == ("pk.in", "web") + assert _history._canonical_command(argparse.Namespace(access_type="not safe!")) == ( + "unknown", + None, + ) + + schema_home = tmp_path / "future" + monkeypatch.setenv("HACKSAWS_HOME", str(schema_home)) + schema_home.joinpath("history").mkdir(parents=True) + future_database = schema_home / "history" / "history.db" + with closing(sqlite3.connect(future_database)) as connection, connection: + connection.execute("PRAGMA user_version = 99") + _history._initialized_databases.discard(future_database) + monkeypatch.setattr(_state, "load_config", _state.default_config) + assert _history.begin(json_mode=False, interactive=False).enabled is False + + +def test_filters_templates_errors_and_confirmation_branches( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=False) + _history.enrich( + handle, + argparse.Namespace( + access_type="iam", + iam_action="policy", + policy_action="create", + profile="admin", + account="123456789012", + role="AgentSession", + external_id="secret", + mfa_code="secret", + file=Path("policy.yaml"), + yes=True, + ), + ) + assert _history.current_id() == handle.id + _history.finish( + handle, + _result( + code="IAM_POLICY_CREATED", + data={ + "arn": "arn:aws:iam::123456789012:policy/hacksaws/Agent", + "accountId": "123456789012", + "partition": "aws", + "journalId": "safe-journal-1", + "classification": "recovery-required", + "changed": 2, + "failed": -1, + }, + ), + ) + record = _history.list_records()[0] + assert record["confirmation"] == "yes-flag:bypassed" + assert record["accountId"] == "123456789012" + assert record["partition"] == "aws" + template = _history.command_template(record) + assert "--file " in template + assert "--external-id " in template + assert "" in template + + started = datetime.fromisoformat(str(record["startedAt"])) + assert _history.list_records( + since=started - timedelta(seconds=1), + until=started + timedelta(seconds=1), + command="iam.policy", + outcome="success", + account="123456789012", + resource="Agent", + ) == [record] + assert _history.list_records(patterns=("policy",)) == [record] + assert _history.list_records(patterns=("*missing*",)) == [] + assert json.loads(_history.export_records([record], format_name="json")) == [record] + + with pytest.raises(_configs.OperationalError, match="4-32"): + _history.get_record("bad") + with pytest.raises(_configs.OperationalError, match="not found"): + _history.get_record("deadbeef") + with pytest.raises(_configs.OperationalError, match="requires --before"): + _history.clear(before=None, all_records=False, apply=False) + assert _history.parse_time("1week", now=started) == started - timedelta(days=7) + assert _history.parse_time("2026-08-02", now=started).tzinfo is UTC + + _history.note_confirmation("yes-no", "accepted") + assert _history.current_id() is None + + +def test_history_error_translation_corruption_and_bounded_retention( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + handles = [] + for _index in range(3): + handle = _history.begin(json_mode=False, interactive=False) + _history.finish(handle, _result()) + handles.append(handle) + with _history._database() as connection: + connection.execute( + "UPDATE invocations SET safe_json = '[]' WHERE id = ?", (handles[0].id,) + ) + connection.execute( + "UPDATE invocations SET safe_json = '{broken' WHERE id = ?", + (handles[1].id,), + ) + report = _history.check() + assert report["ok"] is False + assert report["corruptRecords"] == 2 + + monkeypatch.setattr( + _history, + "_settings", + lambda: { + "enabled": True, + "max_age": _history.DEFAULT_MAX_AGE, + "max_entries": 1, + "max_bytes": _history.DEFAULT_MAX_BYTES, + }, + ) + with _history._database() as connection: + connection.execute("DELETE FROM events") + connection.execute( + "UPDATE invocations SET safe_json = '{}', recovery_unresolved = 0" + ) + _history._maintain() + assert len(_history.list_records(limit=10)) == 1 + _history._maintain() + + database_error = sqlite3.OperationalError("unavailable") + + @contextlib.contextmanager + def unavailable() -> Iterator[sqlite3.Connection]: + raise database_error + yield # pragma: no cover + + monkeypatch.setattr(_history, "_database", unavailable) + with pytest.raises(_configs.OperationalError, match="read command history"): + _history.list_records() + with pytest.raises(_configs.OperationalError, match="inspect command history"): + _history.status() + disabled = _history.HistoryHandle(id=None, started_monotonic=0.0, enabled=False) + _history.enrich(disabled, argparse.Namespace()) + _history.finish(disabled, _result()) + _history.fail(disabled, RuntimeError()) diff --git a/hacksaws/tests/test_iam_cleanup.py b/hacksaws/tests/test_iam_cleanup.py index c093aa2..8cf854f 100644 --- a/hacksaws/tests/test_iam_cleanup.py +++ b/hacksaws/tests/test_iam_cleanup.py @@ -5,6 +5,7 @@ from __future__ import annotations +import argparse import json import threading from dataclasses import replace @@ -16,6 +17,7 @@ from botocore.exceptions import ClientError from hacksaws import _iam_cleanup as cleanup +from hacksaws import _iam_cli as iam_cli from hacksaws import _iam_managed_policies as managed from hacksaws import _iam_recovery as recovery from hacksaws import _iam_roles as roles @@ -39,7 +41,13 @@ def policy( managed.Tag("hacksaws:resource-id", resource_id), ] if origin is not None: - tags.append(managed.Tag(cleanup.ORIGIN_TAG, origin)) + tags.extend( + ( + managed.Tag("hacksaws:created-by", CALLER), + managed.Tag("hacksaws:created-at", "2026-08-01T00:00:00+00:00"), + managed.Tag(cleanup.ORIGIN_TAG, origin), + ) + ) return managed.ManagedPolicyRecord( managed.ManagedPolicyArn.parse( f"arn:aws:iam::{ACCOUNT}:policy/hacksaws/{name}" @@ -323,6 +331,64 @@ def test_inventory_classifies_origins_groups_smoke_and_filters() -> None: assert inventory.as_dict()["count"] == 3 +def test_legacy_is_visible_by_default_but_cleanup_requires_explicit_origin() -> None: + legacy = policy( + "hacksaws-Agents-assume-roles", + resource_id="group-Agents", + origin=None, + ) + selected = service(policy_values=(legacy,)) + summary, _, _ = summary_service(policy_values=(legacy,)) + visible = summary.inventory_summary(cleanup.InventoryQuery()) + assert [item.origin for item in visible.items] == [cleanup.OwnershipOrigin.LEGACY] + + safe_default = selected.plan(cleanup.CleanupOptions(all_resources=True)) + assert safe_default.resources == () + explicit = selected.plan( + cleanup.CleanupOptions( + all_resources=True, + origins=frozenset({cleanup.OwnershipOrigin.LEGACY}), + ) + ) + assert [item.origin for item in explicit.resources] == [ + cleanup.OwnershipOrigin.LEGACY + ] + default_args = argparse.Namespace(created=False, adopted=False, legacy=False) + explicit_args = argparse.Namespace(created=False, adopted=False, legacy=True) + assert cleanup.OwnershipOrigin.LEGACY not in iam_cli._cleanup_origins(default_args) + assert iam_cli._cleanup_origins(explicit_args) == frozenset( + {cleanup.OwnershipOrigin.LEGACY} + ) + + +def test_recovery_call_rejects_ownership_tag_drift_before_mutation() -> None: + class TaggedIam: + def __init__(self) -> None: + self.deleted = False + + def get_policy(self, **_kwargs: object) -> dict[str, object]: + return {"Policy": {"PolicyId": "ANPA-STABLE"}} + + def list_policy_tags(self, **_kwargs: object) -> dict[str, object]: + return {"Tags": [{"Key": "owner", "Value": "changed"}]} + + def delete_policy(self, **_kwargs: object) -> None: + self.deleted = True + + iam = TaggedIam() + with pytest.raises(OperationalError, match="ownership tags changed"): + cleanup._call( + SimpleNamespace(iam=iam), + "delete_policy", + { + "PolicyArn": "arn:aws:iam::123456789012:policy/hacksaws/Test", + "ExpectedPolicyId": "ANPA-STABLE", + "ExpectedOwnershipTags": {"owner": "planned"}, + }, + ) + assert not iam.deleted + + def test_summary_inventory_has_bounded_call_budget_and_stable_output() -> None: role_values = (role("Zulu"), role("Alpha")) policy_values = (policy("ZuluPolicy"), policy("AlphaPolicy")) @@ -916,7 +982,7 @@ def test_origin_tags_are_emitted_for_create_and_adopt() -> None: for operation in adopted.operations for item in operation.params["Tags"] } - assert tags[roles.ORIGIN_TAG] == "adopted" + assert tags[roles.ORIGIN_TAG] == "legacy" def test_policy_dependency_steps_cover_every_relationship_and_drift() -> None: diff --git a/hacksaws/tests/test_iam_cli_scaffold.py b/hacksaws/tests/test_iam_cli_scaffold.py index eff7bfd..eb14234 100644 --- a/hacksaws/tests/test_iam_cli_scaffold.py +++ b/hacksaws/tests/test_iam_cli_scaffold.py @@ -332,8 +332,9 @@ def execute( ), context, ) - assert dry_run.code == "IAM_CLEANUP_PLAN" - assert dry_run.data["classification"] == "planned" + assert dry_run.code == "IAM_CLEANUP_DRY_RUN" + assert dry_run.data["plan"]["classification"] == "planned" + assert dry_run.data["result"]["journalId"] is None execute_args = argparse.Namespace( patterns=["*Agent*"], @@ -440,18 +441,77 @@ def execute( yes=False, ) result = _iam_cli.cleanup_result(args, SimpleNamespace()) - assert result.code == "IAM_CLEANUP_PLAN" - assert result.exit_code == 2 + assert result.code == "IAM_CLEANUP_BLOCKED" + assert result.exit_code == 3 selected_plan = planned monkeypatch.setattr(_iam_cli.os, "isatty", lambda _fd: False) result = _iam_cli.cleanup_result(args, SimpleNamespace()) assert result.code == "IAM_CLEANUP_CONFIRMATION_REQUIRED" + assert result.exit_code == 4 + + monkeypatch.setattr(_iam_cli.os, "isatty", lambda _fd: True) + monkeypatch.setattr("builtins.input", lambda _prompt: "no") + result = _iam_cli.cleanup_result(args, SimpleNamespace()) + assert result.code == "IAM_CLEANUP_CANCELLED" + assert result.data["result"]["classification"] == "cancelled" args.yes = True result = _iam_cli.cleanup_result(args, SimpleNamespace()) assert result.code == "IAM_CLEANUP_PARTIAL" assert result.exit_code == 2 + assert result.data["result"]["consoleUrl"].startswith("https://") + + +def test_cleanup_no_matches_does_not_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + options = _iam_cleanup.CleanupOptions(patterns=("missing",), dry_run=False) + no_matches = _iam_cleanup.CleanupPlan( + "123456789012", + "aws", + "arn:aws:iam::123456789012:user/test", + options, + (), + (), + ) + + class Service: + def __init__(self, _context: object) -> None: + pass + + def plan( + self, _options: _iam_cleanup.CleanupOptions + ) -> _iam_cleanup.CleanupPlan: + return no_matches + + monkeypatch.setattr(_iam_cli._iam_cleanup, "CleanupService", Service) + monkeypatch.setattr( + "builtins.input", lambda _prompt: pytest.fail("no-op must not prompt") + ) + args = argparse.Namespace( + patterns=["missing"], + all=False, + roles=False, + policies=True, + group_grants=False, + created=False, + adopted=False, + smoke=False, + smoke_run=None, + cascade=False, + remove_boundaries=False, + remove_from_instance_profiles=False, + dry_run=False, + yes=False, + ) + + result = _iam_cli.cleanup_result(args, SimpleNamespace()) + + assert result.code == "IAM_CLEANUP_NO_MATCHES" + assert result.data["result"] == { + "classification": "no-change", + "journalId": None, + "leaveNoTrace": True, + } def test_inventory_rendering_and_central_dispatch_branches( diff --git a/hacksaws/tests/test_iam_inventory_summary.py b/hacksaws/tests/test_iam_inventory_summary.py index 6914058..2b9640e 100644 --- a/hacksaws/tests/test_iam_inventory_summary.py +++ b/hacksaws/tests/test_iam_inventory_summary.py @@ -68,6 +68,8 @@ def policy( managed.Tag("hacksaws:managed-by", "hacksaws"), managed.Tag("hacksaws:resource-kind", "managed-policy"), managed.Tag("hacksaws:resource-id", resource_id), + managed.Tag("hacksaws:created-by", CALLER), + managed.Tag("hacksaws:created-at", "2026-08-01T00:00:00+00:00"), ) ) tags.append(managed.Tag(cleanup.ORIGIN_TAG, origin)) diff --git a/hacksaws/tests/test_iam_managed_policies.py b/hacksaws/tests/test_iam_managed_policies.py index c345cd4..c930e9a 100644 --- a/hacksaws/tests/test_iam_managed_policies.py +++ b/hacksaws/tests/test_iam_managed_policies.py @@ -741,6 +741,71 @@ def test_adopt_release_and_tag_drift() -> None: assert iam.tags["Existing"] == "yes" +def test_ownership_classification_and_idempotent_legacy_migration() -> None: + iam = StatefulIam() + service = make_service(iam) + assert service.get_policy(ARN).ownership_status is managed.OwnershipStatus.LEGACY + + legacy_id = iam.tags["hacksaws:resource-id"] + legacy = service.plan_adopt(ARN, "must-not-replace") + assert legacy.add == (managed.Tag("hacksaws:ownership-origin", "legacy"),) + migrated = service.execute_tag_change(legacy) + assert migrated.policy is not None + assert iam.tags["hacksaws:resource-id"] == legacy_id + assert iam.tags["hacksaws:ownership-origin"] == "legacy" + + repeated = service.plan_adopt(ARN, "must-not-replace") + assert repeated.operation.steps == () + assert repeated.add == () + + iam.tags = {"hacksaws:managed-by": "hacksaws"} + with pytest.raises(managed.PolicyServiceError, match="partial"): + service.plan_adopt(ARN, "new") + iam.tags = {"hacksaws:managed-by": "other"} + with pytest.raises(managed.PolicyServiceError, match="already managed"): + service.plan_adopt(ARN, "new") + + +def test_publish_reconciles_tags_without_version_and_fences_tag_races() -> None: + iam = StatefulIam() + iam.tags.update( + { + "hacksaws:created-by": f"arn:aws:iam::{ACCOUNT}:user/tester", + "hacksaws:created-at": NOW.isoformat(), + "hacksaws:ownership-origin": "created", + "Team": "old", + } + ) + service = make_service(iam) + current = service.get_policy(ARN) + desired = service.reconciled_owned_tags(current, (managed.Tag("Team", "agents"),)) + plan = service.plan_publish( + ARN, + POLICY, + include_aws_validation=False, + planned_tags=desired, + ) + assert plan.operation.action is managed.ChangeAction.UPDATE + assert [step.operation for step in plan.operation.steps] == ["TagPolicy"] + assert plan.before is not None + assert plan.after is not None + assert plan.before.policy_id == plan.after.policy_id + service.execute_change(plan) + assert iam.default == "v1" + assert "CreatePolicyVersion" not in iam.calls + assert iam.tags["Team"] == "agents" + + raced = service.plan_publish( + ARN, + CHANGED, + include_aws_validation=False, + planned_tags=desired, + ) + iam.tags["race"] = "changed" + with pytest.raises(managed.PolicyDriftError): + service.execute_change(raced) + + def test_dependency_complete_delete_requires_cascade_then_executes() -> None: iam = StatefulIam() iam.permission_users = {"U1": "alice"} diff --git a/hacksaws/tests/test_iam_policy_cli.py b/hacksaws/tests/test_iam_policy_cli.py index 9bf5b9d..b081ede 100644 --- a/hacksaws/tests/test_iam_policy_cli.py +++ b/hacksaws/tests/test_iam_policy_cli.py @@ -28,9 +28,11 @@ from hacksaws._iam_managed_policies import ManagedPolicyRecord from hacksaws._iam_managed_policies import OperationJournal from hacksaws._iam_managed_policies import OperationPlan +from hacksaws._iam_managed_policies import OperationStep from hacksaws._iam_managed_policies import PackedPolicyDiagnostic from hacksaws._iam_managed_policies import PackedPolicyProbeError from hacksaws._iam_managed_policies import PackedPolicyWarning +from hacksaws._iam_managed_policies import PlannedPolicyState from hacksaws._iam_managed_policies import PolicyChangePlan from hacksaws._iam_managed_policies import PolicyDeletionPlan from hacksaws._iam_managed_policies import PolicyDependencies @@ -68,6 +70,9 @@ def record( Tag("hacksaws:managed-by", "hacksaws"), Tag("hacksaws:resource-id", "resource-1"), Tag("hacksaws:resource-kind", "managed-policy"), + Tag("hacksaws:created-by", f"arn:aws:iam::{ACCOUNT}:user/tester"), + Tag("hacksaws:created-at", NOW.isoformat()), + Tag("hacksaws:ownership-origin", "created"), ) if owned and not aws else () @@ -243,6 +248,28 @@ def test_create_reports_no_change_and_conflict_requires_replace( assert result.code == "IAM_POLICY_COLLISION" +def test_create_replace_never_adopts_an_unowned_collision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "AgentRead.json" + path.write_text(json.dumps(DOCUMENT), encoding="utf-8") + fake = service() + unowned = record(owned=False) + fake.resolve.return_value = ResolutionResult("AgentRead", (unowned,)) + fake.get_policy.return_value = unowned + monkeypatch.setattr(cli, "_service", lambda _: fake) + monkeypatch.setattr(cli._state, "load_config", _state.default_config) + + result = cli.dispatch( + parser().parse_args(["create", str(path), "--replace", "--yes"]), context() + ) + + assert result is not None + assert result.code == "IAM_POLICY_COLLISION" + assert "adopt" in result.message + fake.plan_publish.assert_not_called() + + def test_generated_create_name_can_be_accepted_edited_or_cancelled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -360,10 +387,52 @@ def test_noninteractive_update_fails_closed( parser().parse_args(["update", "AgentRead", str(path)]), context() ) assert result is not None - assert result.code == "IAM_POLICY_CANCELLED" + assert result.code == "IAM_POLICY_CONFIRMATION_REQUIRED" + assert result.exit_code == 4 fake.execute_change.assert_not_called() +def test_policy_inputs_are_order_independent_and_explicit(tmp_path: Path) -> None: + source = tmp_path / "policy.json" + source.write_text(json.dumps(DOCUMENT), encoding="utf-8") + for values in ( + ["create", "AgentRead", str(source)], + ["create", str(source), "AgentRead"], + ["create", "--name", "AgentRead", "--file", str(source)], + ): + args = parser().parse_args(values) + cli.normalize_arguments(args) + assert args.name == "AgentRead" + assert args.file == str(source) + + args = parser().parse_args(["update", str(source), "AgentRead"]) + cli.normalize_arguments(args) + assert args.policy_or_file == "AgentRead" + assert args.file == str(source) + + with pytest.raises(cli.OperationalError, match="Unable to distinguish"): + cli.normalize_arguments(parser().parse_args(["create", "one", "two"])) + + with pytest.raises(cli.OperationalError, match="cannot be combined with --file"): + cli.normalize_arguments( + parser().parse_args( + ["update", "AgentRead", "--from-stored", "Saved", "--file", str(source)] + ) + ) + with pytest.raises(cli.OperationalError, match="at most one policy reference"): + cli.normalize_arguments( + parser().parse_args( + [ + "update", + "AgentRead", + "Other", + "--from-stored", + "Saved", + ] + ) + ) + + def test_ambiguous_reference_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: fake = service() fake.resolve.return_value = ResolutionResult( @@ -578,7 +647,7 @@ def test_edit_success_and_failure(monkeypatch: pytest.MonkeyPatch) -> None: ) result = cli.dispatch(parser().parse_args(["edit", "AgentRead"]), context()) assert result is not None - assert result.code == "IAM_POLICY_CHANGED" + assert result.code == "IAM_POLICY_NO_CHANGE" monkeypatch.setattr( cli.subprocess, "run", lambda *_args, **_kwargs: SimpleNamespace(returncode=7) ) @@ -608,7 +677,7 @@ def test_rollback_and_owned_delete_execute(monkeypatch: pytest.MonkeyPatch) -> N parser().parse_args(["rollback", "AgentRead", "v1", "--yes"]), context() ) assert result is not None - assert result.code == "IAM_POLICY_CHANGED" + assert result.code == "IAM_POLICY_NO_CHANGE" dependencies = PolicyDependencies( permission_roles=(EntityReference("Role", "Agent", "R1"),) @@ -627,7 +696,7 @@ def test_rollback_and_owned_delete_execute(monkeypatch: pytest.MonkeyPatch) -> N ) assert result is not None assert result.code == "IAM_POLICY_DELETED" - assert result.data["dependencies"]["permissionRoles"] == ["Agent"] + assert result.data["plan"]["dependencies"]["permissionRoles"] == ["Agent"] def test_check_success_warning_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None: @@ -695,13 +764,81 @@ def test_tag_list_remove_and_ownership(monkeypatch: pytest.MonkeyPatch) -> None: parser().parse_args(["adopt", "AgentRead", "--yes"]), context() ) assert result is not None - assert result.code == "IAM_POLICY_OWNERSHIP_CHANGED" + assert result.code == "IAM_POLICY_NO_CHANGE" fake.plan_release.return_value = ownership_plan result = cli.dispatch( parser().parse_args(["release", "AgentRead", "--yes"]), context() ) assert result is not None - assert result.data["action"] == "release" + assert result.data["result"]["action"] == "release" + + +def test_ownership_plan_dry_run_confirmation_decline_and_apply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + current = replace(record(), tags=(*record().tags, Tag("team", "agents"))) + after_tags = (Tag("team", "agents"),) + remove = tuple(tag.key for tag in current.tags if tag.key != "team") + step = OperationStep("release", "UntagPolicy", {"TagKeys": list(remove)}) + plan = TagChangePlan( + current, + OperationPlan("release", ChangeAction.RELEASE, "Release policy.", (step,)), + (), + remove, + "digest", + before_tags=current.tags, + after_tags=after_tags, + ) + fake = service() + fake.plan_release.return_value = plan + fake.get_policy.return_value = current + monkeypatch.setattr(cli, "_service", lambda _: fake) + + dry_run = cli.dispatch( + parser().parse_args(["release", "AgentRead", "--dry-run"]), context() + ) + assert dry_run is not None + assert dry_run.code == "IAM_POLICY_OWNERSHIP_DRY_RUN" + after_values = dry_run.data["plan"]["changes"]["tags"]["after"] + assert after_values["team"].startswith("sha256:") + assert "agents" not in json.dumps(dry_run.data) + assert dry_run.data["result"]["journalId"] is None + + monkeypatch.setattr(cli.sys, "stdin", StringIO()) + required = cli.dispatch(parser().parse_args(["release", "AgentRead"]), context()) + assert required is not None + assert required.code == "IAM_POLICY_CONFIRMATION_REQUIRED" + + monkeypatch.setattr(cli, "_confirmation_unavailable", lambda: False) + monkeypatch.setattr("builtins.input", lambda _prompt: "not-now") + declined = cli.dispatch(parser().parse_args(["release", "AgentRead"]), context()) + assert declined is not None + assert declined.code == "IAM_POLICY_CANCELLED" + + applied = cli.dispatch( + parser().parse_args(["release", "AgentRead", "--yes"]), context() + ) + assert applied is not None + assert applied.code == "IAM_POLICY_OWNERSHIP_CHANGED" + assert applied.message.startswith("Released ownership") + assert applied.data["result"]["journalId"] == "journal-1" + + fallback = TagChangePlan( + current, + OperationPlan("release", ChangeAction.RELEASE, "Release policy.", (step,)), + (), + ("team",), + "digest", + ) + fallback_data = cli._ownership_plan_data(fallback, context(), "release") + assert "team" not in fallback_data["changes"]["tags"]["after"] + + fake.get_policy.return_value = replace(current, tags=after_tags) + drifted = cli.dispatch( + parser().parse_args(["release", "AgentRead", "--yes"]), context() + ) + assert drifted is not None + assert drifted.code == "IAM_POLICY_DRIFT" def test_dispatch_normalizes_drift_and_unknown_action( @@ -795,11 +932,191 @@ def test_policy_dry_run_returns_plan_without_confirmation_or_journal() -> None: context(), ) assert result.code == "IAM_POLICY_DRY_RUN" - assert result.data["dryRun"] is True - assert result.data["classification"] == "planned" + assert result.data["result"]["classification"] == "dry-run" + assert result.data["result"]["journalId"] is None + assert result.data["plan"]["classification"] == "planned" assert fake.execute_change.call_count == 0 +def test_policy_plan_has_exact_deltas_without_documents_or_parameters() -> None: + before_record = record() + raw_tag_values = ( + "TAG-CANARY-BEFORE", + "TAG-CANARY-AFTER", + "TAG-CANARY-ADDED", + ) + before_tags = (*before_record.tags, Tag("environment", raw_tag_values[0])) + after_tags = ( + *before_record.tags, + Tag("environment", raw_tag_values[1]), + Tag("team", raw_tag_values[2]), + ) + after_document: dict[str, JsonValue] = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:GetLogEvents", "logs:FilterLogEvents"], + "Resource": "arn:aws:logs:*:*:log-group:private-agent-log", + } + ], + } + before = PlannedPolicyState( + ARN, + "ANPA123", + "AgentRead", + "/hacksaws/", + None, + DOCUMENT, + before_tags, + "v1", + ) + after = replace(before, document=after_document, tags=after_tags) + step = OperationStep( + "publish", + "CreatePolicyVersion", + { + "PolicyArn": ARN, + "PolicyDocument": after_document, + "SecretAccessKey": "must-not-leak", + }, + ) + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.UPDATE, "Update policy.", (step,)), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + after_document, + None, + after_tags, + validation=ValidationReport(), + before=before, + after=after, + ) + + data = cli._policy_plan_data(plan, context(), [], []) + encoded = json.dumps(data) + assert "private-agent-log" not in encoded + assert "must-not-leak" not in encoded + changed_tag = data["changes"]["tags"]["changed"]["environment"] + assert changed_tag["before"].startswith("sha256:") + assert changed_tag["after"].startswith("sha256:") + assert data["changes"]["tags"]["added"]["team"].startswith("sha256:") + assert data["changes"]["document"]["after"]["actions"] == 2 + assert data["operations"] == [ + { + "id": "publish", + "action": "CreatePolicyVersion", + "destructive": False, + "reversible": False, + "detail": {}, + } + ] + review = cli._policy_plan_text(data) + assert review.endswith("No changes have been made.") + assert "environment: sha256:" in review + assert all(value not in review for value in raw_tag_values) + + version_step = cli._policy_step_data( + OperationStep("delete-version", "DeletePolicyVersion", {"VersionId": "v2"}) + ) + assert version_step["detail"] == {"versionId": "v2"} + tag_step = cli._policy_step_data( + OperationStep( + "tag", + "TagPolicy", + {"Tags": [{"Key": "team", "Value": "ai"}, {"Value": "ignored"}]}, + ) + ) + assert tag_step["detail"] == {"tagKeys": ["team"]} + untag_step = cli._policy_step_data( + OperationStep("untag", "UntagPolicy", {"TagKeys": ["old", "team"]}) + ) + assert untag_step["detail"] == {"tagKeys": ["old", "team"]} + + warned = {**data, "warnings": ["Review the account boundary."]} + assert "Warnings:" in cli._policy_plan_text(warned) + + +def test_interactive_decline_is_cancelled_without_journal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + step = OperationStep("publish", "CreatePolicyVersion", {"PolicyArn": ARN}) + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.UPDATE, "Update policy.", (step,)), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + record().tags, + validation=ValidationReport(), + ) + monkeypatch.setattr(cli, "_confirmation_unavailable", lambda: False) + monkeypatch.setattr("builtins.input", lambda _prompt: "no") + durable = Mock(side_effect=AssertionError("journal must not be created")) + monkeypatch.setattr(cli, "_durable_reconcile", durable) + + result = cli._execute_plan( + service(), plan, argparse.Namespace(yes=False, dry_run=False), context() + ) + + assert result.code == "IAM_POLICY_CANCELLED" + assert result.data["result"]["classification"] == "cancelled" + durable.assert_not_called() + + +def test_noop_skips_confirmation_and_journal(monkeypatch: pytest.MonkeyPatch) -> None: + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.NOOP, "Already current.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + record().tags, + validation=ValidationReport(), + ) + fake = service() + monkeypatch.setattr( + "builtins.input", lambda _prompt: pytest.fail("no-op must not prompt") + ) + durable = Mock(side_effect=AssertionError("no-op must not create a journal")) + monkeypatch.setattr(cli, "_durable_reconcile", durable) + + result = cli._execute_plan( + fake, plan, argparse.Namespace(yes=False, dry_run=False), context() + ) + + assert result.code == "IAM_POLICY_NO_CHANGE" + assert result.data["result"]["classification"] == "no-change" + assert result.data["result"]["journalId"] is None + durable.assert_not_called() + + +def test_change_state_snapshot_rejects_missing_document_and_drift() -> None: + plan = PolicyChangePlan( + OperationPlan("plan", ChangeAction.UPDATE, "Update policy.", ()), + ManagedPolicyArn.parse(ARN), + "AgentRead", + "/hacksaws/", + DOCUMENT, + None, + record().tags, + expected_default_version_id="v1", + expected_digest=cli.policy_digest(DOCUMENT), + validation=ValidationReport(), + ) + fake = service() + fake.get_policy.return_value = record(document=False) + with pytest.raises(cli.PolicyServiceError, match="document is unavailable"): + cli._change_states(fake, plan) + + fake.get_policy.return_value = replace(record(), default_version_id="v2") + with pytest.raises(cli.PolicyDriftError, match="changed after planning"): + cli._change_states(fake, plan) + + def test_missing_reference_and_missing_document_are_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -869,7 +1186,37 @@ def test_delete_dependencies_and_confirmation_are_safe( monkeypatch.setattr(cli.sys, "stdin", StringIO()) result = cli.dispatch(parser().parse_args(["delete", "AgentRead"]), context()) assert result is not None - assert result.code == "IAM_POLICY_CANCELLED" + assert result.code == "IAM_POLICY_CONFIRMATION_REQUIRED" + assert result.exit_code == 4 + + +def test_delete_interactive_decline_and_post_plan_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + planned_policy = record() + plan = PolicyDeletionPlan( + policy=planned_policy, + dependencies=PolicyDependencies(), + operation=OperationPlan("delete", ChangeAction.DELETE, "Delete policy.", ()), + cascade=False, + ) + fake = service() + fake.plan_delete.return_value = plan + monkeypatch.setattr(cli, "_service", lambda _: fake) + monkeypatch.setattr(cli, "_confirmation_unavailable", lambda: False) + monkeypatch.setattr("builtins.input", lambda _prompt: "wrong-name") + + declined = cli.dispatch(parser().parse_args(["delete", "AgentRead"]), context()) + assert declined is not None + assert declined.code == "IAM_POLICY_CANCELLED" + assert declined.data["result"]["classification"] == "cancelled" + + fake.get_policy.return_value = replace(planned_policy, policy_id="ANPA-REPLACED") + drifted = cli.dispatch( + parser().parse_args(["delete", "AgentRead", "--yes"]), context() + ) + assert drifted is not None + assert drifted.code == "IAM_POLICY_DRIFT" def test_dispatch_normalizes_immutable_and_os_errors( @@ -1200,9 +1547,9 @@ def test_delete_requires_explicit_boundary_removal_and_snapshots_preview( ) assert deleted is not None assert deleted.code == "IAM_POLICY_DELETED" - assert deleted.data["preview"]["attachments"]["roles"] == ["Reader"] - assert deleted.data["preview"]["permissionBoundaries"]["users"] == ["Restricted"] - assert deleted.data["preview"]["versions"][0]["id"] == "v1" + assert deleted.data["plan"]["dependencies"]["permissionRoles"] == ["Reader"] + assert deleted.data["plan"]["dependencies"]["boundaryUsers"] == ["Restricted"] + assert deleted.data["result"]["policyId"] == "ANPA123" assert snapshots[0][0]["exists"] is False assert snapshots[0][1]["dependencies"]["boundaryUsers"] == [ {"type": "User", "name": "Restricted", "id": "U1"} diff --git a/hacksaws/tests/test_iam_roles.py b/hacksaws/tests/test_iam_roles.py index bc3ca07..c038278 100644 --- a/hacksaws/tests/test_iam_roles.py +++ b/hacksaws/tests/test_iam_roles.py @@ -33,7 +33,11 @@ def snapshot(**overrides: Any) -> roles.RoleSnapshot: "arn": ROLE_ARN, "path": "/hacksaws/", "trust": TRUST, - "tags": {roles.MANAGED_TAG: "true", "old": "x"}, + "tags": { + roles.MANAGED_TAG: "true", + roles.OWNER_TAG: "scott", + "old": "x", + }, } values.update(overrides) return roles.RoleSnapshot(**values) @@ -266,11 +270,13 @@ def test_role_create_update_ownership_and_tag_plans() -> None: adopted = roles.plan_adopt_role(current, "scott", "audit") assert adopted.kind == "role-adopt" - with pytest.raises(roles.ConflictError, match="already managed"): - roles.plan_adopt_role( - snapshot(tags={roles.MANAGED_TAG: "true", roles.OWNER_TAG: "other"}), - "scott", - ) + legacy = roles.plan_adopt_role( + snapshot(tags={roles.MANAGED_TAG: "true", roles.OWNER_TAG: "other"}), + "scott", + ) + assert legacy.operations[0].params["Tags"] == [ + {"Key": roles.ORIGIN_TAG, "Value": "legacy"} + ] released = roles.plan_release_role( snapshot(tags={roles.MANAGED_TAG: "true", roles.OWNER_TAG: "x"}) ) @@ -279,6 +285,43 @@ def test_role_create_update_ownership_and_tag_plans() -> None: assert not roles.plan_remove_tags("Agent", []).operations +def test_role_adoption_identity_origin_and_idempotence() -> None: + unowned = snapshot(tags={"team": "platform"}, role_id="AROA-STABLE") + adopted = roles.plan_adopt_role(unowned, "scott", "identity-1") + additions = { + item["Key"]: item["Value"] + for operation in adopted.operations + for item in operation.params["Tags"] + } + assert additions == { + roles.MANAGED_TAG: "true", + roles.OWNER_TAG: "scott", + roles.AUDIT_TAG: "identity-1", + roles.ORIGIN_TAG: "adopted", + } + assert isinstance(adopted.after, roles.RoleSnapshot) + repeated = roles.plan_adopt_role(adopted.after, "scott", "identity-1") + assert repeated.operations == () + + legacy = snapshot( + tags={ + roles.MANAGED_TAG: "true", + roles.OWNER_TAG: "original-owner", + roles.AUDIT_TAG: "original-id", + } + ) + migration = roles.plan_adopt_role(legacy, "different-caller") + assert migration.operations[0].params["Tags"] == [ + {"Key": roles.ORIGIN_TAG, "Value": "legacy"} + ] + assert isinstance(migration.after, roles.RoleSnapshot) + assert migration.after.tags[roles.OWNER_TAG] == "original-owner" + assert migration.after.tags[roles.AUDIT_TAG] == "original-id" + + with pytest.raises(roles.ConflictError, match="partial"): + roles.plan_adopt_role(snapshot(tags={roles.OWNER_TAG: "orphan"}), "scott") + + def test_trust_set_add_remove_and_complex_ambiguity() -> None: principal = roles.DurablePrincipal( "role", "arn:aws:iam::123456789012:role/Caller", ACCOUNT.account_id, "aws" diff --git a/hacksaws/tests/test_local_lifecycle.py b/hacksaws/tests/test_local_lifecycle.py index 728ec81..14938dc 100644 --- a/hacksaws/tests/test_local_lifecycle.py +++ b/hacksaws/tests/test_local_lifecycle.py @@ -547,17 +547,18 @@ def test_browser_cache_cleanup_is_scoped_and_fingerprint_guarded( } with pytest.raises(_configs.OperationalError, match="no logout changes"): _sessions._tracked_login_cache_plan(session, destination, force=False) - roots, removals, residue = _sessions._tracked_login_cache_plan( + roots, removals, residue, _ = _sessions._tracked_login_cache_plan( session, destination, force=True ) assert roots == [root.absolute()] residue = _sessions._remove_tracked_login_cache(removals, residue, force=True) assert not matched.exists() - assert not changed.exists() + assert changed.exists() assert outside.exists() - assert residue == [ - {"path": str(outside.absolute()), "reason": "outside tracked cache roots"} - ] + assert {item["path"] for item in residue} == { + str(changed.absolute()), + str(outside.absolute()), + } def test_logout_not_managed_and_bulk_collects_independent_errors( @@ -904,11 +905,14 @@ def test_browser_logout_cache_drift_fails_closed_and_force_tracks_residue( outcome = _sessions._logout_key(key, _logout_args(directory, force=True)) assert outcome["state"] == "logout-residue" - assert not changed.exists() + assert changed.exists() assert outside.exists() residue_session = _state.load_sessions()[key] assert residue_session["auth_method"] == "browser-cache-residue" - assert residue_session["login_cache_residue"][0]["path"] == str(outside.absolute()) + assert {item["path"] for item in residue_session["login_cache_residue"]} == { + str(changed.absolute()), + str(outside.absolute()), + } def test_config_check_scopes_local_stored_policies_to_selected_account( diff --git a/hacksaws/tests/test_mutation_contract.py b/hacksaws/tests/test_mutation_contract.py new file mode 100644 index 0000000..d45af4e --- /dev/null +++ b/hacksaws/tests/test_mutation_contract.py @@ -0,0 +1,620 @@ +"""Tests for shared mutation rendering and deterministic CLI input resolution.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from botocore.exceptions import ClientError +from botocore.exceptions import EndpointConnectionError + +from hacksaws import _audit +from hacksaws import _configs +from hacksaws import _iam_role_cli +from hacksaws import _iam_roles as roles +from hacksaws import _mutation_view +from hacksaws import _resource_input +from hacksaws._configs import OperationalError + +ACCOUNT = "123456789012" +ROLE_ARN = f"arn:aws:iam::{ACCOUNT}:role/Agent" + + +def _context() -> Any: # noqa: ANN401 + return SimpleNamespace( + account_id=ACCOUNT, + partition="aws", + arn=f"arn:aws:iam::{ACCOUNT}:user/scott", + session=SimpleNamespace(region_name="us-west-2"), + ) + + +def _client_error(code: str) -> ClientError: + return ClientError({"Error": {"Code": code, "Message": code}}, "test") + + +def test_name_file_resolves_both_orders_and_explicit_flags(tmp_path: Path) -> None: + document = tmp_path / "policy.yaml" + document.write_text("Version: '2012-10-17'", encoding="utf-8") + + first = _resource_input.resolve_name_file(("ReadOnly", str(document))) + second = _resource_input.resolve_name_file((str(document), "ReadOnly")) + explicit = _resource_input.resolve_name_file( + (), explicit_name="ReadOnly", explicit_file=document + ) + named = _resource_input.resolve_name_file( + (str(document),), explicit_name="ReadOnly" + ) + filed = _resource_input.resolve_name_file(("ReadOnly",), explicit_file=document) + + assert first == second == explicit == named == filed + assert first.name == "ReadOnly" + assert first.file == document + + +@pytest.mark.parametrize( + ("values", "name", "file"), + [ + (("ReadOnly",), "ReadOnly", None), + (("policy.json",), None, Path("policy.json")), + (("-",), None, Path("-")), + ((r"C:\policies\agent.toml",), None, Path(r"C:\policies\agent.toml")), + ], +) +def test_name_file_single_value_classification( + values: tuple[str, ...], name: str | None, file: Path | None +) -> None: + resolved = _resource_input.resolve_name_file( + values, require_name=False, require_file=False + ) + assert resolved.name == name + assert resolved.file == file + + +def test_name_file_rejects_ambiguous_or_missing_inputs() -> None: + with pytest.raises(OperationalError, match="at most"): + _resource_input.resolve_name_file(("one", "two", "three")) + with pytest.raises(OperationalError, match="Unable to distinguish"): + _resource_input.resolve_name_file(("one", "two")) + with pytest.raises(OperationalError, match="Missing NAME"): + _resource_input.resolve_name_file(()) + with pytest.raises(OperationalError, match="Missing FILE"): + _resource_input.resolve_name_file(("name",)) + with pytest.raises(OperationalError, match="already"): + _resource_input.resolve_name_file( + ("extra",), explicit_name="name", explicit_file="file.json" + ) + with pytest.raises(OperationalError, match="cannot be combined"): + _resource_input.resolve_name_file(("one", "two"), explicit_name="name") + + +def test_reference_or_file_requires_one_input(tmp_path: Path) -> None: + document = tmp_path / "policy.json" + document.write_text("{}", encoding="utf-8") + assert ( + _resource_input.resolve_reference_or_file(("ReadOnly",)).reference == "ReadOnly" + ) + assert ( + _resource_input.resolve_reference_or_file( + (), explicit_reference="ReadOnly" + ).reference + == "ReadOnly" + ) + assert _resource_input.resolve_reference_or_file((str(document),)).file == document + assert ( + _resource_input.resolve_reference_or_file((), explicit_file=document).file + == document + ) + with pytest.raises(OperationalError, match="exactly one"): + _resource_input.resolve_reference_or_file(()) + with pytest.raises(OperationalError, match="exactly one"): + _resource_input.resolve_reference_or_file( + ("ReadOnly",), explicit_reference="Other" + ) + + +def test_shared_mutation_contract_has_stable_human_and_json_shapes() -> None: + plan = _mutation_view.ChangeView( + operation="role-update", + resource_type="IAM role", + name="Agent", + classification="planned", + arn="arn:aws:iam::123456789012:role/Agent", + account_id="123456789012", + partition="aws", + ownership="current", + origin="created", + before_exists=True, + after_exists=True, + changes=(_mutation_view.FieldChange("description", "old", "new"),), + actions=(_mutation_view.ActionView("iam", "update_role", "update role Agent"),), + dependencies=( + _mutation_view.DependencyView("attached policy", "ReadOnly", "preserved"), + ), + warnings=("Review this change.",), + confirmation="type exactly 'yes'", + ) + data = _mutation_view.change_data(plan) + text = _mutation_view.change_text(plan) + assert data["changes"] == [ + {"field": "description", "before": "old", "after": "new"} + ] + assert "old → new" in text + assert "iam:update_role" in text + assert "ReadOnly" in text + + result = _mutation_view.MutationResultView( + "updated", + "IAM role", + "Agent", + arn=plan.arn, + resource_id="ARO123", + console_url="https://example.invalid/role", + journal_id="journal-1", + applied_actions=("iam:update_role",), + warnings=("Review this change.",), + plan=plan, + details={"resultCode": "IAM_ROLE_UPDATED"}, + ) + result_data = _mutation_view.result_data(result) + result_text = _mutation_view.result_text(result) + resource = result_data["resource"] + assert isinstance(resource, dict) + assert resource["id"] == "ARO123" + assert result_data["plan"] == data + assert "Recovery journal: journal-1" in result_text + assert "Applied: iam:update_role" in result_text + + +def test_shared_mutation_contract_escapes_terminal_controls() -> None: + view = _mutation_view.ChangeView( + "role-tag", + "IAM role", + "Agent\x1b[31m", + "no-change", + changes=(_mutation_view.FieldChange("enabled", before=True, after=False),), + ) + text = _mutation_view.change_text(view) + assert "\x1b" not in text + assert "yes → no" in text + result = _mutation_view.MutationResultView( + "no-change", "IAM role", "Agent", plan=view + ) + assert "remote state already matched" in _mutation_view.result_text(result) + empty = _mutation_view.ChangeView("role-update", "IAM role", "Agent", "no-change") + assert "No field changes." in _mutation_view.change_text(empty) + assert "None." in _mutation_view.change_text(empty) + + +def test_role_plan_adapter_hashes_documents_and_never_renders_them() -> None: + current = roles.RoleSnapshot( + "Agent", + "arn:aws:iam::123456789012:role/Agent", + "/", + {"Version": "2012-10-17", "Statement": []}, + description="old", + tags={roles.MANAGED_TAG: "true", roles.OWNER_TAG: "scott"}, + attached_policies=("arn:aws:iam::123456789012:policy/ReadOnly",), + ) + desired = roles.RoleSpec( + "Agent", + {"Version": "2012-10-17", "Statement": [{"Effect": "Deny"}]}, + path="/", + description="new", + owner="scott", + ) + view = _iam_role_cli._role_plan_view(roles.plan_update_role(current, desired)) + data = _mutation_view.change_data(view) + rendered = _mutation_view.change_text(view) + assert view.account_id == "123456789012" + assert any(change.field == "trust document SHA-256" for change in view.changes) + assert "Statement" not in rendered + assert "PolicyDocument" not in str(data) + assert view.dependencies[0].resource.endswith("ReadOnly") + + +def test_role_delete_view_lists_dependencies_and_name_confirmation() -> None: + current = roles.RoleSnapshot( + "Agent", + "arn:aws:iam::123456789012:role/Agent", + "/hacksaws/", + {"Version": "2012-10-17", "Statement": []}, + permissions_boundary="arn:aws:iam::123456789012:policy/Boundary", + tags={roles.MANAGED_TAG: "true", roles.OWNER_TAG: "scott"}, + attached_policies=("arn:aws:iam::aws:policy/ReadOnlyAccess",), + inline_policies=("Inline",), + inline_policy_documents={"Inline": {"Version": "2012-10-17", "Statement": []}}, + instance_profiles=("Profile",), + ) + plan = roles.plan_delete_role( + current, + cascade=True, + remove_from_instance_profiles=True, + ) + view = _iam_role_cli._role_plan_view( + roles.MutationPlan( + plan.kind, + plan.resources, + plan.operations, + plan.expected, + plan.warnings, + before=current, + ) + ) + assert view.confirmation == "type role name 'Agent'" + assert view.after_exists is False + assert len(view.dependencies) == 4 + assert view.actions[-1].destructive is True + + +def test_role_operation_adapter_covers_each_safe_effect_without_documents() -> None: + policy = f"arn:aws:iam::{ACCOUNT}:policy/ReadOnly" + document = {"Version": "2012-10-17", "Statement": []} + operations = ( + roles.Operation("iam", "attach_role_policy", {"PolicyArn": policy}), + roles.Operation("iam", "detach_role_policy", {"PolicyArn": policy}), + roles.Operation("iam", "put_role_policy", {"PolicyName": "Inline"}), + roles.Operation("iam", "delete_role_policy", {"PolicyName": "Inline"}), + roles.Operation( + "iam", + "update_assume_role_policy", + {"PolicyDocument": json.dumps(document)}, + ), + roles.Operation( + "iam", "update_assume_role_policy", {"PolicyDocument": document} + ), + roles.Operation( + "iam", + "tag_role", + { + "Tags": [ + {"Key": "Project", "Value": "TAG-VALUE-MUST-NOT-LEAK"}, + "ignored", + ] + }, + ), + roles.Operation("iam", "untag_role", {"TagKeys": ["Old"]}), + roles.Operation( + "iam", + "remove_role_from_instance_profile", + {"InstanceProfileName": "AgentProfile"}, + ), + ) + plan = roles.MutationPlan( + "role-mixed", + (ROLE_ARN, policy), + operations, + expected={"trust": "old-digest"}, + ) + view = _iam_role_cli._role_plan_view(plan) + fields = {change.field for change in view.changes} + assert f"attached policy {policy}" in fields + assert "inline policy Inline" in fields + assert "tag Project value SHA-256" in fields + assert "tag Old" in fields + assert any(change.after == "updated" for change in view.changes) + assert view.actions[-1].summary.endswith("AgentProfile") + assert view.dependencies[-1].resource == policy + encoded = json.dumps(_mutation_view.change_data(view)) + assert "TAG-VALUE-MUST-NOT-LEAK" not in encoded + + +def test_role_plan_view_handles_snapshot_after_and_unknown_identity() -> None: + after = roles.RoleSnapshot( + "Agent", + ROLE_ARN, + "/hacksaws/", + {"Version": "2012-10-17", "Statement": []}, + tags={ + roles.MANAGED_TAG: "true", + roles.OWNER_TAG: "scott", + roles.ORIGIN_TAG: "adopted", + roles.AUDIT_TAG: "audit", + }, + ) + view = _iam_role_cli._role_plan_view( + roles.MutationPlan("role-adopt", (ROLE_ARN,), (), after=after) + ) + assert view.ownership == "current" + assert view.origin == "adopted" + assert view.classification == "no-change" + unknown = _iam_role_cli._role_plan_view( + roles.MutationPlan("trust-retained", (), ()) + ) + assert unknown.name == "unknown" + assert unknown.before_exists is True + assert _iam_role_cli._state_changes(None, None) == () + assert _iam_role_cli._role_state_values(None) == {} + + +def test_role_confirmation_semantics_cover_all_mechanisms( + monkeypatch: pytest.MonkeyPatch, +) -> None: + update = roles.MutationPlan( + "role-update", + ("Agent",), + (roles.Operation("iam", "update_role", {"RoleName": "Agent"}),), + ) + delete = roles.MutationPlan( + "role-delete", + (ROLE_ARN,), + (roles.Operation("iam", "delete_role", {"RoleName": "Agent"}),), + ) + assert _iam_role_cli._confirm_plan( + argparse.Namespace(), roles.MutationPlan("noop", (), ()) + ) + assert _iam_role_cli._confirm_plan(argparse.Namespace(yes=True), update) + assert _audit.confirmation() == "yes-flag:bypassed" + + monkeypatch.setattr(_iam_role_cli.sys.stdin, "isatty", lambda: False) + assert not _iam_role_cli._confirm_plan(argparse.Namespace(), update) + assert _audit.confirmation() == "exact-yes:unavailable" + assert not _iam_role_cli._confirm_plan(argparse.Namespace(json=True), delete) + assert _audit.confirmation() == "resource-name:unavailable" + + monkeypatch.setattr(_iam_role_cli.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(_iam_role_cli, "_input", lambda _prompt: "Agent") + assert _iam_role_cli._confirm_plan(argparse.Namespace(), delete) + assert _audit.confirmation() == "resource-name:accepted" + monkeypatch.setattr(_iam_role_cli, "_input", lambda _prompt: "wrong") + assert not _iam_role_cli._confirm_plan(argparse.Namespace(), delete) + assert _audit.confirmation() == "resource-name:declined" + monkeypatch.setattr(_iam_role_cli, "_input", lambda _prompt: "yes") + assert _iam_role_cli._confirm_plan(argparse.Namespace(), update) + assert _audit.confirmation() == "exact-yes:accepted" + monkeypatch.setattr(_iam_role_cli, "_input", lambda _prompt: "no") + assert not _iam_role_cli._confirm_plan(argparse.Namespace(), update) + assert _audit.confirmation() == "exact-yes:declined" + + +def test_role_execute_prepares_each_recovery_operation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recorded: list[tuple[str, dict[str, object], dict[str, object]]] = [] + + class Journal: + id = "journal-1" + + def record_before_mutation( + self, + handler: str, + *, + forward: dict[str, object], + compensation: dict[str, object], + ) -> None: + recorded.append((handler, forward, compensation)) + + continued: list[str] = [] + monkeypatch.setattr( + _iam_role_cli, "_materialize_managed_operations", lambda plan, _context: plan + ) + monkeypatch.setattr(_iam_role_cli, "_confirm_plan", lambda _args, _plan: True) + monkeypatch.setattr( + _iam_role_cli, "_assert_preconditions", lambda _plan, _context: None + ) + monkeypatch.setattr(_iam_role_cli, "ensure_role_recovery_handlers", lambda: None) + monkeypatch.setattr( + _iam_role_cli.recovery, "begin_journal", lambda *_args, **_kwargs: Journal() + ) + monkeypatch.setattr( + _iam_role_cli.recovery, + "continue_journal", + lambda journal_id, _context: continued.append(journal_id), + ) + operations = ( + roles.Operation( + "managed_policy", + "publish", + {"State": {"exists": True}}, + compensate_params={"State": {"exists": False}}, + ), + roles.Operation( + "iam", + "create_role", + {"RoleName": "Agent"}, + "delete_role", + {"RoleName": "Agent"}, + ), + roles.Operation( + "iam", + "attach_role_policy", + {"RoleName": "Agent", "PolicyArn": "arn:policy"}, + "detach_role_policy", + {"RoleName": "Agent", "PolicyArn": "arn:policy"}, + ), + ) + args = argparse.Namespace(yes=True) + journal = _iam_role_cli._execute( + roles.MutationPlan("role-create", ("Agent",), operations), _context(), args + ) + assert journal is not None + assert journal.id == "journal-1" + assert [item[0] for item in recorded] == [ + "publish-owned-policy--restore-owned-policy", + "create-role-with-receipt", + "attach-role-policy--detach-role-policy", + ] + assert recorded[1][2]["effectSourceStep"] == "self" + assert continued == ["journal-1"] + assert args._mutation_journal_id == "journal-1" + + +def test_role_execute_dry_run_cancel_noop_and_unknown_handler( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + _iam_role_cli, "_materialize_managed_operations", lambda plan, _context: plan + ) + noop = roles.MutationPlan("noop", ("Agent",), ()) + monkeypatch.setattr(_iam_role_cli, "_confirm_plan", lambda _args, _plan: True) + assert _iam_role_cli._execute(noop, _context(), argparse.Namespace()) is None + with pytest.raises(_iam_role_cli._DryRunCompletedError): + _iam_role_cli._execute(noop, _context(), argparse.Namespace(dry_run=True)) + + operation = roles.Operation("iam", "unknown", {}) + plan = roles.MutationPlan("unknown", ("Agent",), (operation,)) + monkeypatch.setattr(_iam_role_cli, "_confirm_plan", lambda _args, _plan: False) + with pytest.raises(_iam_role_cli._MutationCancelledError): + _iam_role_cli._execute(plan, _context(), argparse.Namespace()) + monkeypatch.setattr(_iam_role_cli, "_confirm_plan", lambda _args, _plan: True) + monkeypatch.setattr( + _iam_role_cli, "_assert_preconditions", lambda _plan, _context: None + ) + monkeypatch.setattr(_iam_role_cli, "ensure_role_recovery_handlers", lambda: None) + with pytest.raises(OperationalError, match="no whitelisted recovery handler"): + _iam_role_cli._execute(plan, _context(), argparse.Namespace()) + + +def test_role_argument_normalization_flag_forms_and_errors(tmp_path: Path) -> None: + document = tmp_path / "policy.yaml" + document.write_text("Version: '2012-10-17'", encoding="utf-8") + attach = argparse.Namespace( + role_command="attach", + policy_input=None, + policy_reference=None, + policy_file=document, + ) + _iam_role_cli.normalize_arguments(attach) + assert attach.policy == str(document) + inline = argparse.Namespace( + role_command="inline-policy", + role_inline_action="put", + policy_inputs=[str(document)], + explicit_policy_name="Inline", + explicit_file=None, + ) + _iam_role_cli.normalize_arguments(inline) + assert inline.policy == "Inline" + assert inline.file == document + trust = argparse.Namespace( + role_command="trust", + role_trust_action="set", + trust_inputs=["Agent"], + explicit_role=None, + explicit_file=document, + ) + _iam_role_cli.normalize_arguments(trust) + assert trust.role == "Agent" + assert trust.file == document + missing = argparse.Namespace( + role_command="attach", + policy_input=None, + policy_reference=None, + policy_file=tmp_path / "missing.json", + ) + with pytest.raises(OperationalError, match="does not exist"): + _iam_role_cli.normalize_arguments(missing) + untouched = argparse.Namespace(role_command="list") + _iam_role_cli.normalize_arguments(untouched) + + +@pytest.mark.parametrize( + ("code", "operations", "expected"), + [ + ("IAM_ROLE_COLLISION", True, "conflict"), + ("IAM_ROLE_MUTATION_CANCELLED", True, "cancelled"), + ("IAM_ROLE_NO_CHANGE", True, "no-change"), + ("IAM_ROLE_UPDATED", False, "no-change"), + ("IAM_ROLE_CREATED", True, "created"), + ("IAM_ROLE_DELETED", True, "deleted"), + ("IAM_ROLE_POLICY_ATTACH", True, "updated"), + ("IAM_ROLE_COMPLETE", True, "applied"), + ], +) +def test_role_result_classification(code: str, operations: bool, expected: str) -> None: + items = ( + (roles.Operation("iam", "update_role", {"RoleName": "Agent"}),) + if operations + else () + ) + assert ( + _iam_role_cli._result_classification( + _configs.Result(code, "done"), + roles.MutationPlan("role-update", ("Agent",), items), + ) + == expected + ) + + +def test_role_result_presentation_preserves_envelope_and_safe_details() -> None: + operation = roles.Operation("iam", "update_role", {"RoleName": "Agent"}) + plan = roles.MutationPlan("role-update", (ROLE_ARN,), (operation,)) + original = _configs.Result( + "IAM_ROLE_UPDATED", + "old", + data={ + "arn": ROLE_ARN, + "roleId": "ARO123", + "consoleUrl": "https://console.example/Agent", + "warning": "Naming warning", + "trust": {"SECRET": "must-not-render"}, + }, + details={"detail": True}, + repairs=["repair"], + kind="success", + ) + args = argparse.Namespace(_mutation_journal_id="journal-1") + presented = _iam_role_cli._present_role_result(original, plan, args, _context()) + assert presented.code == original.code + assert presented.details == original.details + assert presented.repairs == original.repairs + assert presented.kind == "success" + assert isinstance(presented.data, dict) + assert presented.data["journalId"] == "journal-1" + resource = presented.data["resource"] + assert isinstance(resource, dict) + assert resource["id"] == "ARO123" + assert "SECRET" not in str(presented.data) + assert "Naming warning" in presented.message + assert ( + _iam_role_cli._present_role_result( + original, None, argparse.Namespace(), _context() + ) + is original + ) + fallback = _iam_role_cli._present_role_result( + _configs.Result("IAM_ROLE_UPDATED", "old", data="not-a-map"), + plan, + argparse.Namespace(), + _context(), + ) + assert "AWS Console: https://us-west-2" in fallback.message + + +def test_role_dispatch_translates_expected_error_families( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_iam_role_cli, "normalize_arguments", lambda _args: None) + args = argparse.Namespace() + monkeypatch.setattr(_iam_role_cli, "_dispatch", lambda _args, _context: None) + assert _iam_role_cli.dispatch(args, _context()) is None + + error = OperationalError("already normalized") + + def operational(*_args: object) -> None: + raise error + + monkeypatch.setattr(_iam_role_cli, "_dispatch", operational) + with pytest.raises(OperationalError) as captured: + _iam_role_cli.dispatch(args, _context()) + assert captured.value is error + + conflict = roles.ConflictError("role conflict") + + def role_error(*_args: object) -> None: + raise conflict + + monkeypatch.setattr(_iam_role_cli, "_dispatch", role_error) + with pytest.raises(OperationalError, match="role conflict"): + _iam_role_cli.dispatch(args, _context()) + + def aws_error(*_args: object) -> None: + raise EndpointConnectionError(endpoint_url="https://iam.invalid") + + monkeypatch.setattr(_iam_role_cli, "_dispatch", aws_error) + with pytest.raises(OperationalError, match="AWS IAM role operation failed"): + _iam_role_cli.dispatch(args, _context()) diff --git a/hacksaws/tests/test_sessions_coverage.py b/hacksaws/tests/test_sessions_coverage.py index 4eaa672..b456144 100644 --- a/hacksaws/tests/test_sessions_coverage.py +++ b/hacksaws/tests/test_sessions_coverage.py @@ -132,6 +132,42 @@ def _write_source(aws: Path) -> None: ) +def _write_browser_login( + config: Path, + cache: Path, + profile: str, + *, + login_session: str = f"arn:aws:iam::{ACCOUNT}:user/test", + dpop: str = "test-DPoP-generation", +) -> Path: + parser = _sessions._read_ini(config) + parser[_sessions._section(profile, config=True)] = { + "region": "us-west-2", + "login_session": login_session, + } + _sessions._write_ini(config, parser) + cache.mkdir(parents=True, exist_ok=True) + path = cache / f"{_state.digest(login_session.encode())}.json" + path.write_text( + json.dumps( + { + "accessToken": { + "accessKeyId": "access", + "secretAccessKey": "secret", + "sessionToken": "token", + "accountId": ACCOUNT, + "expiresAt": "2030-01-01T00:00:00Z", + }, + "refreshToken": "refresh", + "clientId": "client", + "dpopKey": dpop, + } + ), + encoding="utf-8", + ) + return path + + def _archive(path: Path, config: dict[str, object], files: dict[str, bytes]) -> Path: config_bytes = (json.dumps(config, indent=2) + "\n").encode() payloads = {"config.json": config_bytes, **files} @@ -146,7 +182,7 @@ def _archive(path: Path, config: dict[str, object], files: dict[str, bytes]) -> return path -def test_journal_commit_and_crash_recovery_restore_files_and_cache( +def test_journal_recovery_restores_files_without_snapshotting_browser_cache( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _home(tmp_path, monkeypatch) @@ -159,7 +195,9 @@ def test_journal_commit_and_crash_recovery_restore_files_and_cache( preexisting_directory.mkdir(parents=True) old_cache.write_bytes(b"cached") - journal = _sessions._begin([original, created], cache_roots=[cache]) + journal = _sessions._begin([original, created]) + assert journal["browser_cache_claims"] == [] + assert "cache_snapshots" not in journal assert _sessions._journal_path().exists() original.write_bytes(b"changed") created.write_bytes(b"new") @@ -172,9 +210,9 @@ def test_journal_commit_and_crash_recovery_restore_files_and_cache( _sessions.recover_journal() assert original.read_bytes() == b"original" assert not created.exists() - assert old_cache.read_bytes() == b"cached" - assert not (cache / "new.json").exists() - assert not (cache / "created").exists() + assert old_cache.read_bytes() == b"changed-cache" + assert (cache / "new.json").exists() + assert (cache / "created").exists() assert preexisting_directory.is_dir() assert not _sessions._journal_path().exists() @@ -184,18 +222,18 @@ def test_journal_commit_and_crash_recovery_restore_files_and_cache( assert journal["safe_to_rollback"] is True -def test_cache_rollback_removes_an_entire_new_nested_cache_root( +def test_generic_rollback_never_claims_an_entire_new_cache_root( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _home(tmp_path, monkeypatch) cache = tmp_path / "absent-before" assert not cache.exists() - journal = _sessions._begin([], cache_roots=[cache]) + journal = _sessions._begin([]) token = cache / "provider" / "nested" / "token.json" token.parent.mkdir(parents=True) token.write_text("broad", encoding="utf-8") _sessions._rollback(journal) - assert not cache.exists() + assert token.exists() assert not _sessions._journal_path().exists() @@ -695,18 +733,34 @@ def test_native_browser_remote_cache_ecr_success_and_logout( old_cache = aws / "login" / "cache" / "old.json" old_cache.parent.mkdir(parents=True) old_cache.write_text("old") - new_cache = old_cache.with_name("new.json") + new_cache = old_cache.with_name( + f"{_state.digest(f'arn:aws:iam::{ACCOUNT}:user/test'.encode())}.json" + ) monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(old_cache.parent)) native = MagicMock(region_name="us-west-2") registry = f"{ACCOUNT}.dkr.ecr.us-west-2.amazonaws.com" - def login(*args: object, **kwargs: object) -> None: - new_cache.write_text("new") + def login( + config: Path, + _credentials: Path, + profile: str, + *, + remote: bool, + login_cache: Path, + ) -> None: + del remote + assert ( + _write_browser_login(config, login_cache, profile).absolute() + == new_cache.absolute() + ) with ( patch("hacksaws._sessions._aws_login", side_effect=login) as aws_login, patch("hacksaws._sessions.boto3.Session", return_value=native), - patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch( + "hacksaws._sessions._identity", + return_value=(ACCOUNT, "aws", f"arn:aws:iam::{ACCOUNT}:user/test"), + ), patch("hacksaws._ecr.login_with_session", return_value=[registry]), ): result = _sessions.browser_login( @@ -715,7 +769,7 @@ def login(*args: object, **kwargs: object) -> None: assert result.code == "BROWSER_LOGIN" assert aws_login.call_args.kwargs["remote"] is True saved = _state.load_sessions()[f"{aws.absolute()}::out"] - assert saved["login_cache_files"] == [str(new_cache.absolute())] + assert saved["login_cache_lineage"]["path"] == str(new_cache.absolute()) assert old_cache.exists() with patch("hacksaws._ecr._run_container_engine") as engine: @@ -734,7 +788,8 @@ def test_native_browser_logout_preserves_a_later_same_path_replacement( aws = tmp_path / "alternate-aws" cache = tmp_path / "shared-login-cache" old_cache = cache / "old.json" - new_cache = cache / "debug.json" + login_session = f"arn:aws:iam::{ACCOUNT}:user/test" + new_cache = cache / f"{_state.digest(login_session.encode())}.json" old_cache.parent.mkdir(parents=True) old_cache.write_text("old", encoding="utf-8") monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(cache)) @@ -752,22 +807,24 @@ def login( assert profile == "debug" assert login_cache == cache.absolute() config.parent.mkdir(parents=True, exist_ok=True) - config.write_text( - "[profile debug]\nregion=us-west-2\nlogin_session=x\n", - encoding="utf-8", + assert ( + _write_browser_login(config, login_cache, profile).absolute() + == new_cache.absolute() ) - new_cache.write_text("new", encoding="utf-8") args = _args(directory=str(aws), profile="debug") with ( patch("hacksaws._sessions._aws_login", side_effect=login), patch("hacksaws._sessions.boto3.Session", return_value=native), - patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch( + "hacksaws._sessions._identity", + return_value=(ACCOUNT, "aws", login_session), + ), ): _sessions.browser_login(_configs.Context(args)) saved = _state.load_sessions()[f"{aws.absolute()}::debug"] - assert saved["login_cache_files"] == [str(new_cache.absolute())] - assert saved["login_cache_directories"] == [str(cache.absolute())] + assert saved["login_cache_lineage"]["path"] == str(new_cache.absolute()) + assert saved["login_cache_lineage"]["root"] == str(cache.absolute()) assert old_cache.read_text(encoding="utf-8") == "old" new_cache.write_text("independent replacement", encoding="utf-8") with pytest.raises(_configs.OperationalError, match="changed after login"): @@ -787,7 +844,8 @@ def test_native_browser_post_login_failure_reports_complete_rollback( config.write_bytes(b"[default]\nregion=us-east-1\n") cache = tmp_path / "cache" old_cache = cache / "old.json" - new_cache = cache / "new.json" + login_session = f"arn:aws:iam::{ACCOUNT}:user/test" + new_cache = cache / f"{_state.digest(login_session.encode())}.json" cache.mkdir() old_cache.write_text("old", encoding="utf-8") monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(cache)) @@ -802,8 +860,10 @@ def login( ) -> None: del credentials, profile, remote assert login_cache == cache.absolute() - config_path.write_text("[profile debug]\nlogin_session=x\n", encoding="utf-8") - new_cache.write_text("broad", encoding="utf-8") + assert ( + _write_browser_login(config_path, login_cache, "debug").absolute() + == new_cache.absolute() + ) args = _args(directory=str(aws), profile="debug") with ( @@ -848,15 +908,16 @@ def login( assert root / "staging" in login_cache.parents assert login_cache != inherited_cache config.parent.mkdir(parents=True, exist_ok=True) - config.write_text("[profile dev]\nregion=us-west-2\nlogin_session=x\n") + _write_browser_login(config, login_cache, "dev") credentials.write_text("[dev]\na=x\n") - login_cache.mkdir(parents=True) - (login_cache / "broad.json").write_text("broad", encoding="utf-8") with ( patch("hacksaws._sessions._aws_login", side_effect=login), patch("hacksaws._sessions.boto3.Session", return_value=intermediate), - patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch( + "hacksaws._sessions._identity", + return_value=(ACCOUNT, "aws", f"arn:aws:iam::{ACCOUNT}:user/test"), + ), patch( "hacksaws._sessions._assume", return_value=(_credentials(), {"target_account": ACCOUNT}), @@ -891,15 +952,17 @@ def login( remote: bool, login_cache: Path, ) -> None: - del config, credentials, profile, remote + del credentials, remote assert inherited_cache not in login_cache.parents - login_cache.mkdir(parents=True) - (login_cache / "broad.json").write_text("broad", encoding="utf-8") + _write_browser_login(config, login_cache, profile) with ( patch("hacksaws._sessions._aws_login", side_effect=login), patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), - patch("hacksaws._sessions._identity", return_value=(ACCOUNT, "aws", "arn")), + patch( + "hacksaws._sessions._identity", + return_value=(ACCOUNT, "aws", f"arn:aws:iam::{ACCOUNT}:user/test"), + ), patch("hacksaws._sessions._assume", side_effect=RuntimeError("after-auth")), pytest.raises(RuntimeError, match="after-auth"), ): @@ -1006,7 +1069,8 @@ def test_logout_conservatively_preserves_legacy_unfingerprinted_cache( assert _sessions.logout( _configs.Context(_args(directory=str(aws), profile="dev", force=True)) ) - assert not cache.exists() + assert cache.exists() + assert _state.load_sessions()[key]["auth_method"] == "browser-cache-residue" def test_status_is_secret_free_and_handles_expiry_values( diff --git a/hacksaws/tests/test_v04.py b/hacksaws/tests/test_v04.py index bdf00aa..140d37d 100644 --- a/hacksaws/tests/test_v04.py +++ b/hacksaws/tests/test_v04.py @@ -65,6 +65,36 @@ def _minimal_target(home: Path, *, boundary: bool = False) -> None: _state.save_config(data) +def _browser_login_files(config: Path, cache: Path, profile: str) -> Path: + login_session = "arn:aws:iam::123456789012:user/dev" + parser = _sessions._read_ini(config) + parser[_sessions._section(profile, config=True)] = { + "login_session": login_session, + "region": "us-east-1", + } + _sessions._write_ini(config, parser) + cache.mkdir(parents=True, exist_ok=True) + path = cache / f"{_state.digest(login_session.encode())}.json" + path.write_text( + json.dumps( + { + "accessToken": { + "accessKeyId": "access", + "secretAccessKey": "secret", + "sessionToken": "token", + "accountId": "123456789012", + "expiresAt": "2030-01-01T00:00:00Z", + }, + "refreshToken": "refresh", + "clientId": "client", + "dpopKey": "dpop-generation", + } + ), + encoding="utf-8", + ) + return path + + def test_remote_name_listing_failure_is_only_same_account( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -247,12 +277,22 @@ def test_native_browser_cache_is_removed_after_identity_failure( ) -> None: monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) _minimal_target(tmp_path) - cache_file = tmp_path / "aws" / "login" / "cache" / "new.json" - monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(cache_file.parent)) + cache_root = tmp_path / "aws" / "login" / "cache" + cache_file = cache_root / ( + f"{_state.digest(b'arn:aws:iam::123456789012:user/dev')}.json" + ) + monkeypatch.setenv("AWS_LOGIN_CACHE_DIRECTORY", str(cache_root)) - def fake_login(*args: object, **kwargs: object) -> None: - cache_file.parent.mkdir(parents=True, exist_ok=True) - cache_file.write_text("{}", encoding="utf-8") + def fake_login( + config: Path, + _credentials: Path, + profile: str, + *, + remote: bool, + login_cache: Path, + ) -> None: + del remote + assert _browser_login_files(config, login_cache, profile) == cache_file namespace = _cli._create_parser().parse_args(["web", "in", "+Prod"]) _cli._validate_login(namespace) @@ -605,8 +645,19 @@ def ecr_login( on_success(registry) # type: ignore[operator] return [registry] + def browser_login( + config: Path, + _credentials: Path, + profile: str, + *, + remote: bool, + login_cache: Path, + ) -> None: + del remote + _browser_login_files(config, login_cache, profile) + with ( - patch("hacksaws._sessions._aws_login"), + patch("hacksaws._sessions._aws_login", side_effect=browser_login), patch("boto3.Session", return_value=MagicMock(region_name="us-east-1")), patch( "hacksaws._sessions._identity", @@ -781,7 +832,7 @@ def test_remote_check_does_not_scope_same_id_other_partition( iam.get_role.assert_not_called() -def test_cache_rollback_restores_modified_deleted_and_removes_created( +def test_generic_rollback_does_not_snapshot_or_rewrite_cache_files( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "home")) @@ -792,14 +843,14 @@ def test_cache_rollback_restores_modified_deleted_and_removes_created( created = cache / "created.json" modified.write_bytes(b"original-modified") deleted.write_bytes(b"original-deleted") - journal = _sessions._begin([], cache_roots=[cache]) + journal = _sessions._begin([]) modified.write_bytes(b"changed") deleted.unlink() created.write_bytes(b"new") _sessions._rollback(journal) - assert modified.read_bytes() == b"original-modified" - assert deleted.read_bytes() == b"original-deleted" - assert not created.exists() + assert modified.read_bytes() == b"changed" + assert not deleted.exists() + assert created.read_bytes() == b"new" def test_import_rejects_manifest_declared_unused_junk( diff --git a/scripts/prettier.py b/scripts/prettier.py index 04e4d01..c7d89a5 100644 --- a/scripts/prettier.py +++ b/scripts/prettier.py @@ -6,6 +6,7 @@ import shutil import subprocess import sys +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -38,7 +39,12 @@ def _candidates(paths: Sequence[str]) -> tuple[int, list[str]]: ) if completed.returncode: return completed.returncode, [] - candidates = [os.fsdecode(item) for item in completed.stdout.split(b"\0") if item] + candidates = [ + candidate + for item in completed.stdout.split(b"\0") + if item + if Path(candidate := os.fsdecode(item)).is_file() + ] return 0, candidates From 435ee594adce7de0396cbee2930c3eeb1912f0dd Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Sun, 2 Aug 2026 19:26:17 -0500 Subject: [PATCH 7/8] Unify AWS Region Handling - **Region Resolution** - Establish canonical, partition-aware region selection with friendly aliases and explicit precedence so every AWS operation uses one predictable region without accepting ambiguous input. - **Session Safety** - Preserve and transactionally update profile region state across browser, MFA, AssumeRole, ECR, and logout workflows so failed handoffs restore prior configuration and bounded credentials remain safe. - **Guided Operations** - Add discovery, explanation, alias, profile, and configuration commands with human-readable guidance so users can inspect, repair, and automate multi-account region choices confidently. - **Operational Reliability** - Validate region support and partition compatibility before remote actions while closing failed history database initialization cleanly to keep warning-free automation dependable. --- CHEATSHEET.md | 34 ++ README.md | 9 + docs/configuration.md | 25 +- docs/regions.md | 138 +++++ hacksaws/_cli.py | 619 ++++++++++++++++++++- hacksaws/_ecr.py | 56 +- hacksaws/_history.py | 62 ++- hacksaws/_iam_cli.py | 136 ++++- hacksaws/_iam_policy_cli.py | 24 +- hacksaws/_iam_role_cli.py | 40 +- hacksaws/_regions.py | 661 ++++++++++++++++++++++ hacksaws/_sessions.py | 523 +++++++++++++++++- hacksaws/_state.py | 116 +++- hacksaws/tests/test_assume_role.py | 14 +- hacksaws/tests/test_hacksaws.py | 131 ++--- hacksaws/tests/test_history.py | 16 + hacksaws/tests/test_iam_cli_scaffold.py | 90 +++ hacksaws/tests/test_iam_policy_cli.py | 10 + hacksaws/tests/test_iam_role_cli.py | 31 ++ hacksaws/tests/test_output_foundation.py | 6 +- hacksaws/tests/test_regions.py | 667 +++++++++++++++++++++++ hacksaws/tests/test_session_regions.py | 186 +++++++ hacksaws/tests/test_sessions_coverage.py | 53 +- hacksaws/tests/test_v04.py | 63 +++ 24 files changed, 3544 insertions(+), 166 deletions(-) create mode 100644 docs/regions.md create mode 100644 hacksaws/_regions.py create mode 100644 hacksaws/tests/test_regions.py create mode 100644 hacksaws/tests/test_session_regions.py diff --git a/CHEATSHEET.md b/CHEATSHEET.md index f06883a..c7b4610 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -147,6 +147,40 @@ hacksaws target add NAME --source-account ACCOUNT [--source-profile PROFILE] \ hacksaws target update NAME [--boundary NAME|--clear-boundary] ``` +## Regions + +```shell +hacksaws region list [PATTERN]... [--partition PARTITION|--account ACCOUNT] +hacksaws region list --all-partitions +hacksaws region explain REGION_OR_ALIAS [--account ACCOUNT] +hacksaws region alias add ALIAS REGION_OR_ALIAS [--description TEXT] +hacksaws region alias update ALIAS [--region REGION_OR_ALIAS] \ + [--description TEXT|--clear-description] +hacksaws region alias get ALIAS +hacksaws region alias list [PATTERN]... +hacksaws region alias rename ALIAS NEW_ALIAS +hacksaws region alias remove ALIAS + +hacksaws account update ACCOUNT --region REGION_OR_ALIAS|--clear-region +hacksaws target update TARGET --region REGION_OR_ALIAS|--clear-region +hacksaws config set aws.region REGION_OR_ALIAS +hacksaws config reset aws.region +hacksaws profile region get [--profile PROFILE|--target TARGET] +hacksaws profile region set REGION_OR_ALIAS [--profile PROFILE|--target TARGET] +hacksaws profile region clear [--profile PROFILE|--target TARGET] +``` + +Accepted inputs are canonical names (`us-west-2`), collision-free compact +aliases (`usw2`), curated geography aliases (`oregon`), and global custom +aliases. Aliases are input-only; configuration persists canonical names. + +Precedence: `--region` → `AWS_REGION` → `AWS_DEFAULT_REGION` → saved target → +destination profile → source profile → account preference → `aws.region` → +interactive prompt. Noninteractive execution never prompts. Use +`--allow-unknown-region` only for an exact canonical-shaped region missing from +Botocore; alias-like unknowns and partition mismatches still fail. See +[Regions and aliases](docs/regions.md). + ## Policies ```shell diff --git a/README.md b/README.md index 5aed355..34f0378 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,14 @@ hacksaws web in debug aws sts get-caller-identity --profile debug ``` +Regions accept canonical names and friendly aliases. Hacksaws explains the +resolution and always persists the canonical AWS name: + +```shell +hacksaws region explain oregon +# Canonical region: us-west-2 +``` + `pk` is an exact alias for `web`: ```shell @@ -272,6 +280,7 @@ models. - [IAM roles and trust](docs/iam-roles-and-trust.md) - [Cleanup and Leave No Trace](docs/cleanup.md) - [Configuration](docs/configuration.md) +- [Regions and aliases](docs/regions.md) - [Policy cache](docs/cache.md) - [Local command history](docs/history.md) - [Security model](docs/security-model.md) diff --git a/docs/configuration.md b/docs/configuration.md index 0a7294d..280eaed 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,7 +1,7 @@ # Configuration -Hacksaws stores first-class configuration in `~/.hacksaws/configs.json`. -Accounts scope boundaries, naming rules, validation, check/fix, and remote IAM +Hacksaws stores first-class configuration in `~/.hacksaws/config.json`. Accounts +scope boundaries, naming rules, validation, check/fix, and remote IAM operations. Targets save login source/destination choices; boundaries save role, policy, external ID, and duration choices. @@ -11,6 +11,8 @@ hacksaws account list hacksaws account rename prod production hacksaws boundary add logs AgentSession --account prod --policy LogsRead hacksaws target add debug --source-account prod --source-profile admin +hacksaws target update debug --region oregon +hacksaws config set aws.region us-east-2 hacksaws config show --account prod hacksaws config explain debug hacksaws config check --account prod --profile admin --remote @@ -27,6 +29,25 @@ performs a deny-all AssumeRole probe before offering interactive repairs. Naming rules have global account defaults and policy/role overrides for prefix, suffix, case, path, and enforcement. +Region preferences are also first-class settings. Values supplied through the +CLI may be canonical names or aliases; the configuration always stores the +canonical region. Global custom aliases are input-only and portable across +accounts: + +```shell +hacksaws region alias add pacific us-west-2 --description "Primary west region" +hacksaws account update prod --region pacific +hacksaws target update debug --region oregon +hacksaws config get aws.region +hacksaws config reset targets.debug.region +``` + +The schema-one region fields are `aws.region`, `aws.region_aliases`, optional +`accounts.NAME.region`, and optional `targets.NAME.region`. `config export` +includes them, and `config import`, `check`, and `fix` validate canonical +storage, alias collisions, and account partition compatibility. See +[Regions and aliases](regions.md) for precedence and discovery commands. + Local history settings are first-class options as well: ```shell diff --git a/docs/regions.md b/docs/regions.md new file mode 100644 index 0000000..89dc509 --- /dev/null +++ b/docs/regions.md @@ -0,0 +1,138 @@ +# Regions and aliases + +Hacksaws resolves AWS regions once, before creating AWS sessions, and carries +the canonical region through authentication, STS identity checks, IAM helpers, +ECR login, generated console links, and destination profile configuration. This +avoids one command accidentally using different regions at different stages. + +## Discover and explain + +```shell +hacksaws region list +hacksaws region list "*west*" "*oregon*" +hacksaws region explain usw2 +hacksaws region explain oregon +hacksaws region list --account production +hacksaws region list --partition aws-cn +hacksaws region list --all-partitions +``` + +`region list` shows the canonical name, AWS description, partition, +collision-free compact alias, curated geography aliases, and custom aliases. +Patterns use case-insensitive fnmatch syntax and multiple patterns are ORed. +`--account` limits results to that configured account's partition. Add global +`--json` for stable machine-readable output. + +Hacksaws discovers every partition in the installed Botocore metadata. It can +operate only in `aws`, `aws-cn`, and `aws-us-gov`; `--all-partitions` is for +discovery and explanation, not permission to run AWS operations elsewhere. + +## Accepted inputs + +Every region-bearing command accepts: + +- canonical names, such as `us-west-2`; +- collision-free compact aliases, such as `usw2`; +- curated, unambiguous geography aliases, such as `oregon`; +- global custom aliases configured by the user. + +Aliases are input-only. Configuration and AWS profiles always receive the +canonical name, so changing or removing an alias never changes existing saved +consumers. + +Manage portable custom aliases with: + +```shell +hacksaws region alias add pacific us-west-2 --description "Primary west region" +hacksaws region alias list "pac*" +hacksaws region alias get pacific +hacksaws region alias update pacific --region us-west-1 +hacksaws region alias rename pacific west-coast +hacksaws region alias remove west-coast +``` + +Custom aliases use normalized lower-kebab-case. They cannot shadow a canonical +name or built-in alias, point to another alias, or target a region absent from +Botocore. Those rules prevent alias chains and machine-dependent resolution. + +## Resolution precedence + +The effective region is the first available value in this exact order: + +1. command `--region`; +2. `AWS_REGION`; +3. `AWS_DEFAULT_REGION`; +4. saved target region; +5. existing destination AWS profile region; +6. source AWS profile region; +7. configured account preference; +8. global `aws.region` setting; +9. a Hacksaws-owned interactive prompt. + +An environment-selected region is written to the destination only when that +destination does not already store a region. All other selected values are +persisted canonically where the workflow installs a region. + +If nothing resolves, an interactive terminal prompts with retry, suggestions, +`?` to list candidates, and `q` to cancel. Non-interactive and JSON execution +never prompts; it returns a structured `REGION_REQUIRED`, `REGION_INVALID`, or +`REGION_UNKNOWN` error with candidates and repair guidance. + +## Saved preferences + +```shell +# Global fallback +hacksaws config set aws.region oregon +hacksaws config reset aws.region + +# Account preference +hacksaws account update production --region ohio +hacksaws account update production --clear-region + +# Highest-priority saved target preference +hacksaws target update prod-agent --region pacific +hacksaws target update prod-agent --clear-region +``` + +Inspect or deliberately change the physical region stored on an AWS profile: + +```shell +hacksaws profile region get --profile debug +hacksaws profile region set oregon --profile debug +hacksaws profile region clear --profile debug +``` + +The profile command accepts the usual `--profile`, `--location`/`--directory`, +or saved `--target` selector. Updates are transactional. For a Hacksaws-managed +session, Hacksaws also rebases its logout metadata so a later logout preserves +the deliberate change. Clearing is refused for a live native-browser profile, +because the AWS browser credential provider requires a physical region; log out +first in that case. + +`hacksaws config show` includes account, target, global, and alias information. +`config options` documents the dotted settings; direct equivalents such as +`config set accounts.production.region ohio` and +`config reset targets.prod-agent.region` are supported. + +## New regions and service validation + +Botocore metadata can lag a newly announced canonical region. Use +`--allow-unknown-region` only when the exact canonical-shaped value is known: + +```shell +hacksaws region explain us-future-1 --allow-unknown-region +hacksaws iam policy list --region us-future-1 --allow-unknown-region +``` + +The escape hatch never accepts alias-like input such as `future-west`, never +bypasses account partition checks, and emits a warning because service support +could not be verified. Without the flag, unknown values fail before credentials +or AWS mutations are attempted. + +IAM/remote/cleanup uses the canonical region for credential refresh, STS +identity, regional helpers, and partition-correct console links. A region whose +partition differs from the authenticated caller is rejected. Repeated +`--ecr-region` values accept the same aliases, are canonicalized and +deduplicated in input order after the effective primary region, and are checked +for ECR support. ECR login still uses the intermediate authenticated credentials +before any boundary role is assumed. diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index 320f0be..071eb9b 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -27,11 +27,11 @@ from hacksaws import _aws from hacksaws import _configs from hacksaws import _duration -from hacksaws import _ecr from hacksaws import _history from hacksaws import _iam_cli from hacksaws import _output from hacksaws import _policies +from hacksaws import _regions from hacksaws import _sessions from hacksaws import _state @@ -211,6 +211,11 @@ def _login_arguments(parser: argparse.ArgumentParser, *, browser: bool = False) parser.add_argument( "--region", help="AWS region used for login and regional operations." ) + parser.add_argument( + "--allow-unknown-region", + action="store_true", + help="Accept a new canonical AWS region absent from bundled Botocore data.", + ) _duration_arguments(parser) _ecr_arguments(parser) if browser: @@ -326,6 +331,11 @@ def _assume_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--region", help="AWS region used for credential resolution and console links." ) + parser.add_argument( + "--allow-unknown-region", + action="store_true", + help="Accept a new canonical AWS region absent from bundled Botocore data.", + ) _duration_arguments(parser) parser.add_argument( "--keep-source", @@ -444,6 +454,32 @@ def _resource_parser(parent: argparse._SubParsersAction[Any], kind: str) -> None if kind == "account": add.add_argument("account_id") add.add_argument("--partition", choices=sorted(_state.PARTITIONS)) + add.add_argument( + "--region", + metavar="REGION_OR_ALIAS", + help="Preferred region for this account; stored canonically.", + ) + add.add_argument( + "--allow-unknown-region", + action="store_true", + help="Accept a canonical-shaped region absent from bundled metadata.", + ) + account_region = update.add_mutually_exclusive_group() + account_region.add_argument( + "--region", + metavar="REGION_OR_ALIAS", + help="Replace this account's preferred region.", + ) + account_region.add_argument( + "--clear-region", + action="store_true", + help="Remove this account's preferred region.", + ) + update.add_argument( + "--allow-unknown-region", + action="store_true", + help="Accept a canonical-shaped region absent from bundled metadata.", + ) _credential_selector(add) _credential_selector(update) elif kind == "boundary": @@ -472,8 +508,34 @@ def _resource_parser(parent: argparse._SubParsersAction[Any], kind: str) -> None add.add_argument("--to-directory") add.add_argument("--to-profile") add.add_argument("--boundary") + add.add_argument( + "--region", + metavar="REGION_OR_ALIAS", + help="Saved target region, ahead of profile/account/global defaults.", + ) + add.add_argument( + "--allow-unknown-region", + action="store_true", + help="Accept a canonical-shaped region absent from bundled metadata.", + ) update.add_argument("--boundary") update.add_argument("--clear-boundary", action="store_true") + target_region = update.add_mutually_exclusive_group() + target_region.add_argument( + "--region", + metavar="REGION_OR_ALIAS", + help="Replace this target's saved region.", + ) + target_region.add_argument( + "--clear-region", + action="store_true", + help="Remove this target's saved region.", + ) + update.add_argument( + "--allow-unknown-region", + action="store_true", + help="Accept a canonical-shaped region absent from bundled metadata.", + ) update.add_argument("--source-account") update.add_argument("--source-profile") update_source = update.add_mutually_exclusive_group() @@ -608,10 +670,135 @@ def _create_parser() -> argparse.ArgumentParser: help="Include full AWS-directory paths in the human table.", ) profile_list.add_argument("--json", action="store_true") + profile_region = profile_actions.add_parser( + "region", help="Inspect or change the region stored on one AWS profile." + ) + profile_region_actions = profile_region.add_subparsers(dest="profile_region_action") + profile_region_get = profile_region_actions.add_parser( + "get", help="Show the profile's currently stored region." + ) + _credential_selector(profile_region_get) + profile_region_get.add_argument( + "--json", action="store_true", help="Emit stable JSON." + ) + profile_region_set = profile_region_actions.add_parser( + "set", help="Store a canonical region on the profile." + ) + profile_region_set.add_argument( + "region", + metavar="REGION_OR_ALIAS", + help="Canonical region or compact, geography, or custom alias.", + ) + profile_region_set.add_argument( + "--allow-unknown-region", + action="store_true", + help="Accept a canonical-shaped region absent from bundled metadata.", + ) + _credential_selector(profile_region_set) + profile_region_set.add_argument( + "--json", action="store_true", help="Emit stable JSON." + ) + profile_region_clear = profile_region_actions.add_parser( + "clear", help="Remove the region stored on the profile." + ) + _credential_selector(profile_region_clear) + profile_region_clear.add_argument( + "--json", action="store_true", help="Emit stable JSON." + ) for kind in ("account", "boundary", "target"): _resource_parser(types, kind) + region = types.add_parser( + "region", + help="Discover canonical AWS regions and manage input-only aliases.", + description=( + "Resolve canonical regions, compact aliases such as usw2, geography " + "aliases such as oregon, and portable custom aliases. Hacksaws always " + "stores the canonical AWS region." + ), + epilog=( + "Examples:\n" + " hacksaws region list '*west*'\n" + " hacksaws region explain oregon\n" + " hacksaws region alias add pacific us-west-2\n" + " hacksaws region list --account production\n\n" + "Operational AWS, China, and GovCloud regions are shown by default. " + "Use --all-partitions for discovery only." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + region_actions = region.add_subparsers(dest="region_action") + for action in ("list", "explain"): + item = region_actions.add_parser(action) + if action == "list": + item.add_argument( + "patterns", + nargs="*", + help="Case-insensitive fnmatch patterns ORed across names and aliases.", + ) + else: + item.add_argument( + "region", + metavar="REGION_OR_ALIAS", + help="Canonical region or compact, geography, or custom alias.", + ) + item.add_argument( + "--allow-unknown-region", + action="store_true", + help="Accept a canonical-shaped region absent from bundled metadata.", + ) + scope = item.add_mutually_exclusive_group() + scope.add_argument("--partition", help="Limit discovery to one AWS partition.") + scope.add_argument( + "--account", help="Use a configured account's partition as the scope." + ) + item.add_argument( + "--all-partitions", + action="store_true", + help="Include discovery-only partitions Hacksaws cannot operate in.", + ) + item.add_argument("--json", action="store_true", help="Emit stable JSON.") + alias = region_actions.add_parser( + "alias", help="Manage portable global custom region aliases." + ) + alias_actions = alias.add_subparsers(dest="region_alias_action") + alias_add = alias_actions.add_parser("add", help="Create a portable input alias.") + alias_add.add_argument("alias", help="New lower-kebab-case alias name.") + alias_add.add_argument( + "region", + metavar="REGION_OR_ALIAS", + help="Known region or built-in alias to store canonically.", + ) + alias_add.add_argument("--description", help="Optional purpose or geography note.") + alias_update = alias_actions.add_parser( + "update", help="Change an alias target or description." + ) + alias_update.add_argument("alias", help="Existing custom alias name.") + alias_update.add_argument( + "--region", + metavar="REGION_OR_ALIAS", + help="Replacement known region or built-in alias.", + ) + alias_update.add_argument("--description", help="Replacement description.") + alias_update.add_argument( + "--clear-description", action="store_true", help="Remove its description." + ) + for action in ("get", "remove"): + item = alias_actions.add_parser(action) + item.add_argument("alias", help="Existing custom alias name.") + item.add_argument("--json", action="store_true", help="Emit stable JSON.") + alias_list = alias_actions.add_parser("list", help="List custom aliases.") + alias_list.add_argument( + "patterns", + nargs="*", + help="Case-insensitive fnmatch patterns ORed across alias fields.", + ) + alias_list.add_argument("--json", action="store_true", help="Emit stable JSON.") + alias_rename = alias_actions.add_parser("rename", help="Rename an input alias.") + alias_rename.add_argument("alias", help="Existing custom alias name.") + alias_rename.add_argument("new_alias", help="New lower-kebab-case alias name.") + policy = types.add_parser( "policy", help="Manage reusable policy documents stored on this computer." ) @@ -724,6 +911,7 @@ def _create_parser() -> argparse.ArgumentParser: direct_set.add_argument("key") direct_set.add_argument("value") direct_set.add_argument("--json", action="store_true") + direct_set.add_argument("--allow-unknown-region", action="store_true") option = config_actions.add_parser("option", aliases=["opt"]) option_actions = option.add_subparsers(dest="option_action") option_actions.add_parser("list", aliases=["ls"]).add_argument( @@ -737,6 +925,7 @@ def _create_parser() -> argparse.ArgumentParser: option_set.add_argument("key") option_set.add_argument("value") option_set.add_argument("--json", action="store_true") + option_set.add_argument("--allow-unknown-region", action="store_true") history = types.add_parser( "history", @@ -1235,7 +1424,7 @@ def _run_assume(context: _configs.Context) -> _configs.Result: def _run_mfa(context: _configs.Context) -> _configs.Result: - """Execute MFA while preserving the legacy direct-profile behavior.""" + """Execute MFA through the transactional session lifecycle.""" action = cast("str | None", context.args.action) if not action: _print_help(("mfa",)) @@ -1274,19 +1463,7 @@ def _run_mfa(context: _configs.Context) -> _configs.Result: if not context.args.mfa_code: raise _configs.OperationalError("MFA token code cannot be empty.") _history.note_mfa_code(source=context.args.mfa_code_source) - if _sessions.is_expanded_login(context.args): - return _sessions.mfa_login(context) - - os.environ["AWS_SHARED_CREDENTIALS_FILE"] = str(context.credentials_path) - os.environ["AWS_CONFIG_FILE"] = str(context.config_path) - _aws.logout(context) - aws_account = _configs.AwsAccount.from_context(context) - if cast("bool", context.args.ecr): - _ecr.logout(context, aws_account, check=False) - _aws.login(context) - if cast("bool", context.args.ecr): - _ecr.login(context, aws_account) - return _configs.Result("MFA_LOGIN", f"Logged into profile {context.profile}") + return _sessions.mfa_login(context) def _run_logout(context: _configs.Context) -> _configs.Result: @@ -1625,7 +1802,7 @@ def _status_text(report: dict[str, Any]) -> str: states = [_status_state(item) for item in sessions] auth = [_status_auth(item) for item in sessions] - columns = ["PROFILE", "STATE", "AUTH", "ACCOUNT", "SCOPE"] + columns = ["PROFILE", "REGION", "STATE", "AUTH", "ACCOUNT", "SCOPE"] if show_location: columns.insert(0, "LOCATION") if show_ttl: @@ -1637,6 +1814,7 @@ def _status_text(report: dict[str, Any]) -> str: for index, item in enumerate(sessions): row = [ str(item.get("profile") or "default"), + str(item.get("profile_region") or item.get("region") or ""), states[index][0], auth[index][0], str(item.get("target_account") or item.get("source_account") or ""), @@ -1654,7 +1832,7 @@ def _status_text(report: dict[str, Any]) -> str: def _profile_list_text(report: dict[str, Any], *, wide: bool = False) -> str: - columns = ["LOCATION", "PROFILE", "STATE", "AUTH", "VERIFY"] + columns = ["LOCATION", "PROFILE", "REGION", "STATE", "AUTH", "VERIFY"] if wide: columns.append("DIRECTORY") return _text_table( @@ -1663,6 +1841,7 @@ def _profile_list_text(report: dict[str, Any], *, wide: bool = False) -> str: [ item.get("location"), item["profile"], + item.get("region"), item["state"], item.get("auth_method"), (item.get("verification") or {}).get("status"), @@ -1708,12 +1887,13 @@ def _config_text(data: dict[str, Any], *, account: str | None = None) -> str: sections = [ "Accounts\n" + _text_table( - ["NAME", "ID", "PARTITION", "VERIFIED", "DESCRIPTION"], + ["NAME", "ID", "PARTITION", "REGION", "VERIFIED", "DESCRIPTION"], [ [ name, value.get("id"), value.get("partition"), + value.get("region"), "no" if value.get("unverified") else "yes", value.get("description"), ] @@ -1736,11 +1916,12 @@ def _config_text(data: dict[str, Any], *, account: str | None = None) -> str: ), "Targets\n" + _text_table( - ["NAME", "ACCOUNT", "SOURCE", "DESTINATION", "BOUNDARY"], + ["NAME", "ACCOUNT", "REGION", "SOURCE", "DESTINATION", "BOUNDARY"], [ [ name, value.get("source_account"), + value.get("region"), ( f"{value.get('source_location', value.get('source_directory', 'default'))}:" f"{value.get('source_profile', 'default')}" @@ -1769,6 +1950,11 @@ def _config_text(data: dict[str, Any], *, account: str | None = None) -> str: + _text_table( ["AREA", "VALUE"], [ + ["aws.region", data.get("aws", {}).get("region")], + [ + "aws.region-aliases", + len(data.get("aws", {}).get("region_aliases", {})), + ], ["cache.max-age", data.get("cache", {}).get("max_age")], ["output.color", data.get("output", {}).get("color")], ], @@ -1987,6 +2173,13 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: "partition": partition, **({"unverified": True} if unverified else {}), } + if args.region: + value["region"] = _regions.resolve_region( + args.region, + custom_aliases=data["aws"]["region_aliases"], + partition=partition, + allow_unknown=args.allow_unknown_region, + ).canonical elif kind == "boundary": _, account = _state.get_resource(data, "account", args.account) role = args.role @@ -2060,6 +2253,16 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: ) if args.boundary: value["boundary"] = args.boundary + if args.region: + _, source_account = _state.get_resource( + data, "account", args.source_account + ) + value["region"] = _regions.resolve_region( + args.region, + custom_aliases=data["aws"]["region_aliases"], + partition=source_account["partition"], + allow_unknown=args.allow_unknown_region, + ).canonical if args.description: value["description"] = args.description _state.add_resource(data, kind, args.resource_name, value) @@ -2070,6 +2273,17 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: if args.clear_description: _, item = _state.get_resource(data, kind, args.resource_name) item.pop("description", None) + if kind == "account": + _, item = _state.get_resource(data, kind, args.resource_name) + if args.region: + patch["region"] = _regions.resolve_region( + args.region, + custom_aliases=data["aws"]["region_aliases"], + partition=item["partition"], + allow_unknown=args.allow_unknown_region, + ).canonical + if args.clear_region: + item.pop("region", None) if kind == "boundary": _, existing_boundary = _state.get_resource(data, kind, args.resource_name) selected_account = args.account or existing_boundary["account"] @@ -2140,6 +2354,19 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: "destination_profile", ): item.pop(field, None) + if args.region: + source_account_name = args.source_account or item["source_account"] + _, source_account = _state.get_resource( + data, "account", source_account_name + ) + patch["region"] = _regions.resolve_region( + args.region, + custom_aliases=data["aws"]["region_aliases"], + partition=source_account["partition"], + allow_unknown=args.allow_unknown_region, + ).canonical + if args.clear_region: + item.pop("region", None) if args.to and (args.to_directory or args.to_profile): raise _configs.OperationalError( "--to is mutually exclusive with --to-directory/--to-profile." @@ -2338,6 +2565,44 @@ def _run_cache(args: argparse.Namespace) -> _configs.Result: def _run_profile(args: argparse.Namespace) -> _configs.Result: + if args.profile_action == "region": + action = args.profile_region_action + if action == "get": + value = _sessions.profile_region_get(args) + message = ( + json.dumps(value, indent=2) + if args.json + else ( + f"{value['location']}:{value['profile']} uses {value['region']}." + if value["region"] + else f"{value['location']}:{value['profile']} has no stored region." + ) + ) + return _configs.Result( + "PROFILE_REGION_GET", message, data=value, kind="info" + ) + if action in {"set", "clear"}: + value = _sessions.profile_region_change(args, clear=action == "clear") + if args.json: + message = json.dumps(value, indent=2) + elif value["changed"]: + verb = "Cleared" if action == "clear" else f"Set {value['region']} on" + message = f"{verb} {value['location']}:{value['profile']}." + if value["warnings"]: + message += "\nWarning: " + " ".join(value["warnings"]) + else: + message = f"{value['location']}:{value['profile']} already " + ( + "has no stored region." + if value["region"] is None + else f"uses {value['region']}." + ) + return _configs.Result( + f"PROFILE_REGION_{action.upper()}", message, data=value + ) + _print_help(("profile", "region")) + return _configs.Result( + "PROFILE_REGION_HELP", "Choose a profile region action.", 2, "stderr" + ) if args.profile_action != "list": _print_help(("profile",)) return _configs.Result("PROFILE_HELP", "Choose a profile action.", 2, "stderr") @@ -2350,6 +2615,306 @@ def _run_profile(args: argparse.Namespace) -> _configs.Result: ) +def _region_scope( + data: dict[str, Any], args: argparse.Namespace +) -> tuple[str | None, str | None]: + """Resolve optional partition/account filters for region discovery.""" + if getattr(args, "account", None): + account_name, account = _state.get_resource(data, "account", args.account) + return str(account["partition"]), account_name + partition = getattr(args, "partition", None) + known_partitions = { + item.partition for item in _regions.region_registry(all_partitions=True) + } + if partition and partition not in known_partitions: + raise _regions.RegionError( + "REGION_PARTITION_UNKNOWN", + f"Unknown AWS partition {partition!r}.", + candidates=sorted(known_partitions), + ) + return partition, None + + +def _region_record( + info: _regions.RegionInfo, + custom: dict[str, tuple[str, str | None]], +) -> dict[str, Any]: + return { + "region": info.name, + "name": info.description, + "partition": info.partition, + "operational": info.operational, + "compact": info.compact_alias, + "geography": list(info.geography_aliases), + "custom": sorted( + alias for alias, (region, _) in custom.items() if region == info.name + ), + } + + +def _region_list_text(values: list[dict[str, Any]]) -> str: + return _text_table( + ["REGION", "NAME", "PARTITION", "COMPACT", "GEOGRAPHY", "CUSTOM"], + [ + [ + item["region"], + item["name"], + item["partition"], + item["compact"], + ", ".join(item["geography"]), + ", ".join(item["custom"]), + ] + for item in values + ], + ) + + +def _alias_items(data: dict[str, Any]) -> dict[str, dict[str, str]]: + return cast("dict[str, dict[str, str]]", data["aws"]["region_aliases"]) + + +def _alias_key(aliases: dict[str, Any], name: str) -> str: + normalized = _regions.normalize_alias(name) + match = next((key for key in aliases if key.casefold() == normalized), None) + if match is None: + raise _regions.RegionError( + "REGION_ALIAS_NOT_FOUND", f"Unknown custom region alias {name!r}." + ) + return match + + +def _alias_list_text(values: list[dict[str, Any]]) -> str: + return _text_table( + ["ALIAS", "REGION", "NAME", "DESCRIPTION"], + [ + [item["alias"], item["region"], item["name"], item.get("description")] + for item in values + ], + ) + + +def _run_region_alias( + args: argparse.Namespace, data: dict[str, Any] +) -> _configs.Result: + """Run custom alias CRUD while persisting canonical targets only.""" + action = args.region_alias_action + if not action: + _print_help(("region", "alias")) + return _configs.Result( + "REGION_ALIAS_HELP", "Choose a region alias action.", 2, "stderr" + ) + aliases = _alias_items(data) + custom = data["aws"]["region_aliases"] + if action == "list": + values = [] + for alias, item in sorted(aliases.items()): + resolution = _regions.resolve_region(item["region"]) + record = { + "alias": alias, + "region": resolution.canonical, + "name": resolution.description, + **( + {"description": item["description"]} + if item.get("description") + else {} + ), + } + if not args.patterns or any( + fnmatch.fnmatchcase(candidate.casefold(), pattern.casefold()) + for pattern in args.patterns + for candidate in ( + alias, + resolution.canonical, + resolution.description, + item.get("description", ""), + ) + ): + values.append(record) + return _configs.Result( + "REGION_ALIAS_LIST", + json.dumps(values, indent=2) if args.json else _alias_list_text(values), + data=values, + ) + if action == "get": + key = _alias_key(aliases, args.alias) + item = aliases[key] + resolution = _regions.resolve_region(item["region"]) + value = { + "alias": key, + "region": resolution.canonical, + "name": resolution.description, + **({"description": item["description"]} if item.get("description") else {}), + } + return _configs.Result( + "REGION_ALIAS_GET", + json.dumps(value, indent=2) if args.json else _alias_list_text([value]), + data=value, + ) + if action == "remove": + key = _alias_key(aliases, args.alias) + del aliases[key] + _state.save_config(data) + return _configs.Result( + "REGION_ALIAS_REMOVE", + f"Removed region alias {key}; canonical stored regions are unchanged.", + data={"alias": key, "consumersChanged": False}, + ) + if action == "rename": + key = _alias_key(aliases, args.alias) + new_name = _regions.normalize_alias(args.new_alias) + if new_name != args.new_alias: + raise _regions.RegionError( + "REGION_ALIAS_INVALID", + f"Custom aliases use lower kebab case; try {new_name!r}.", + ) + if any(existing.casefold() == new_name for existing in aliases): + raise _regions.RegionError( + "REGION_ALIAS_CONFLICT", f"Region alias {new_name!r} already exists." + ) + aliases[new_name] = aliases.pop(key) + _state.save_config(data) + return _configs.Result( + "REGION_ALIAS_RENAME", + f"Renamed region alias {key} to {new_name}; canonical consumers are unchanged.", + ) + name = _regions.normalize_alias(args.alias) + if name != args.alias: + raise _regions.RegionError( + "REGION_ALIAS_INVALID", + f"Custom aliases use lower kebab case; try {name!r}.", + ) + if action == "add" and any(key.casefold() == name for key in aliases): + raise _regions.RegionError( + "REGION_ALIAS_CONFLICT", f"Region alias {name!r} already exists." + ) + key = name if action == "add" else _alias_key(aliases, name) + existing = aliases.get(key, {}) + region_input = args.region if action == "add" else args.region or existing["region"] + resolution = _regions.resolve_region(region_input, custom_aliases=custom) + value = {"region": resolution.canonical} + description = getattr(args, "description", None) + if description is not None: + value["description"] = description + elif existing.get("description") and not getattr(args, "clear_description", False): + value["description"] = existing["description"] + aliases[key] = value + _state.save_config(data) + return _configs.Result( + "REGION_ALIAS_SAVED", + f"Saved region alias {key} as {resolution.canonical} ({resolution.description}).", + data={"alias": key, **value, "name": resolution.description}, + ) + + +def _run_region(args: argparse.Namespace) -> _configs.Result: + """Discover canonical regions or manage global custom aliases.""" + data = _state.load_config() + if args.region_action == "alias": + return _run_region_alias(args, data) + if args.region_action not in {"list", "explain"}: + _print_help(("region",)) + return _configs.Result("REGION_HELP", "Choose a region action.", 2, "stderr") + partition, account_name = _region_scope(data, args) + aliases = data["aws"]["region_aliases"] + if args.region_action == "explain": + resolution = _regions.resolve_region( + args.region, + custom_aliases=aliases, + partition=partition, + allow_unknown=args.allow_unknown_region, + allow_non_operational=args.all_partitions, + ) + value = { + "input": resolution.input, + "region": resolution.canonical, + "name": resolution.description, + "partition": resolution.partition, + "operational": resolution.operational, + "known": resolution.known, + "matchedBy": resolution.source, + "matchedAlias": resolution.matched_alias, + "account": account_name, + "storedAs": resolution.canonical, + "warning": resolution.warning, + } + text = "\n".join( + f"{label}: {value[key] or '-'}" + for key, label in ( + ("input", "Input"), + ("region", "Canonical region"), + ("name", "Name"), + ("partition", "Partition"), + ("matchedBy", "Matched by"), + ("storedAs", "Configuration stores"), + ("warning", "Warning"), + ) + ) + return _configs.Result( + "REGION_EXPLAIN", + json.dumps(value, indent=2) if args.json else text, + data=value, + ) + custom = _regions.custom_alias_map(aliases) + values = [] + for info in _regions.region_registry(all_partitions=args.all_partitions): + if partition and info.partition != partition: + continue + item = _region_record(info, custom) + candidates = ( + item["region"], + item["name"], + item["partition"], + item["compact"] or "", + *item["geography"], + *item["custom"], + ) + if args.patterns and not any( + fnmatch.fnmatchcase(str(candidate).casefold(), pattern.casefold()) + for pattern in args.patterns + for candidate in candidates + ): + continue + values.append(item) + return _configs.Result( + "REGION_LIST", + json.dumps(values, indent=2) if args.json else _region_list_text(values), + data=values, + ) + + +def _canonical_config_region( + data: dict[str, Any], key: str, value: object, *, allow_unknown: bool +) -> object: + """Resolve region-bearing config values before schema persistence.""" + if type(value) is not str: + return value + parts = key.split(".") + option_shape = tuple(parts) + partition = None + if option_shape[:1] == ("accounts",) and option_shape[2:] == ("region",): + _, account = _state.get_resource(data, "account", parts[1]) + partition = account["partition"] + elif option_shape[:1] == ("targets",) and option_shape[2:] == ("region",): + _, target = _state.get_resource(data, "target", parts[1]) + _, account = _state.get_resource(data, "account", target["source_account"]) + partition = account["partition"] + is_region = key == "aws.region" or ( + option_shape[:1] in {("accounts",), ("targets",)} + and option_shape[2:] == ("region",) + ) + is_alias_region = option_shape[:2] == ("aws", "region_aliases") and option_shape[ + 3: + ] == ("region",) + if not (is_region or is_alias_region): + return value + return _regions.resolve_region( + value, + custom_aliases=data["aws"]["region_aliases"], + partition=partition, + allow_unknown=allow_unknown and not is_alias_region, + ).canonical + + def _run_config(args: argparse.Namespace) -> _configs.Result: action = args.config_action if action == "options": @@ -2395,6 +2960,12 @@ def _run_config(args: argparse.Namespace) -> _configs.Result: nested_set_value: object = json.loads(args.value) except json.JSONDecodeError: nested_set_value = args.value + nested_set_value = _canonical_config_region( + data, + args.key, + nested_set_value, + allow_unknown=args.allow_unknown_region, + ) _state.set_config_option(data, args.key, nested_set_value) _state.save_config(data) return _configs.Result( @@ -2431,6 +3002,12 @@ def _run_config(args: argparse.Namespace) -> _configs.Result: direct_option_value = json.loads(args.value) except json.JSONDecodeError: direct_option_value = args.value + direct_option_value = _canonical_config_region( + data, + args.key, + direct_option_value, + allow_unknown=args.allow_unknown_region, + ) _state.set_config_option(data, args.key, direct_option_value) _state.save_config(data) return _configs.Result( @@ -2936,6 +3513,8 @@ def _console_main_invocation( result = _run_profile(namespace) elif namespace.access_type in {"account", "boundary", "target"}: result = _run_resource(namespace) + elif namespace.access_type == "region": + result = _run_region(namespace) elif namespace.access_type == "policy": result = _run_policy(namespace) elif namespace.access_type == "cache": @@ -2946,7 +3525,7 @@ def _console_main_invocation( result = _run_config(namespace) except _configs.OperationalError as error: result = _configs.Result( - "OPERATIONAL_ERROR", + getattr(error, "code", "OPERATIONAL_ERROR"), f"Error: {error}", 1, "stderr", diff --git a/hacksaws/_ecr.py b/hacksaws/_ecr.py index 8f5d5b4..af4f627 100644 --- a/hacksaws/_ecr.py +++ b/hacksaws/_ecr.py @@ -5,6 +5,7 @@ import base64 import binascii import subprocess +import sys from datetime import UTC from datetime import datetime from typing import TYPE_CHECKING @@ -16,11 +17,59 @@ from botocore.exceptions import ClientError from hacksaws import _configs +from hacksaws import _regions +from hacksaws import _state if TYPE_CHECKING: from collections.abc import Callable +def _configured_region_aliases() -> dict[str, object]: + """Return user region aliases without exposing unrelated configuration.""" + data = _state.load_config() + aws = data.get("aws") + if not isinstance(aws, dict): + return {} + aliases = aws.get("region_aliases") + return cast("dict[str, object]", aliases) if isinstance(aliases, dict) else {} + + +def _ecr_regions( + context: _configs.Context, + aws_account: _configs.AwsAccount, +) -> tuple[str, ...]: + """Canonicalize, partition-check, service-check, and dedupe ECR regions.""" + args = getattr(context, "args", None) + allow_unknown = getattr(args, "allow_unknown_region", False) is True + preference = getattr(args, "_region_preference", None) + preferred = ( + preference.canonical + if isinstance(preference, _regions.RegionPreference) + else None + ) + effective = getattr(args, "_effective_region", None) + explicit = getattr(args, "region", None) + primary = next( + ( + value + for value in (preferred, effective, explicit, aws_account.region_name) + if isinstance(value, str) and value + ), + aws_account.region_name, + ) + resolutions = _regions.canonicalize_regions( + (primary, *aws_account.ecr_additional_regions), + custom_aliases=_configured_region_aliases(), + partition=aws_account.partition, + allow_unknown=allow_unknown, + service="ecr", + ) + for resolution in resolutions: + if resolution.warning: + print(f"Warning: {resolution.warning}", file=sys.stderr) # noqa: T201 + return tuple(item.canonical for item in resolutions) + + def _run_container_engine( engine: _configs.ContainerEngine, command: list[str], @@ -118,7 +167,7 @@ def login(context: _configs.Context, aws_account: _configs.AwsAccount) -> list[s region_name=region_name, dns_suffix=aws_account.dns_suffix, ) - for region_name in aws_account.ecr_regions + for region_name in _ecr_regions(context, aws_account) ] @@ -131,7 +180,7 @@ def login_with_session( ) -> list[str]: """Install ECR tokens using broad intermediate credentials.""" completed: list[str] = [] - for region_name in aws_account.ecr_regions: + for region_name in _ecr_regions(context, aws_account): registry = _do_login( context, account_id=aws_account.id, @@ -153,7 +202,8 @@ def logout( ) -> None: """Log the selected container engine out of every configured ECR registry.""" engine = context.container_engine - for registry in aws_account.ecr_registries: + for region_name in _ecr_regions(context, aws_account): + registry = f"{aws_account.id}.dkr.ecr.{region_name}.{aws_account.dns_suffix}" _run_container_engine( engine, [engine, "logout", registry], diff --git a/hacksaws/_history.py b/hacksaws/_history.py index 4dfd10f..ecdaf7b 100644 --- a/hacksaws/_history.py +++ b/hacksaws/_history.py @@ -117,21 +117,25 @@ def _connect() -> sqlite3.Connection: _secure(directory) path = database_path() connection = sqlite3.connect(path, timeout=5.0) - connection.row_factory = sqlite3.Row - connection.execute("PRAGMA busy_timeout = 5000") - connection.execute("PRAGMA foreign_keys = ON") - connection.execute("PRAGMA synchronous = NORMAL") - with _initialization_lock: - if path not in _initialized_databases: - try: + try: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout = 5000") + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA synchronous = NORMAL") + with _initialization_lock: + if path not in _initialized_databases: connection.execute("PRAGMA journal_mode = WAL") _migrate(connection) - except (OSError, sqlite3.Error, HistoryError): - connection.close() - raise - _initialized_databases.add(path) - _secure(path) - return connection + _initialized_databases.add(path) + except BaseException: + # sqlite3.connect() can succeed before a corrupt/locked database makes + # an initialization PRAGMA fail. Close that partially initialized + # handle immediately; Python 3.13+ warns when it is left to the GC. + connection.close() + raise + else: + _secure(path) + return connection @contextlib.contextmanager @@ -235,6 +239,7 @@ def _canonical_command(args: argparse.Namespace) -> tuple[str, str | None]: "config_action", "option_action", "profile_action", + "profile_region_action", "history_action", ): value = getattr(args, field, None) @@ -457,6 +462,37 @@ def note_mfa_code(*, source: str) -> None: return +def note_region(*, region: str, partition: str, source: str) -> None: + """Attach the resolved, secret-free region provenance to this invocation.""" + identifier = _current.get() + if identifier is None: + return + try: + with _database() as connection: + row = connection.execute( + "SELECT safe_json FROM invocations WHERE id = ?", (identifier,) + ).fetchone() + if row is None: + return + with contextlib.suppress(json.JSONDecodeError): + safe = json.loads(row[0]) + if isinstance(safe, dict): + safe["resolvedRegion"] = region + safe["regionPartition"] = partition + safe["regionSource"] = source + connection.execute( + "UPDATE invocations SET safe_json = ?, updated_at = ? " + "WHERE id = ?", + ( + json.dumps(safe, sort_keys=True, separators=(",", ":")), + _now(), + identifier, + ), + ) + except (OSError, sqlite3.Error, HistoryError): + return + + def _outcome(exit_code: int) -> str: return { 0: "success", diff --git a/hacksaws/_iam_cli.py b/hacksaws/_iam_cli.py index c216eea..b10f9ef 100644 --- a/hacksaws/_iam_cli.py +++ b/hacksaws/_iam_cli.py @@ -24,6 +24,7 @@ from hacksaws import _iam_recovery from hacksaws import _iam_role_cli from hacksaws import _output +from hacksaws import _regions from hacksaws import _state if TYPE_CHECKING: @@ -141,6 +142,15 @@ def add_selector_arguments( metavar="REGION", help="AWS region used for regional clients and console links.", ) + credentials.add_argument( + "--allow-unknown-region", + action="store_true", + default=False if root else argparse.SUPPRESS, + help=( + "Allow an exact canonical region absent from bundled Botocore metadata; " + "aliases never bypass validation." + ), + ) if mutation: safety = parser.add_argument_group("safety") safety.add_argument( @@ -172,6 +182,7 @@ def _selector_arguments(parser: argparse.ArgumentParser, *, root: bool = False) "target": ("--target",), "account": ("--account",), "region": ("--region",), + "allow_unknown_region": ("--allow-unknown-region",), "yes": ("--yes",), "dry_run": ("--dry-run",), } @@ -790,14 +801,20 @@ def _cleanup_plan_text(data: Mapping[str, object]) -> str: return "\n".join(_output.safe_terminal_text(line) for line in lines) -def _iam_console_url(partition: str) -> str: +def _iam_console_url(context: IamCommandContext) -> str: """Return the partition-appropriate IAM console home link.""" + partition = getattr(context, "partition", "aws") + region_name = getattr(context, "region_name", "us-east-1") domain = { "aws": "console.aws.amazon.com", "aws-cn": "console.amazonaws.cn", "aws-us-gov": "console.amazonaws-us-gov.com", - }.get(partition, "console.aws.amazon.com") - return f"https://{domain}/iam/home#/home" + }.get(partition) + if domain is None: + raise _configs.OperationalError( + f"AWS Console links are not supported for partition {partition!r}." + ) + return f"https://{region_name}.{domain}/iam/home?region={region_name}#/home" def cleanup_result( @@ -891,7 +908,7 @@ def cleanup_result( ) outcome = service.execute(plan) result_data = outcome.as_dict() - console_url = _iam_console_url(plan.partition) + console_url = _iam_console_url(context) result_data["consoleUrl"] = console_url applied = { "operationsPlanned": len(plan.steps), @@ -991,6 +1008,8 @@ class IamCommandContext: account_id: str partition: str arn: str + region_name: str + region_resolution: _regions.RegionResolution @classmethod def create( @@ -1002,12 +1021,74 @@ def create( """Resolve local selection, create clients, and verify caller identity only.""" selector = _configs.resolve_credential_selector(args) directory, profile, expected_account = _selected_source(selector, args) + region_settings = _iam_region_settings(selector, expected_account) + env_region = os.getenv("AWS_REGION") or "" + env_default_region = os.getenv("AWS_DEFAULT_REGION") or "" with credential_environment(directory / "config", directory / "credentials"): try: - selected_session = session_factory( - profile_name=profile, region_name=args.region + higher_precedence_region = any( + ( + getattr(args, "region", None), + env_region, + env_default_region, + region_settings["target"], + ) + ) + profile_session = ( + None + if higher_precedence_region + else session_factory(profile_name=profile) + ) + profile_credentials = ( + profile_session.get_credentials() + if profile_session is not None + else None + ) + if profile_session is not None and profile_credentials is None: + raise _configs.OperationalError( + f"Selected AWS profile {profile!r} has no credentials." + ) + preference = _regions.resolve_region_preference( + explicit=getattr(args, "region", None), + env_region=env_region, + env_default_region=env_default_region, + target=region_settings["target"], + source=( + profile_session.region_name + if profile_session is not None + else None + ), + account=region_settings["account"], + global_region=region_settings["global"], + custom_aliases=region_settings["aliases"], + partition=( + str(expected_account["partition"]) + if expected_account is not None + else None + ), + allow_unknown=bool(getattr(args, "allow_unknown_region", False)), + interactive=False, + ) + region_resolution = _regions.validate_service_region( + preference.resolution, + "sts", + allow_unknown=bool(getattr(args, "allow_unknown_region", False)), + ) + region_name = region_resolution.canonical + selected_session = ( + profile_session + if profile_session is not None + and profile_session.region_name == region_name + else session_factory( + profile_name=profile, + region_name=region_name, + ) + ) + credentials = ( + profile_credentials + if selected_session is profile_session + else selected_session.get_credentials() ) - credentials = selected_session.get_credentials() if credentials is None: raise _configs.OperationalError( f"Selected AWS profile {profile!r} has no credentials." @@ -1017,7 +1098,7 @@ def create( aws_access_key_id=frozen.access_key, aws_secret_access_key=frozen.secret_key, aws_session_token=frozen.token, - region_name=args.region or selected_session.region_name, + region_name=region_name, ) sts = session.client("sts") iam = session.client("iam") @@ -1034,6 +1115,16 @@ def create( "Unable to verify selected IAM credentials with " f"GetCallerIdentity: {error}" ) from error + if partition not in _regions.OPERATIONAL_PARTITIONS: + raise _configs.OperationalError( + f"Authenticated caller partition {partition!r} is not supported." + ) + if partition != region_resolution.partition: + raise _configs.OperationalError( + "Resolved AWS region partition " + f"{region_resolution.partition!r} does not match authenticated " + f"caller partition {partition!r}." + ) if expected_account and ( account_id != expected_account["id"] or partition != expected_account["partition"] @@ -1059,6 +1150,8 @@ def create( account_id=account_id, partition=partition, arn=arn, + region_name=region_name, + region_resolution=region_resolution, ) @@ -1086,6 +1179,33 @@ def _selected_source( return directory.expanduser().absolute(), profile, expected +def _iam_region_settings( + selector: _configs.CredentialSelector, + expected_account: dict[str, Any] | None, +) -> dict[str, Any]: + """Return configured IAM-region preference layers without AWS side effects.""" + data = _state.load_config() + aws = data.get("aws") + aws_settings = aws if isinstance(aws, dict) else {} + target_region: str | None = None + if selector.target: + _, target = _state.get_resource(data, "target", selector.target.lstrip("+")) + value = target.get("region") + target_region = value if isinstance(value, str) else None + account_region: str | None = None + if expected_account is not None: + value = expected_account.get("region") + account_region = value if isinstance(value, str) else None + global_value = aws_settings.get("region") + aliases = aws_settings.get("region_aliases") + return { + "target": target_region, + "account": account_region, + "global": global_value if isinstance(global_value, str) else None, + "aliases": aliases if isinstance(aliases, dict) else {}, + } + + def recovery_result(args: argparse.Namespace) -> _configs.Result: """Inspect or execute an IAM-only durable recovery journal.""" action = args.recovery_action diff --git a/hacksaws/_iam_policy_cli.py b/hacksaws/_iam_policy_cli.py index 27ff236..40172da 100644 --- a/hacksaws/_iam_policy_cli.py +++ b/hacksaws/_iam_policy_cli.py @@ -91,11 +91,21 @@ def _console_url(context: IamCommandContext, arn: str) -> str: - region = ( - getattr(getattr(context, "session", None), "region_name", None) or "us-east-1" - ) + region = getattr(context, "region_name", None) or getattr( + getattr(context, "session", None), "region_name", None + ) + region = region or "us-east-1" + domain = { + "aws": "console.aws.amazon.com", + "aws-cn": "console.amazonaws.cn", + "aws-us-gov": "console.amazonaws-us-gov.com", + }.get(context.partition) + if domain is None: + raise OperationalError( + f"AWS Console links are not supported for partition {context.partition!r}." + ) return ( - f"https://{region}.console.aws.amazon.com/iam/home?region={region}" + f"https://{region}.{domain}/iam/home?region={region}" f"#/policies/details/{quote(arn, safe='')}?section=permissions" ) @@ -140,6 +150,12 @@ def _selectors(parser: argparse.ArgumentParser, *, mutation: bool = False) -> No metavar="REGION", help="Region used for AWS clients and console links.", ) + group.add_argument( + "--allow-unknown-region", + action="store_true", + default=argparse.SUPPRESS, + help="Allow only an exact unknown canonical region; aliases stay strict.", + ) if mutation: safety = parser.add_argument_group("safety") safety.add_argument( diff --git a/hacksaws/_iam_role_cli.py b/hacksaws/_iam_role_cli.py index 7969850..0679a68 100644 --- a/hacksaws/_iam_role_cli.py +++ b/hacksaws/_iam_role_cli.py @@ -57,11 +57,21 @@ def _console_url(context: IamCommandContext, role_name: str) -> str: - region = ( - getattr(getattr(context, "session", None), "region_name", None) or "us-east-1" - ) + region = getattr(context, "region_name", None) or getattr( + getattr(context, "session", None), "region_name", None + ) + region = region or "us-east-1" + domain = { + "aws": "console.aws.amazon.com", + "aws-cn": "console.amazonaws.cn", + "aws-us-gov": "console.amazonaws-us-gov.com", + }.get(context.partition) + if domain is None: + raise OperationalError( + f"AWS Console links are not supported for partition {context.partition!r}." + ) return ( - f"https://{region}.console.aws.amazon.com/iam/home?region={region}" + f"https://{region}.{domain}/iam/home?region={region}" f"#/roles/details/{quote(role_name, safe='')}" ) @@ -119,6 +129,12 @@ def _add_selector_arguments( metavar="REGION", help="Region used for AWS clients and console links.", ) + group.add_argument( + "--allow-unknown-region", + action="store_true", + default=argparse.SUPPRESS, + help="Allow only an exact unknown canonical region; aliases stay strict.", + ) if mutation: safety = parser.add_argument_group("safety") safety.add_argument( @@ -1652,7 +1668,21 @@ def _looks_like_file(value: str) -> bool: def _resolve_policy_arn(reference: str, context: IamCommandContext) -> str: if reference.startswith("arn:"): - return reference + try: + parsed = managed.ManagedPolicyArn.parse(reference) + except managed.PolicyServiceError as error: + raise OperationalError("Invalid IAM managed-policy ARN.") from error + if parsed.partition != context.partition: + raise OperationalError( + "Managed-policy ARN partition does not match the authenticated " + "caller partition." + ) + if parsed.account_id not in {managed.AWS_ACCOUNT, context.account_id}: + raise OperationalError( + "Managed-policy ARN does not belong to AWS or the authenticated " + "caller account." + ) + return parsed.value matches: list[str] = [] paginator = context.iam.get_paginator("list_policies") for scope in ("Local", "AWS"): diff --git a/hacksaws/_regions.py b/hacksaws/_regions.py new file mode 100644 index 0000000..63792a8 --- /dev/null +++ b/hacksaws/_regions.py @@ -0,0 +1,661 @@ +"""Canonical AWS region discovery, aliases, validation, and precedence.""" + +from __future__ import annotations + +import difflib +import os +import re +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from functools import cache +from typing import TYPE_CHECKING +from typing import Literal + +import botocore.session +from botocore.exceptions import UnknownRegionError + +from hacksaws._configs import OperationalError + +if TYPE_CHECKING: + from collections.abc import Callable + from collections.abc import Iterable + +OPERATIONAL_PARTITIONS = frozenset({"aws", "aws-cn", "aws-us-gov"}) +REGION_RE = re.compile(r"^[a-z]{2}(?:-[a-z0-9]+)+-\d+$") +ALIAS_SEPARATOR_RE = re.compile(r"[\s._-]+") +MAX_PROMPT_ATTEMPTS = 5 + +ResolutionSource = Literal["canonical", "compact", "geography", "custom", "unknown"] +PreferenceSource = Literal[ + "explicit", + "aws-region-env", + "aws-default-region-env", + "target", + "destination-profile", + "source-profile", + "account", + "global", + "prompt", +] + +# Friendly names are intentionally curated. Stored configuration always uses the +# canonical region, so additions here expand input vocabulary without migration. +CURATED_GEOGRAPHY_ALIASES: dict[str, tuple[str, ...]] = { + "af-south-1": ("cape-town",), + "ap-east-1": ("hong-kong",), + "ap-east-2": ("taipei",), + "ap-northeast-1": ("tokyo",), + "ap-northeast-2": ("seoul",), + "ap-northeast-3": ("osaka",), + "ap-south-1": ("mumbai",), + "ap-south-2": ("hyderabad",), + "ap-southeast-1": ("singapore",), + "ap-southeast-2": ("sydney",), + "ap-southeast-3": ("jakarta",), + "ap-southeast-4": ("melbourne",), + "ap-southeast-5": ("malaysia",), + "ap-southeast-6": ("new-zealand",), + "ap-southeast-7": ("thailand",), + "ca-central-1": ("canada-central",), + "ca-west-1": ("calgary", "canada-west"), + "cn-north-1": ("beijing",), + "cn-northwest-1": ("ningxia",), + "eu-central-1": ("frankfurt",), + "eu-central-2": ("zurich",), + "eu-north-1": ("stockholm",), + "eu-south-1": ("milan",), + "eu-south-2": ("spain",), + "eu-west-1": ("ireland",), + "eu-west-2": ("london",), + "eu-west-3": ("paris",), + "il-central-1": ("tel-aviv",), + "me-central-1": ("uae",), + "me-south-1": ("bahrain",), + "mx-central-1": ("mexico-central",), + "sa-east-1": ("sao-paulo",), + "us-east-1": ("n-virginia", "north-virginia", "virginia"), + "us-east-2": ("ohio",), + "us-gov-east-1": ("govcloud-east",), + "us-gov-west-1": ("govcloud-west",), + "us-west-1": ("n-california", "north-california"), + "us-west-2": ("oregon",), +} + +_DIRECTION_ABBREVIATIONS = { + "north": "n", + "south": "s", + "east": "e", + "west": "w", + "central": "c", + "northeast": "ne", + "northwest": "nw", + "southeast": "se", + "southwest": "sw", +} + + +class RegionError(OperationalError): + """Structured region failure suitable for human and JSON repairs.""" + + def __init__( + self, + code: str, + message: str, + *, + candidates: Iterable[str] = (), + repairs: Iterable[str] = (), + ) -> None: + self.code = code + selected = tuple(candidates) + super().__init__( + message, + data={"code": code, "candidates": list(selected)}, + details=list(selected), + repairs=list(repairs), + ) + + +@dataclass(frozen=True) +class RegionInfo: + """One canonical Botocore region and its stable input aliases.""" + + name: str + partition: str + description: str + compact_alias: str | None + geography_aliases: tuple[str, ...] + operational: bool + + +@dataclass(frozen=True) +class RegionResolution: + """Canonical result of resolving one region-like input.""" + + input: str + canonical: str + partition: str + description: str + source: ResolutionSource + matched_alias: str | None = None + known: bool = True + operational: bool = True + warning: str | None = None + + +@dataclass(frozen=True) +class RegionPreference: + """Effective region plus the layer that selected and may persist it.""" + + resolution: RegionResolution + source: PreferenceSource + persist_to_destination: bool + + @property + def canonical(self) -> str: + """Return the canonical effective region.""" + return self.resolution.canonical + + +def normalize_alias(value: str) -> str: + """Normalize a human alias without treating it as a canonical region.""" + return ALIAS_SEPARATOR_RE.sub("-", value.strip().casefold()).strip("-") + + +def _compact_candidate(region: str) -> str: + parts = region.split("-") + ordinal = parts[-1] + words = parts[:-1] + if not ordinal.isdigit() or not words: + return "" + result = words[0] + for word in words[1:]: + result += _DIRECTION_ABBREVIATIONS.get(word, word[:1]) + return result + ordinal + + +@cache +def region_registry(*, all_partitions: bool = False) -> tuple[RegionInfo, ...]: + """Return deterministic Botocore regions with collision-free compact aliases.""" + partitions = botocore.session.get_session().get_data("partitions")["partitions"] + raw: list[tuple[str, str, str]] = [] + for partition in partitions: + partition_name = str(partition["id"]) + if not all_partitions and partition_name not in OPERATIONAL_PARTITIONS: + continue + for region, metadata in partition.get("regions", {}).items(): + canonical = str(region).casefold() + if not REGION_RE.fullmatch(canonical): + continue + raw.append( + ( + canonical, + partition_name, + str(metadata.get("description") or canonical), + ) + ) + compact_owners: dict[str, set[str]] = {} + for canonical, _, _ in raw: + compact_owners.setdefault(_compact_candidate(canonical), set()).add(canonical) + result = [] + for canonical, partition_name, description in sorted(raw): + compact = _compact_candidate(canonical) + result.append( + RegionInfo( + name=canonical, + partition=partition_name, + description=description, + compact_alias=( + compact if compact and len(compact_owners[compact]) == 1 else None + ), + geography_aliases=tuple( + normalize_alias(value) + for value in CURATED_GEOGRAPHY_ALIASES.get(canonical, ()) + ), + operational=partition_name in OPERATIONAL_PARTITIONS, + ) + ) + return tuple(result) + + +def _registry_maps( + *, + all_partitions: bool, +) -> tuple[dict[str, RegionInfo], dict[str, list[tuple[RegionInfo, ResolutionSource]]]]: + canonical: dict[str, RegionInfo] = {} + aliases: dict[str, list[tuple[RegionInfo, ResolutionSource]]] = {} + for info in region_registry(all_partitions=all_partitions): + canonical[info.name] = info + if info.compact_alias: + aliases.setdefault(info.compact_alias, []).append((info, "compact")) + for alias in info.geography_aliases: + aliases.setdefault(alias, []).append((info, "geography")) + return canonical, aliases + + +def custom_alias_map( + aliases: Mapping[str, object] | None, +) -> dict[str, tuple[str, str | None]]: + """Normalize schema aliases into alias -> canonical/description pairs.""" + result: dict[str, tuple[str, str | None]] = {} + for name, raw in (aliases or {}).items(): + alias = normalize_alias(str(name)) + if isinstance(raw, str): + result[alias] = (raw.casefold(), None) + continue + if isinstance(raw, Mapping) and isinstance(raw.get("region"), str): + description = raw.get("description") + result[alias] = ( + str(raw["region"]).casefold(), + str(description) if isinstance(description, str) else None, + ) + return result + + +def builtin_aliases(*, all_partitions: bool = True) -> dict[str, tuple[str, ...]]: + """Return every normalized built-in alias and its canonical candidates.""" + _, aliases = _registry_maps(all_partitions=all_partitions) + return { + alias: tuple(sorted({item.name for item, _ in matches})) + for alias, matches in aliases.items() + } + + +def validate_custom_aliases(aliases: Mapping[str, object]) -> None: + """Reject malformed, chained, duplicate, or built-in-shadowing aliases.""" + canonical, builtins = _registry_maps(all_partitions=True) + seen: set[str] = set() + normalized_names = [normalize_alias(str(name)) for name in aliases] + if len(set(normalized_names)) != len(normalized_names): + raise RegionError( + "REGION_ALIAS_CONFLICT", + "Region aliases must have unique normalized names.", + ) + for original, raw in aliases.items(): + alias = normalize_alias(str(original)) + if not alias or alias != str(original): + raise RegionError( + "REGION_ALIAS_INVALID", + f"Region alias {original!r} must use normalized lower kebab " + f"case {alias!r}.", + ) + if alias in seen or alias in canonical or alias in builtins: + raise RegionError( + "REGION_ALIAS_CONFLICT", + f"Region alias {alias!r} conflicts with an existing canonical " + "or built-in alias.", + candidates=(alias,), + repairs=("Choose a distinct alias name.",), + ) + seen.add(alias) + if not isinstance(raw, Mapping) or set(raw) - {"region", "description"}: + raise RegionError( + "REGION_ALIAS_INVALID", + f"Region alias {alias!r} must contain region and optional description.", + ) + target = raw.get("region") + if ( + not isinstance(target, str) + or target != target.casefold() + or target not in canonical + ): + raise RegionError( + "REGION_ALIAS_INVALID", + f"Region alias {alias!r} must store a known canonical region, " + "not another alias.", + ) + if not canonical[target].operational: + raise RegionError( + "REGION_PARTITION_UNSUPPORTED", + f"Region alias {alias!r} targets non-operational partition " + f"{canonical[target].partition!r}.", + repairs=( + "Choose a region in the aws, aws-cn, or aws-us-gov partition.", + ), + ) + if "description" in raw and not isinstance(raw["description"], str): + raise RegionError( + "REGION_ALIAS_INVALID", + f"Region alias {alias!r} description must be text.", + ) + + +def _infer_partition(value: str) -> str: + """Infer an unknown canonical region only through Botocore partition patterns.""" + try: + return str(botocore.session.get_session().get_partition_for_region(value)) + except UnknownRegionError as error: + raise RegionError( + "REGION_PARTITION_UNKNOWN", + f"Cannot infer an AWS partition for unknown region {value!r}.", + repairs=( + ( + "Use a canonical region whose prefix belongs to an operational " + "AWS partition." + ), + ), + ) from error + + +def _resolution( + info: RegionInfo, value: str, source: ResolutionSource +) -> RegionResolution: + return RegionResolution( + input=value, + canonical=info.name, + partition=info.partition, + description=info.description, + source=source, + matched_alias=None if source == "canonical" else normalize_alias(value), + operational=info.operational, + ) + + +def resolve_region( # noqa: C901 + value: str, + *, + custom_aliases: Mapping[str, object] | None = None, + partition: str | None = None, + allow_unknown: bool = False, + allow_non_operational: bool = False, +) -> RegionResolution: + """Resolve canonical, built-in, or custom input without prompting.""" + if not isinstance(value, str) or not value.strip(): + raise RegionError("REGION_INVALID", "AWS region cannot be blank.") + raw = value.strip().casefold() + alias = normalize_alias(value) + canonical, builtins = _registry_maps(all_partitions=True) + if raw in canonical: + matches: list[tuple[RegionInfo, ResolutionSource]] = [ + (canonical[raw], "canonical") + ] + else: + matches = list(builtins.get(alias, ())) + custom = custom_alias_map(custom_aliases) + if alias in custom: + target, _ = custom[alias] + info = canonical.get(target) + if info is None: + raise RegionError( + "REGION_ALIAS_INVALID", + f"Custom region alias {alias!r} references unavailable " + f"region {target!r}.", + ) + matches.append((info, "custom")) + if partition: + matches = [match for match in matches if match[0].partition == partition] + distinct = {match[0].name for match in matches} + if len(distinct) > 1: + candidates = sorted(distinct) + raise RegionError( + "REGION_AMBIGUOUS", + f"Region alias {value!r} is ambiguous: {', '.join(candidates)}.", + candidates=candidates, + repairs=("Use a canonical region or collision-free compact alias.",), + ) + if matches: + info, source = matches[0] + if not info.operational and not allow_non_operational: + raise RegionError( + "REGION_PARTITION_UNSUPPORTED", + f"Region {info.name!r} belongs to unsupported operational " + f"partition {info.partition!r}.", + repairs=("Use --all-partitions only for discovery.",), + ) + return _resolution(info, value, source) + if allow_unknown and REGION_RE.fullmatch(raw): + inferred_partition = _infer_partition(raw) + if partition is not None and inferred_partition != partition: + raise RegionError( + "REGION_PARTITION_MISMATCH", + f"Region {raw!r} belongs to {inferred_partition!r}, not {partition!r}.", + ) + selected_partition = partition or inferred_partition + if selected_partition not in OPERATIONAL_PARTITIONS: + raise RegionError( + "REGION_PARTITION_UNSUPPORTED", + f"Unknown region {raw!r} is not in an operational Hacksaws partition.", + ) + return RegionResolution( + input=value, + canonical=raw, + partition=selected_partition, + description="Unknown region accepted explicitly", + source="unknown", + known=False, + warning=( + f"Region {raw} is absent from bundled Botocore metadata; service " + "support cannot be verified." + ), + ) + suggestions = suggest_regions( + value, custom_aliases=custom_aliases, partition=partition + ) + code = "REGION_UNKNOWN" if REGION_RE.fullmatch(raw) else "REGION_INVALID" + raise RegionError( + code, + f"Unknown AWS region or alias {value!r}.", + candidates=suggestions, + repairs=( + "Run 'hacksaws region list' to discover regions and aliases.", + "Use --allow-unknown-region only for a new canonical AWS region.", + ), + ) + + +def suggest_regions( + value: str, + *, + custom_aliases: Mapping[str, object] | None = None, + partition: str | None = None, + limit: int = 5, +) -> tuple[str, ...]: + """Return deterministic close canonical/alias suggestions.""" + canonical, builtins = _registry_maps(all_partitions=True) + choices = { + name + for name, info in canonical.items() + if (partition is None or info.partition == partition) and info.operational + } + choices.update( + alias + for alias, matches in builtins.items() + if any( + (partition is None or info.partition == partition) and info.operational + for info, _ in matches + ) + ) + choices.update(custom_alias_map(custom_aliases)) + return tuple( + difflib.get_close_matches( + normalize_alias(value), sorted(choices), n=limit, cutoff=0.35 + ) + ) + + +def resolve_region_input( # noqa: C901, PLR0913 + value: str | None, + *, + custom_aliases: Mapping[str, object] | None = None, + partition: str | None = None, + allow_unknown: bool = False, + interactive: bool | None = None, + default: str | None = None, + prompt: str = "AWS Region", + max_attempts: int = MAX_PROMPT_ATTEMPTS, + input_fn: Callable[[str], str] = input, + output_fn: Callable[[str], object] = print, +) -> RegionResolution: + """Resolve one input and strictly repair invalid interactive values.""" + can_prompt = sys.stdin.isatty() if interactive is None else interactive + current = value + attempts = 0 + while True: + if current is None or not str(current).strip(): + if default: + current = default + elif not can_prompt: + raise RegionError( + "REGION_REQUIRED", + "No AWS region could be resolved noninteractively.", + repairs=("Supply --region or configure an AWS/global region.",), + ) + if current is not None and str(current).strip(): + try: + return resolve_region( + str(current), + custom_aliases=custom_aliases, + partition=partition, + allow_unknown=allow_unknown, + ) + except RegionError as error: + if not can_prompt: + raise + output_fn(str(error)) + details = ( + error.details if isinstance(error.details, (list, tuple)) else () + ) + if details: + output_fn("Suggestions: " + ", ".join(map(str, details))) + attempts += 1 + if attempts > max_attempts: + raise RegionError( + "REGION_ATTEMPTS_EXCEEDED", + f"Unable to resolve an AWS region after {max_attempts} attempts.", + ) + try: + answer = input_fn(f"{prompt}{f' [{default}]' if default else ''}: ").strip() + except EOFError as error: + raise RegionError( + "REGION_CANCELLED", "AWS region selection cancelled." + ) from error + if answer.casefold() in {"q", "quit"}: + raise RegionError("REGION_CANCELLED", "AWS region selection cancelled.") + if answer == "?": + values = [item.name for item in region_registry()] + output_fn("Available regions: " + ", ".join(values)) + current = None + continue + current = answer or default + + +def resolve_region_preference( # noqa: PLR0913 + *, + explicit: str | None = None, + env_region: str | None = None, + env_default_region: str | None = None, + target: str | None = None, + destination: str | None = None, + source: str | None = None, + account: str | None = None, + global_region: str | None = None, + custom_aliases: Mapping[str, object] | None = None, + partition: str | None = None, + allow_unknown: bool = False, + interactive: bool = False, +) -> RegionPreference: + """Resolve the locked region precedence with destination persistence policy.""" + layers: tuple[tuple[PreferenceSource, str | None], ...] = ( + ("explicit", explicit), + ( + "aws-region-env", + env_region if env_region is not None else os.getenv("AWS_REGION"), + ), + ( + "aws-default-region-env", + env_default_region + if env_default_region is not None + else os.getenv("AWS_DEFAULT_REGION"), + ), + ("target", target), + ("destination-profile", destination), + ("source-profile", source), + ("account", account), + ("global", global_region), + ) + for layer, candidate in layers: + if not candidate: + continue + resolution = resolve_region( + candidate, + custom_aliases=custom_aliases, + partition=partition, + allow_unknown=allow_unknown, + ) + env_layer = layer in {"aws-region-env", "aws-default-region-env"} + return RegionPreference( + resolution=resolution, + source=layer, + persist_to_destination=not env_layer or not bool(destination), + ) + resolution = resolve_region_input( + None, + custom_aliases=custom_aliases, + partition=partition, + allow_unknown=allow_unknown, + interactive=interactive, + ) + return RegionPreference( + resolution=resolution, source="prompt", persist_to_destination=True + ) + + +def validate_service_region( + resolution: RegionResolution, + service: str, + *, + allow_unknown: bool = False, +) -> RegionResolution: + """Require one region to support a Botocore service or regional sign-in.""" + if not resolution.known: + if allow_unknown: + return resolution + raise RegionError( + "REGION_UNKNOWN", + f"Cannot verify {service} support for unknown region " + f"{resolution.canonical!r}.", + ) + if service == "signin": + supported = resolution.partition in OPERATIONAL_PARTITIONS + else: + supported = ( + resolution.canonical + in botocore.session.get_session().get_available_regions( + service, partition_name=resolution.partition + ) + ) + if not supported: + raise RegionError( + "REGION_SERVICE_UNAVAILABLE", + f"AWS service {service!r} is unavailable in region " + f"{resolution.canonical!r}.", + repairs=("Choose a region listed for this service.",), + ) + return resolution + + +def canonicalize_regions( + values: Iterable[str], + *, + custom_aliases: Mapping[str, object] | None = None, + partition: str | None = None, + allow_unknown: bool = False, + service: str | None = None, +) -> tuple[RegionResolution, ...]: + """Resolve and canonical-deduplicate an ordered region sequence.""" + result: list[RegionResolution] = [] + seen: set[str] = set() + for value in values: + resolution = resolve_region( + value, + custom_aliases=custom_aliases, + partition=partition, + allow_unknown=allow_unknown, + ) + if service: + validate_service_region(resolution, service, allow_unknown=allow_unknown) + if resolution.canonical in seen: + continue + seen.add(resolution.canonical) + result.append(resolution) + return tuple(result) diff --git a/hacksaws/_sessions.py b/hacksaws/_sessions.py index 85513be..51b4d40 100644 --- a/hacksaws/_sessions.py +++ b/hacksaws/_sessions.py @@ -35,7 +35,9 @@ from hacksaws import _configs from hacksaws import _duration from hacksaws import _ecr +from hacksaws import _history from hacksaws import _policies +from hacksaws import _regions from hacksaws import _state if TYPE_CHECKING: @@ -52,6 +54,8 @@ "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AWS_LOGIN_CACHE_DIRECTORY", + "AWS_REGION", + "AWS_DEFAULT_REGION", } @@ -607,7 +611,11 @@ def _role_details( r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/(.+)", str(role) ) if not match: - raise _configs.OperationalError(f"Invalid role ARN {role!r}.") + raise _configs.OperationalError("Invalid role ARN.") + if match.group(1) != partition: + raise _configs.OperationalError( + "Role ARN partition does not match the authenticated caller partition." + ) if account_name: asserted_account, asserted_partition = _role_account_assertion( str(account_name), partition @@ -683,6 +691,123 @@ def _configured_role_before_auth(args: Any) -> str | None: return str(boundary["role_arn"]) +def _profile_region(directory: Path, profile: str) -> str | None: + """Read one physical AWS profile region without invoking provider precedence.""" + parser = _read_ini(directory / "config") + section = _section(profile, config=True) + value = parser.get(section, "region", fallback="").strip() + return value or None + + +def _region_configuration( + args: Any, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], str | None]: + """Load target/account/global region inputs without contacting AWS.""" + data = _state.load_config() + target: dict[str, Any] = {} + account: dict[str, Any] = {} + if getattr(args, "target", None): + _, selected = _state.get_resource(data, "target", args.target.lstrip("+")) + target = dict(selected) + if target.get("source_account"): + _, selected_account = _state.get_resource( + data, "account", str(target["source_account"]) + ) + account = dict(selected_account) + elif getattr(args, "account", None): + try: + _, selected_account = _state.get_resource( + data, "account", str(args.account) + ) + except _configs.OperationalError: + selected_account = {} + account = dict(selected_account) + if not account: + configured_role = _configured_role_before_auth(args) + match = re.fullmatch( + r"arn:([^:]+):iam::(\d{12}):role/.+", configured_role or "" + ) + if match: + for selected_account in data.get("accounts", {}).values(): + if selected_account.get("partition") == match.group( + 1 + ) and selected_account.get("id") == match.group(2): + account = dict(selected_account) + break + aws = cast("dict[str, Any]", data["aws"]) + aliases = cast("dict[str, Any]", aws["region_aliases"]) + global_region = aws["region"] + return ( + target, + account, + aliases, + (str(global_region) if isinstance(global_region, str) else None), + ) + + +def _resolve_session_region( + args: Any, + *, + source_directory: Path, + source_profile: str, + destination_directory: Path, + destination_profile: str, +) -> _regions.RegionPreference: + """Resolve one canonical invocation region before any journal or AWS call.""" + existing = getattr(args, "_region_preference", None) + if isinstance(existing, _regions.RegionPreference): + return existing + target, account, aliases, global_region = _region_configuration(args) + destination_region = _profile_region(destination_directory, destination_profile) + source_region = _profile_region(source_directory, source_profile) + partition = account.get("partition") + if not isinstance(partition, str): + configured_role = _configured_role_before_auth(args) + match = re.fullmatch(r"arn:([^:]+):iam::\d{12}:role/.+", configured_role or "") + partition = match.group(1) if match else None + preference = _regions.resolve_region_preference( + explicit=getattr(args, "region", None), + target=( + str(target["region"]) if isinstance(target.get("region"), str) else None + ), + destination=destination_region, + source=source_region, + account=( + str(account["region"]) if isinstance(account.get("region"), str) else None + ), + global_region=global_region, + custom_aliases=aliases, + partition=partition, + allow_unknown=bool(getattr(args, "allow_unknown_region", False)), + interactive=not bool(getattr(args, "json", False)) and sys.stdin.isatty(), + ) + args._region_preference = preference + _history.note_region( + region=preference.canonical, + partition=preference.resolution.partition, + source=preference.source, + ) + return preference + + +def _region_metadata(preference: _regions.RegionPreference) -> dict[str, Any]: + """Return secret-free region metadata for session, status, and history output.""" + resolution = preference.resolution + return { + "region": resolution.canonical, + "region_partition": resolution.partition, + "region_source": preference.source, + "region_input_kind": resolution.source, + "region_alias": resolution.matched_alias, + "region_known": resolution.known, + "region_warning": resolution.warning, + } + + +def _region_to_persist(preference: _regions.RegionPreference) -> str | None: + return preference.canonical if preference.persist_to_destination else None + + def _session_name(role: str, boundary: str | None, override: str | None) -> str: raw = ( override @@ -744,7 +869,7 @@ def _assume( ) -> tuple[dict[str, Any], dict[str, Any]]: match = re.fullmatch(r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/.+", role) if not match: - raise _configs.OperationalError(f"Invalid role ARN {role!r}.") + raise _configs.OperationalError("Invalid role ARN.") duration = effective_duration or _effective_assume_duration( session, role, args=args, target=target ) @@ -775,10 +900,17 @@ def _assume( raise _configs.OperationalError( f"Unable to assume boundary role {role}: {error}" ) from error + preference = getattr(args, "_region_preference", None) + region_name = ( + preference.canonical + if isinstance(preference, _regions.RegionPreference) + else None + ) final_session = boto3.Session( aws_access_key_id=response["Credentials"]["AccessKeyId"], aws_secret_access_key=response["Credentials"]["SecretAccessKey"], aws_session_token=response["Credentials"]["SessionToken"], + region_name=region_name, ) account, final_partition, _ = _identity(final_session, label="boundary credentials") if account != match.group(2) or final_partition != match.group(1): @@ -803,6 +935,11 @@ def _assume( else None ), "expires_at": response["Credentials"]["Expiration"].astimezone(UTC).isoformat(), + **( + _region_metadata(preference) + if isinstance(preference, _regions.RegionPreference) + else {} + ), } return response["Credentials"], metadata @@ -997,7 +1134,7 @@ def _parser_from_bytes(value: bytes | None, path: Path) -> configparser.ConfigPa def _persistent_source( - source_dir: Path, profile: str + source_dir: Path, profile: str, *, region_name: str | None = None ) -> tuple[Any, configparser.ConfigParser]: """Build a session from original persistent credentials, never installed output.""" credentials_path = source_dir / "credentials" @@ -1017,7 +1154,7 @@ def _persistent_source( "for transactional MFA re-login." ) config_section = _section(profile, config=True) - region = config.get(config_section, "region", fallback=None) + region = region_name or config.get(config_section, "region", fallback=None) source = boto3.Session( aws_access_key_id=values["aws_access_key_id"], aws_secret_access_key=values["aws_secret_access_key"], @@ -1033,6 +1170,8 @@ def _mfa_session( profile: str, token: str, lifespan: int, + *, + region_name: str | None = None, ) -> Any: section = _section(profile, config=True) if not config.has_option(section, "mfa_serial"): @@ -1054,7 +1193,7 @@ def _mfa_session( aws_access_key_id=values["AccessKeyId"], aws_secret_access_key=values["SecretAccessKey"], aws_session_token=values["SessionToken"], - region_name=source.region_name, + region_name=region_name or source.region_name, ) @@ -1062,7 +1201,16 @@ def mfa_login(context: _configs.Context) -> _configs.Result: """Authenticate with MFA and transactionally persist only the final tier.""" args = context.args source_dir, source_profile, destination_dir, destination_profile = _paths(args) - raw, source_config = _persistent_source(source_dir, source_profile) + region = _resolve_session_region( + args, + source_directory=source_dir, + source_profile=source_profile, + destination_directory=destination_dir, + destination_profile=destination_profile, + ) + raw, source_config = _persistent_source( + source_dir, source_profile, region_name=region.canonical + ) source_account, partition, _ = _identity(raw, label="MFA source credentials") target = _target_details(args, source_account, partition) role, policy, external_id, boundary_name = _role_details( @@ -1070,7 +1218,12 @@ def mfa_login(context: _configs.Context) -> _configs.Result: ) _require_concrete_role(args, role) intermediate = _mfa_session( - raw, source_config, source_profile, args.mfa_code, args.lifespan + raw, + source_config, + source_profile, + args.mfa_code, + args.lifespan, + region_name=region.canonical, ) journal = _begin( [ @@ -1087,7 +1240,7 @@ def mfa_login(context: _configs.Context) -> _configs.Result: "Account": source_account, "Arn": f"arn:{partition}:iam::{source_account}:user/hacksaws", }, - region_name=intermediate.region_name or args.region or "us-east-1", + region_name=region.canonical, ecr_additional_regions=tuple(args.ecr_region or ()), ) ecr_registries = _ecr.login_with_session( @@ -1132,8 +1285,9 @@ def mfa_login(context: _configs.Context) -> _configs.Result: source_profile, destination_dir / "config", destination_profile, - args.region, + _region_to_persist(region), ) + metadata.update(_region_metadata(region)) metadata["source_account"] = source_account metadata["source_partition"] = partition metadata["target"] = target.get("target_name") @@ -1246,10 +1400,19 @@ def _aws_login( *, remote: bool, login_cache: Path, + region_name: str, ) -> None: _aws_cli_version() config.parent.mkdir(parents=True, exist_ok=True) - command = ["aws", "login", "--profile", profile] + command = [ + "aws", + "login", + "--profile", + profile, + "--region", + region_name, + "--no-cli-auto-prompt", + ] if remote: command.append("--remote") try: @@ -1271,6 +1434,18 @@ def browser_login(context: _configs.Context) -> _configs.Result: configured_role = _configured_role_before_auth(args) _require_concrete_role(args, configured_role) has_boundary = configured_role is not None + region = _resolve_session_region( + args, + source_directory=source_dir, + source_profile=source_profile, + destination_directory=destination_dir, + destination_profile=destination_profile, + ) + _regions.validate_service_region( + region.resolution, + "signin", + allow_unknown=bool(getattr(args, "allow_unknown_region", False)), + ) if not has_boundary: native_cache = _native_login_cache() journal = _begin( @@ -1283,12 +1458,17 @@ def browser_login(context: _configs.Context) -> _configs.Result: ecr_registries: list[str] = [] login_completed = False try: + if region.persist_to_destination: + _apply_region_values( + destination_dir, destination_profile, {}, region.canonical + ) _aws_login( destination_dir / "config", destination_dir / "credentials", destination_profile, remote=args.remote, login_cache=native_cache, + region_name=region.canonical, ) login_completed = True initial_lineage = _browser_cache_lineage( @@ -1307,7 +1487,9 @@ def browser_login(context: _configs.Context) -> _configs.Result: destination_dir / "credentials", native_cache, ): - native = boto3.Session(profile_name=destination_profile) + native = boto3.Session( + profile_name=destination_profile, region_name=region.canonical + ) account, partition, principal = _identity(native, label="browser login") lineage = _browser_cache_lineage( destination_dir / "config", @@ -1326,7 +1508,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: "Account": account, "Arn": f"arn:{partition}:iam::{account}:user/hacksaws", }, - native.region_name or args.region or "us-east-1", + region.canonical, tuple(args.ecr_region or ()), ) ecr_registries = _ecr.login_with_session( @@ -1352,6 +1534,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: "policy_provenance": "AWS-native login_session", "expires_at": None, "login_cache_lineage": lineage, + **_region_metadata(region), }, journal, method="browser-native", @@ -1386,12 +1569,14 @@ def browser_login(context: _configs.Context) -> _configs.Result: ecr_registries = [] login_completed = False try: + _apply_region_values(staging, source_profile, {}, region.canonical) _aws_login( staging_config, staging_credentials, source_profile, remote=args.remote, login_cache=staging_cache, + region_name=region.canonical, ) login_completed = True initial_lineage = _browser_cache_lineage( @@ -1401,7 +1586,9 @@ def browser_login(context: _configs.Context) -> _configs.Result: journal, initial_lineage, staging_config, source_profile ) with _aws_environment(staging_config, staging_credentials, staging_cache): - intermediate = boto3.Session(profile_name=source_profile) + intermediate = boto3.Session( + profile_name=source_profile, region_name=region.canonical + ) source_account, partition, source_principal = _identity( intermediate, label="browser staging login" ) @@ -1426,7 +1613,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: "Account": source_account, "Arn": (f"arn:{partition}:iam::{source_account}:user/hacksaws"), }, - intermediate.region_name or args.region or "us-east-1", + region.canonical, tuple(args.ecr_region or ()), ) ecr_registries = _ecr.login_with_session( @@ -1455,7 +1642,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: source_profile, destination_dir / "config", destination_profile, - args.region, + _region_to_persist(region), ) config = _read_ini(destination_dir / "config") section = _section(destination_profile, config=True) @@ -1467,6 +1654,7 @@ def browser_login(context: _configs.Context) -> _configs.Result: source_partition=partition, target=target.get("target_name"), ) + metadata.update(_region_metadata(region)) _record( destination_dir, destination_profile, @@ -1590,6 +1778,13 @@ def _assume_preflight(context: _configs.Context) -> dict[str, Any]: destination_profile = _normalize_profile(args.to_profile) source = source.absolute() destination = destination.absolute() + region = _resolve_session_region( + args, + source_directory=source, + source_profile=source_profile, + destination_directory=destination, + destination_profile=destination_profile, + ) source_key = f"{source}::{source_profile}" destination_key = f"{destination}::{destination_profile}" same_key = source_key == destination_key @@ -1684,7 +1879,9 @@ def _assume_preflight(context: _configs.Context) -> dict[str, Any]: with _aws_environment( source / "config", source / "credentials", source_login_cache ): - authenticated = boto3.Session(profile_name=source_profile) + authenticated = boto3.Session( + profile_name=source_profile, region_name=region.canonical + ) source_account, partition, source_arn = _identity( authenticated, label="authenticated assume-role source" ) @@ -1725,6 +1922,7 @@ def _assume_preflight(context: _configs.Context) -> dict[str, Any]: "external_id": external_id, "boundary_name": boundary_name, "region_values": _region_values(source, source_profile), + "region_preference": region, } @@ -1769,6 +1967,12 @@ def _assume_public_plan(plan: dict[str, Any]) -> dict[str, Any]: "keepSource": plan["keep_source"], "keepEcr": plan["keep_ecr"], "replace": plan["replace"], + "region": { + "canonical": plan["region_preference"].canonical, + "partition": plan["region_preference"].resolution.partition, + "source": plan["region_preference"].source, + "persistToDestination": plan["region_preference"].persist_to_destination, + }, "lifecycle": { "source": "keep" if plan["keep_source"] else "logout", "destination": destination_action, @@ -1806,8 +2010,22 @@ def _assume_arguments_fingerprint(args: Any) -> str: "replace", "force", ) + preference = getattr(args, "_region_preference", None) + region_state = ( + { + "canonical": preference.canonical, + "partition": preference.resolution.partition, + "source": preference.source, + "persist": preference.persist_to_destination, + } + if isinstance(preference, _regions.RegionPreference) + else None + ) encoded = json.dumps( - {name: getattr(args, name, None) for name in names}, + { + **{name: getattr(args, name, None) for name in names}, + "resolved_region": region_state, + }, sort_keys=True, separators=(",", ":"), default=str, @@ -1994,13 +2212,17 @@ def _write_section(path: Path, section: str, state: dict[str, Any]) -> None: def _planned_destination_config(data: dict[str, Any], args: Any) -> dict[str, str]: + del args # Region precedence was frozen into the prepared plan. destination = cast("Path", data["destination"]) profile = str(data["destination_profile"]) parser = _read_ini(destination / "config") section = _section(profile, config=True) values = dict(parser[section].items()) if section in parser else {} - if getattr(args, "region", None): - values["region"] = str(args.region) + preference = data.get("region_preference") + if isinstance(preference, _regions.RegionPreference) and ( + preference.persist_to_destination + ): + values["region"] = preference.canonical else: for key, value in data["region_values"].items(): values.setdefault(key, value) @@ -3060,11 +3282,18 @@ def _effective_scope(session: dict[str, Any]) -> dict[str, Any]: "source_profile", "source_destination", "source_auth_method", + "region", + "region_partition", + "region_source", + "region_input_kind", + "region_alias", + "region_warning", } _PUBLIC_SESSION_BOOL_FIELDS = { "cache_cleanup_incomplete", "policy_cached", "source_logged_out", + "region_known", } _PUBLIC_SESSION_INT_FIELDS = {"session_schema_version"} @@ -3107,6 +3336,13 @@ def _public_session(session: dict[str, Any], *, now: datetime) -> dict[str, Any] ) public["destination"] = str(destination) public["location"] = _location_for_directory(destination) + try: + public["profile_region"] = _profile_region( + destination, + str(public.get("profile") or session.get("profile") or "default"), + ) + except _configs.OperationalError: + public["profile_region"] = None public["managed"] = True effective_scope = _effective_scope(session) public["effective_scope"] = effective_scope @@ -3230,7 +3466,11 @@ def _verify_status(item: dict[str, Any]) -> dict[str, Any]: directory / "config", directory / "credentials", _native_login_cache() ): account, partition, arn = _identity( - boto3.Session(profile_name=profile), label=f"profile {profile!r}" + boto3.Session( + profile_name=profile, + region_name=(str(item["region"]) if item.get("region") else None), + ), + label=f"profile {profile!r}", ) except _configs.OperationalError as error: return {"status": "error", "message": str(error)} @@ -3346,6 +3586,7 @@ def profile_inventory( } for directory, location in _known_directories().items(): names: set[str] = set() + profile_regions: dict[str, str] = {} for filename, is_config in (("credentials", False), ("config", True)): path = directory / filename try: @@ -3357,8 +3598,19 @@ def profile_inventory( if is_config: if section == "default": names.add("default") + region_value = parser.get( + section, "region", fallback="" + ).strip() + if region_value: + profile_regions["default"] = region_value elif section.startswith("profile ") and section[8:]: - names.add(section[8:]) + profile_name = section[8:] + names.add(profile_name) + region_value = parser.get( + section, "region", fallback="" + ).strip() + if region_value: + profile_regions[profile_name] = region_value else: names.add(section) if directory.exists(): @@ -3379,6 +3631,8 @@ def profile_inventory( "directory": str(directory), "profile": profile_name, "managed": lifecycle is not None or legacy, + "region": profile_regions.get(profile_name) + or (lifecycle or {}).get("region"), "state": ( lifecycle["state"] if lifecycle @@ -3426,6 +3680,208 @@ def profile_inventory( return {"profiles": profiles, "count": len(profiles), "warnings": warnings} +def _selected_profile_endpoint(args: Any) -> tuple[Path, str, str | None]: + """Resolve one profile/location selector for local profile configuration.""" + selector = _configs.resolve_credential_selector(args) + if selector.target: + data = _state.load_config() + target_name, target = _state.get_resource( + data, "target", selector.target.lstrip("+") + ) + directory = ( + Path(str(target["source_directory"])).expanduser().absolute() + if target.get("source_directory") + else _state.aws_directory(target.get("source_location")).absolute() + ) + return directory, _normalize_profile(target.get("source_profile")), target_name + directory = selector.directory or _state.aws_directory(selector.location).absolute() + return directory, _normalize_profile(selector.profile), None + + +def _profile_region_result( + directory: Path, profile: str, *, target: str | None = None +) -> dict[str, Any]: + current = _profile_region(directory, profile) + result: dict[str, Any] = { + "directory": str(directory), + "location": _location_for_directory(directory), + "profile": profile, + "target": target, + "region": current, + } + if current: + data = _state.load_config() + resolution = _regions.resolve_region( + current, + custom_aliases=data["aws"]["region_aliases"], + allow_unknown=True, + ) + result.update( + partition=resolution.partition, + description=resolution.description, + known=resolution.known, + ) + else: + result.update(partition=None, description=None, known=None) + managed = _state.load_sessions().get(f"{directory.absolute()}::{profile}") + result["managed"] = managed is not None + result["auth_method"] = managed.get("auth_method") if managed else None + return result + + +def profile_region_get(args: Any) -> dict[str, Any]: + """Return one profile's physical service region without loading credentials.""" + directory, profile, target = _selected_profile_endpoint(args) + return _profile_region_result(directory, profile, target=target) + + +def _rebase_managed_region( + session: dict[str, Any], + *, + config: Path, + profile: str, + resolution: _regions.RegionResolution | None, +) -> None: + sections = session.get("section_backup") + if not isinstance(sections, dict): + raise _configs.OperationalError( + "Managed session has no safe config-section backup to rebase." + ) + config_backup = sections.get("config") + if not isinstance(config_backup, dict): + raise _configs.OperationalError( + "Managed session has no safe config-section backup to rebase." + ) + original = config_backup.get("original") + if not isinstance(original, dict) or not isinstance(original.get("values"), dict): + raise _configs.OperationalError("Managed original config section is invalid.") + originally_existed = bool(original.get("exists")) + original_values = { + str(key): str(value) for key, value in original["values"].items() + } + if resolution is None: + original_values.pop("region", None) + original["exists"] = originally_existed or bool(original_values) + for key in ( + "region", + "region_partition", + "region_source", + "region_input_kind", + "region_alias", + "region_known", + "region_warning", + ): + session.pop(key, None) + else: + original_values["region"] = resolution.canonical + original["exists"] = True + session.update( + region=resolution.canonical, + region_partition=resolution.partition, + region_source="profile-command", + region_input_kind=resolution.source, + region_alias=resolution.matched_alias, + region_known=resolution.known, + region_warning=resolution.warning, + ) + original["values"] = original_values + config_backup["installed"] = _section_state(config, _section(profile, config=True)) + + +def profile_region_change(args: Any, *, clear: bool = False) -> dict[str, Any]: + """Transactionally persist a profile region and rebase managed logout state.""" + directory, profile, target = _selected_profile_endpoint(args) + config_path = directory / "config" + section = _section(profile, config=True) + parser = _read_ini(config_path) + current = parser.get(section, "region", fallback="").strip() or None + sessions = _state.load_sessions() + key = f"{directory.absolute()}::{profile}" + managed = sessions.get(key) + if managed: + _profile_section_plans(managed, directory, profile, force=False) + data = _state.load_config() + aliases = data["aws"]["region_aliases"] + current_resolution = ( + _regions.resolve_region(current, custom_aliases=aliases, allow_unknown=True) + if current + else None + ) + if clear: + if current is None: + result = _profile_region_result(directory, profile, target=target) + result.update(changed=False, previous_region=None, warnings=[]) + return result + if managed and managed.get("auth_method") == "browser-native": + raise _configs.OperationalError( + "An active browser-native profile requires a physical region for " + "credential refresh; log out before clearing it." + ) + resolution = None + else: + resolution = _regions.resolve_region( + str(args.region), + custom_aliases=aliases, + allow_unknown=bool(getattr(args, "allow_unknown_region", False)), + ) + expected_partition = ( + current_resolution.partition + if current_resolution + else str( + (managed or {}).get("region_partition") + or (managed or {}).get("source_partition") + or (managed or {}).get("target_partition") + or "" + ) + or None + ) + if expected_partition and resolution.partition != expected_partition: + raise _regions.RegionError( + "REGION_PARTITION_MISMATCH", + f"Profile {profile!r} is in partition {expected_partition!r}; " + f"region {resolution.canonical!r} is in {resolution.partition!r}.", + repairs=("Choose a region in the profile's current partition.",), + ) + selected = resolution.canonical if resolution else None + if selected == current: + result = _profile_region_result(directory, profile, target=target) + result.update(changed=False, previous_region=current, warnings=[]) + return result + journal = _begin([config_path, _state.sessions_path()]) + try: + if section not in parser: + parser.add_section(section) + if resolution is None: + parser[section].pop("region", None) + if not parser[section]: + parser.remove_section(section) + else: + parser[section]["region"] = resolution.canonical + _write_ini(config_path, parser) + if managed: + _rebase_managed_region( + managed, + config=config_path, + profile=profile, + resolution=resolution, + ) + sessions[key] = managed + _state.save_sessions(sessions) + _commit() + except Exception: + _rollback(journal) + raise + result = _profile_region_result(directory, profile, target=target) + result.update( + changed=True, + previous_region=current, + warnings=[ + "Already-running processes may retain the previous AWS region until restarted." + ], + ) + return result + + def _restore_profile_sections( session: dict[str, Any], destination: Path, profile: str, *, force: bool ) -> None: @@ -3548,7 +4004,10 @@ def _upgrade_legacy_browser_lineage( with _aws_environment( destination / "config", destination / "credentials", root ): - active = boto3.Session(profile_name=profile) + active = boto3.Session( + profile_name=profile, + region_name=_profile_region(destination, profile), + ) identity = _identity(active, label="legacy browser login cache ownership") current = _browser_cache_lineage(config, profile, root, identity=identity) except _configs.OperationalError: @@ -3722,7 +4181,15 @@ def _tracked_login_cache_plan( # noqa: PLR0911 destination / "credentials", root, ): - active = boto3.Session(profile_name=profile) + current_region = session.get("region") or _profile_region( + destination, profile + ) + active = boto3.Session( + profile_name=profile, + region_name=( + str(current_region) if current_region else None + ), + ) identity = _identity( active, label="browser login cache ownership" ) @@ -4629,6 +5096,16 @@ def import_config(source: Path, *, replace: bool, yes: bool) -> str: existing_config = _state.load_config() current = copy.deepcopy(existing_config) conflicts: list[str] = [] + defaults = _state.default_config() + for section in ("cache", "naming", "history", "aws"): + if current[section] == imported[section]: + continue + customized = current[section] != defaults[section] + if customized: + conflicts.append(f"config:{section}") + if not replace: + continue + current[section] = copy.deepcopy(imported[section]) for collection in ("accounts", "boundaries", "targets", "policies"): for name, value in imported[collection].items(): existing = next( diff --git a/hacksaws/_state.py b/hacksaws/_state.py index a8c68aa..aaa99d0 100644 --- a/hacksaws/_state.py +++ b/hacksaws/_state.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any +from hacksaws import _regions from hacksaws._configs import OperationalError SCHEMA_VERSION = 1 @@ -34,6 +35,7 @@ "session", "output", "history", + "aws", } NAMING_FIELDS = {"case", "prefix", "suffix", "enforcement"} NAMING_CASES = {"Pascal", "camel", "snake", "kebab"} @@ -85,6 +87,7 @@ def default_config() -> dict[str, Any]: "max_entries": 10_000, "max_bytes": 50 * 1024 * 1024, }, + "aws": {"region": None, "region_aliases": {}}, } @@ -153,7 +156,7 @@ def _validate_config(data: object) -> dict[str, Any]: if type(data) is not dict: raise OperationalError("Hacksaws config must be a JSON object.") defaults = default_config() - for key in ("naming", "iam", "session", "output", "history"): + for key in ("naming", "iam", "session", "output", "history", "aws"): data.setdefault(key, deepcopy(defaults[key])) unknown = set(data) - TOP_LEVEL if unknown: @@ -273,6 +276,33 @@ def _validate_foundation_settings(data: dict[str, Any]) -> None: raise OperationalError( f"Config history.{field} must be a positive integer." ) + aws = data["aws"] + if type(aws) is not dict or set(aws) != {"region", "region_aliases"}: + raise OperationalError( + "Config aws accepts only region and region_aliases settings." + ) + if aws["region"] is not None: + _validate_canonical_region(aws["region"], label="Config aws.region") + if type(aws["region_aliases"]) is not dict: + raise OperationalError("Config aws.region_aliases must be an object.") + _regions.validate_custom_aliases(aws["region_aliases"]) + + +def _validate_canonical_region( + value: object, *, label: str, partition: str | None = None +) -> str: + """Require a canonical known or explicitly forward-compatible region.""" + if type(value) is not str: + raise OperationalError(f"{label} must be canonical region text.") + resolution = _regions.resolve_region(value, partition=partition, allow_unknown=True) + if resolution.canonical != value or resolution.source not in { + "canonical", + "unknown", + }: + raise OperationalError( + f"{label} must store canonical region {resolution.canonical!r}, not an alias." + ) + return resolution.canonical def _validate_resources(data: dict[str, Any]) -> None: @@ -297,6 +327,7 @@ def _validate_resources(data: dict[str, Any]) -> None: "description", "unverified", "credential_target", + "region", } if unknown: raise OperationalError( @@ -324,6 +355,12 @@ def _validate_resources(data: dict[str, Any]) -> None: raise OperationalError( f"Account {name!r} unverified must be true when set." ) + if "region" in account: + _validate_canonical_region( + account["region"], + label=f"Account {name!r} region", + partition=account["partition"], + ) for name, policy in data["policies"].items(): unknown = set(policy) - {"file", "description"} if unknown: @@ -402,6 +439,7 @@ def _validate_resources(data: dict[str, Any]) -> None: "destination_directory", "boundary", "description", + "region", } if set(target) - allowed: raise OperationalError(f"Unknown target field(s) for {name}.") @@ -459,9 +497,27 @@ def _validate_resources(data: dict[str, Any]) -> None: raise OperationalError( f"Target {name!r} references missing boundary {boundary!r}." ) + if "region" in target: + account_key = _find_key(data["accounts"], target["source_account"]) + account = data["accounts"][account_key] + _validate_canonical_region( + target["region"], + label=f"Target {name!r} region", + partition=account["partition"], + ) CONFIG_OPTION_PATTERNS: dict[str, dict[str, object]] = { + "aws.region": { + "description": "Global fallback AWS region; aliases resolve before storage.", + "default": None, + }, + "aws.region_aliases..region": { + "description": "Canonical region selected by one global custom alias.", + }, + "aws.region_aliases..description": { + "description": "Optional human description for one custom region alias.", + }, "naming.global.{case|prefix|suffix|enforcement}": { "description": "Default naming policy; later layers override earlier layers.", "default": {"case": "Pascal", "prefix": "", "suffix": "", "enforcement": "off"}, @@ -510,6 +566,12 @@ def _validate_resources(data: dict[str, Any]) -> None: "accounts..credential_target": { "description": "Per-account credential target used only when explicitly selected.", }, + "accounts..region": { + "description": "Preferred fallback region for one configured AWS account.", + }, + "targets..region": { + "description": "Saved target region, overriding profile/account/global defaults.", + }, } @@ -559,6 +621,21 @@ def get_config_option(data: dict[str, Any], key: str) -> object: def set_config_option(data: dict[str, Any], key: str, value: object) -> None: """Set a known leaf option and validate the complete schema-one document.""" parts = _option_parts(key) + if parts[:2] == ["aws", "region_aliases"] and len(parts) == 4: + alias, field = parts[2:] + if field not in {"region", "description"}: + raise OperationalError( + f"Unknown config option {key!r}; run 'config options'." + ) + aliases = data["aws"]["region_aliases"] + existing = aliases.get(alias) + if field == "description" and type(existing) is not dict: + raise OperationalError( + f"Set aws.region_aliases.{alias}.region before its description." + ) + aliases.setdefault(alias, {})[field] = value + _validate_config(data) + return if parts[:2] == ["naming", "resources"] and len(parts) == 4: resource, field = parts[2:] validate_name(resource, kind="naming resource") @@ -595,10 +672,15 @@ def set_config_option(data: dict[str, Any], key: str, value: object) -> None: if ( parts[:1] == ["accounts"] and len(parts) == 3 - and parts[2] == "credential_target" + and parts[2] in {"credential_target", "region"} ): account, value_map = get_resource(data, "account", parts[1]) - data["accounts"][account] = {**value_map, "credential_target": value} + data["accounts"][account] = {**value_map, parts[2]: value} + _validate_config(data) + return + if parts[:1] == ["targets"] and len(parts) == 3 and parts[2] == "region": + target, value_map = get_resource(data, "target", parts[1]) + data["targets"][target] = {**value_map, "region": value} _validate_config(data) return current: dict[str, Any] = data @@ -619,6 +701,20 @@ def reset_config_option(data: dict[str, Any], key: str) -> None: """Reset a known option to its schema-one default where one exists.""" defaults = default_config() parts = _option_parts(key) + if parts[:2] == ["aws", "region_aliases"] and len(parts) == 4: + alias, field = parts[2:] + aliases = data["aws"]["region_aliases"] + existing = aliases.get(alias) + if type(existing) is not dict or field not in existing: + raise OperationalError(f"Config option {key!r} has no reset default.") + if field == "region": + del aliases[alias] + elif field == "description": + del existing[field] + else: + raise OperationalError(f"Config option {key!r} has no reset default.") + _validate_config(data) + return if parts[:2] == ["naming", "resources"] and len(parts) == 4: resource, field = parts[2:] override = data["naming"]["resources"].get(resource) @@ -664,6 +760,20 @@ def reset_config_option(data: dict[str, Any], key: str) -> None: del data["naming"]["account_resources"][account] _validate_config(data) return + if ( + len(parts) == 3 + and parts[0] in {"accounts", "targets"} + and parts[2] + in ({"credential_target", "region"} if parts[0] == "accounts" else {"region"}) + ): + kind = "account" if parts[0] == "accounts" else "target" + canonical, value = get_resource(data, kind, parts[1]) + if parts[2] not in value: + raise OperationalError(f"Config option {key!r} has no reset default.") + value.pop(parts[2]) + data[parts[0]][canonical] = value + _validate_config(data) + return current: dict[str, Any] = data default_current: dict[str, Any] = defaults for part in parts[:-1]: diff --git a/hacksaws/tests/test_assume_role.py b/hacksaws/tests/test_assume_role.py index 6a0cc47..c9bf125 100644 --- a/hacksaws/tests/test_assume_role.py +++ b/hacksaws/tests/test_assume_role.py @@ -1481,16 +1481,24 @@ def test_raw_account_id_uses_caller_partition_and_asserts_direct_role_arn() -> N _sessions._role_details(args, {}, ACCOUNT, "aws-cn") -def test_configured_account_name_keeps_its_configured_partition( +def test_configured_account_name_must_share_the_authenticated_partition( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) data = _state.default_config() + data["accounts"]["Other"] = {"id": OTHER_ACCOUNT, "partition": "aws"} data["accounts"]["China"] = {"id": OTHER_ACCOUNT, "partition": "aws-cn"} _state.save_config(data) - args = _args(Path(), role="AgentSession", account="China") + + args = _args(Path(), role="AgentSession", account="Other") role, *_ = _sessions._role_details(args, {}, ACCOUNT, "aws") - assert role == f"arn:aws-cn:iam::{OTHER_ACCOUNT}:role/AgentSession" + assert role == f"arn:aws:iam::{OTHER_ACCOUNT}:role/AgentSession" + + args = _args(Path(), role="AgentSession", account="China") + with pytest.raises( + _configs.OperationalError, match="authenticated caller partition" + ): + _sessions._role_details(args, {}, ACCOUNT, "aws") def test_legacy_backup_validation_errors_are_operational_errors( diff --git a/hacksaws/tests/test_hacksaws.py b/hacksaws/tests/test_hacksaws.py index a17caec..ea44312 100644 --- a/hacksaws/tests/test_hacksaws.py +++ b/hacksaws/tests/test_hacksaws.py @@ -4,7 +4,6 @@ import argparse import configparser -import os import subprocess import tomllib from contextlib import ExitStack @@ -25,6 +24,8 @@ from hacksaws import _aws from hacksaws import _configs from hacksaws import _ecr +from hacksaws import _sessions +from hacksaws import _state if TYPE_CHECKING: from collections.abc import Mapping @@ -125,6 +126,16 @@ def _session(*, region_name: str, clients: Mapping[str, object]) -> MagicMock: return session +def _authenticated_session(*, clients: Mapping[str, object] | None = None) -> MagicMock: + """Return an MFA-authenticated session with deterministic frozen credentials.""" + session = _session(region_name="us-west-2", clients=clients or {}) + frozen = session.get_credentials.return_value.get_frozen_credentials.return_value + frozen.access_key = TEMPORARY_ACCESS_KEY + frozen.secret_key = TEMPORARY_SECRET_KEY + frozen.token = SESSION_TOKEN + return session + + def _identity_response() -> dict[str, str]: return { "UserId": "AIDAEXAMPLE", @@ -246,18 +257,10 @@ def test_podman_without_ecr_does_not_run_container_commands( ) -> None: """Treat --podman only as the engine choice for an explicit ECR operation.""" context = _context(tmp_path, podman=True) - account = _configs.AwsAccount( - identity_response=_identity_response(), - region_name="us-west-2", - ecr_additional_regions=(), - ) - with ( - patch("hacksaws._aws.logout"), - patch("hacksaws._aws.login"), patch( - "hacksaws._configs.AwsAccount.from_context", - return_value=account, + "hacksaws._sessions.mfa_login", + return_value=_configs.Result("MFA_LOGIN", "logged in"), ), patch("hacksaws._ecr.logout") as ecr_logout, patch("hacksaws._ecr.login") as ecr_login, @@ -284,36 +287,26 @@ def test_mfa_without_action_prints_command_help( def test_login_exchanges_and_stores_credentials( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Use STS with exact parameters and persist temporary credentials.""" + """Persist MFA credentials while retaining the original section for logout.""" _prepare_aws_directory(tmp_path) - identity_client = _sts_client() - token_client = _sts_client() - identity_stubber = Stubber(identity_client) - identity_stubber.add_response( - "get_caller_identity", - _identity_response(), - {}, - ) - token_stubber = Stubber(token_client) - token_stubber.add_response( - "get_session_token", - {"Credentials": _temporary_credentials()}, - { - "DurationSeconds": 43200, - "SerialNumber": MFA_SERIAL, - "TokenCode": "123456", - }, - ) - sessions = [ - _session(region_name="us-west-2", clients={"sts": identity_client}), - _session(region_name="us-west-2", clients={"sts": token_client}), - ] + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "hacksaws-home")) + _state.save_config(_state.default_config()) + raw = MagicMock(region_name="us-west-2") + intermediate = _authenticated_session() + source_config = _sessions._read_ini(tmp_path / "config") with ( - identity_stubber, - token_stubber, - patch("boto3.Session", side_effect=sessions) as session_factory, + patch( + "hacksaws._sessions._persistent_source", + return_value=(raw, source_config), + ), + patch( + "hacksaws._sessions._identity", + return_value=(ACCOUNT_ID, "aws", _identity_response()["Arn"]), + ), + patch("hacksaws._sessions._mfa_session", return_value=intermediate), ): result = hacksaws.console_main( [ @@ -327,7 +320,6 @@ def test_login_exchanges_and_stores_credentials( ) credentials = _read_ini(tmp_path / "credentials") - backup = _read_ini(tmp_path / f"{PROFILE}.store.credentials") assert result.code == "MFA_LOGIN" assert result.exit_code == 0 assert credentials[PROFILE] == { @@ -335,18 +327,11 @@ def test_login_exchanges_and_stores_credentials( "aws_secret_access_key": TEMPORARY_SECRET_KEY, "aws_session_token": SESSION_TOKEN, } - assert backup[PROFILE] == { + managed = _state.load_sessions()[f"{tmp_path.absolute()}::{PROFILE}"] + assert managed["section_backup"]["credentials"]["original"]["values"] == { "aws_access_key_id": STATIC_ACCESS_KEY, "aws_secret_access_key": STATIC_SECRET_KEY, } - assert os.environ["AWS_SHARED_CREDENTIALS_FILE"] == str( - tmp_path / "credentials", - ) - assert os.environ["AWS_CONFIG_FILE"] == str(tmp_path / "config") - assert session_factory.call_args_list == [ - call(profile_name=PROFILE), - call(profile_name=PROFILE), - ] def test_logout_restores_credentials(tmp_path: Path) -> None: @@ -406,33 +391,18 @@ def test_ecr_regions_and_container_commands_are_ordered_and_exact( tmp_path: Path, engine: str, podman: bool, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Run exact engine commands in primary-first, duplicate-free region order.""" _prepare_aws_directory(tmp_path) regions = ["us-east-1", "us-west-2", "eu-west-1", "us-east-1"] ordered_regions = ["us-west-2", "us-east-1", "eu-west-1"] - identity_client = _sts_client() - token_client = _sts_client() - identity_stubber = Stubber(identity_client) - identity_stubber.add_response("get_caller_identity", _identity_response(), {}) - token_stubber = Stubber(token_client) - token_stubber.add_response( - "get_session_token", - {"Credentials": _temporary_credentials()}, - { - "DurationSeconds": 43200, - "SerialNumber": MFA_SERIAL, - "TokenCode": "123456", - }, - ) - sessions = [ - _session(region_name="us-west-2", clients={"sts": identity_client}), - _session(region_name="us-west-2", clients={"sts": token_client}), - ] + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "hacksaws-home")) + _state.save_config(_state.default_config()) + raw = MagicMock(region_name="us-west-2") + regional_clients: dict[str, object] = {} stack = ExitStack() - stack.enter_context(identity_stubber) - stack.enter_context(token_stubber) for region in ordered_regions: ecr_client = _ecr_client(region) ecr_stubber = Stubber(ecr_client) @@ -452,7 +422,12 @@ def test_ecr_regions_and_container_commands_are_ordered_and_exact( {"registryIds": [ACCOUNT_ID]}, ) stack.enter_context(ecr_stubber) - sessions.append(_session(region_name=region, clients={"ecr": ecr_client})) + regional_clients[region] = ecr_client + intermediate = _authenticated_session() + intermediate.client.side_effect = lambda service, *, region_name: ( + regional_clients[region_name] if service == "ecr" else None + ) + source_config = _sessions._read_ini(tmp_path / "config") arguments = [ "mfa", @@ -470,7 +445,15 @@ def test_ecr_regions_and_container_commands_are_ordered_and_exact( with ( stack, - patch("boto3.Session", side_effect=sessions), + patch( + "hacksaws._sessions._persistent_source", + return_value=(raw, source_config), + ), + patch( + "hacksaws._sessions._identity", + return_value=(ACCOUNT_ID, "aws", _identity_response()["Arn"]), + ), + patch("hacksaws._sessions._mfa_session", return_value=intermediate), patch("subprocess.run") as subprocess_run, ): result = hacksaws.console_main(arguments) @@ -479,10 +462,6 @@ def test_ecr_regions_and_container_commands_are_ordered_and_exact( f"{ACCOUNT_ID}.dkr.ecr.{region}.amazonaws.com" for region in ordered_regions ] expected_calls = [ - *[ - call([engine, "logout", registry], input=None, check=False) - for registry in registries - ], *[ call( [ @@ -582,13 +561,17 @@ def test_known_configuration_failure_is_concise( "123456", "--directory", str(tmp_path), + "--region", + "us-west-2", ], ) captured = capsys.readouterr() assert result.code == "OPERATIONAL_ERROR" assert result.exit_code == 1 - assert captured.err.startswith("Error: AWS config file does not exist:") + assert captured.err.startswith( + f"Error: Profile '{PROFILE}' does not define mfa_serial." + ) assert "Traceback" not in captured.err diff --git a/hacksaws/tests/test_history.py b/hacksaws/tests/test_history.py index a5c5124..bee0cbc 100644 --- a/hacksaws/tests/test_history.py +++ b/hacksaws/tests/test_history.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import cast +from unittest.mock import MagicMock import pytest @@ -487,6 +488,21 @@ def unavailable() -> sqlite3.Connection: assert payload["code"] == "CONFIG_OPTIONS" +def test_partial_database_initialization_always_closes_connection( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Close a sqlite handle when an initialization PRAGMA fails.""" + _isolate(monkeypatch, tmp_path) + connection = MagicMock() + connection.execute.side_effect = sqlite3.DatabaseError("corrupt database") + monkeypatch.setattr(sqlite3, "connect", MagicMock(return_value=connection)) + + with pytest.raises(sqlite3.DatabaseError, match="corrupt database"): + _history._connect() + + connection.close.assert_called_once_with() + + def test_unexpected_cli_exception_is_finalized_as_crashed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/hacksaws/tests/test_iam_cli_scaffold.py b/hacksaws/tests/test_iam_cli_scaffold.py index eb14234..d400496 100644 --- a/hacksaws/tests/test_iam_cli_scaffold.py +++ b/hacksaws/tests/test_iam_cli_scaffold.py @@ -138,6 +138,94 @@ def __init__(self) -> None: ) +def test_context_canonicalizes_alias_before_every_aws_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _account_config(tmp_path, monkeypatch) + data = _state.load_config() + data["aws"]["region_aliases"] = { + "pacific": {"region": "us-west-2", "description": "deployment region"} + } + _state.save_config(data) + calls: list[dict[str, object]] = [] + + def factory(**kwargs: object) -> _Session: + calls.append(kwargs) + return _Session() + + context = _iam_cli.IamCommandContext.create( + argparse.Namespace( + profile="deploy", + location="default", + directory=str(tmp_path / "aws"), + target=None, + account="Prod", + region="pacific", + allow_unknown_region=False, + ), + session_factory=factory, + ) + + assert context.region_name == "us-west-2" + assert context.region_resolution.source == "custom" + assert calls[0] == {"profile_name": "deploy", "region_name": "us-west-2"} + assert calls[1]["region_name"] == "us-west-2" + + +def test_context_verifies_region_partition_against_authenticated_caller( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + _state.save_config(_state.default_config()) + + with pytest.raises( + _configs.OperationalError, match="authenticated caller partition" + ): + _iam_cli.IamCommandContext.create( + argparse.Namespace( + profile="deploy", + location="default", + directory=str(tmp_path / "aws"), + target=None, + account=None, + region="beijing", + allow_unknown_region=False, + ), + session_factory=lambda **_kwargs: _Session(), + ) + + +def test_context_unknown_escape_never_accepts_alias_like_input( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + _state.save_config(_state.default_config()) + args = argparse.Namespace( + profile="deploy", + location="default", + directory=str(tmp_path / "aws"), + target=None, + account=None, + region="future-west", + allow_unknown_region=True, + ) + with pytest.raises(_configs.OperationalError, match="Unknown AWS region or alias"): + _iam_cli.IamCommandContext.create( + args, + session_factory=lambda **_kwargs: pytest.fail( + "invalid alias must fail before AWS session creation" + ), + ) + + args.region = "us-future-1" + context = _iam_cli.IamCommandContext.create( + args, + session_factory=lambda **_kwargs: _Session(), + ) + assert context.region_name == "us-future-1" + assert context.region_resolution.known is False + + def test_iam_remote_alias_help_json_error_and_recovery( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: @@ -184,11 +272,13 @@ def test_terminal_iam_selectors_cleanup_aliases_and_no_abbreviations() -> None: "admin", "--location", "horizon", + "--allow-unknown-region", "--dry-run", ] ) assert policy.profile == "admin" assert policy.location == "horizon" + assert policy.allow_unknown_region is True assert policy.dry_run is True for prefix in (["cleanup"], ["iam", "cleanup"], ["remote", "cleanup"]): diff --git a/hacksaws/tests/test_iam_policy_cli.py b/hacksaws/tests/test_iam_policy_cli.py index b081ede..2966bd0 100644 --- a/hacksaws/tests/test_iam_policy_cli.py +++ b/hacksaws/tests/test_iam_policy_cli.py @@ -113,6 +113,16 @@ def context() -> SimpleNamespace: ) +def test_policy_console_link_uses_verified_partition_and_region() -> None: + ctx = SimpleNamespace(partition="aws-cn", region_name="cn-north-1") + url = cli._console_url( + ctx, + f"arn:aws-cn:iam::{ACCOUNT}:policy/hacksaws/AgentRead", + ) + assert url.startswith("https://cn-north-1.console.amazonaws.cn/") + assert "region=cn-north-1" in url + + def service() -> Mock: value = Mock() value.account_id = ACCOUNT diff --git a/hacksaws/tests/test_iam_role_cli.py b/hacksaws/tests/test_iam_role_cli.py index dae8cec..f90db2b 100644 --- a/hacksaws/tests/test_iam_role_cli.py +++ b/hacksaws/tests/test_iam_role_cli.py @@ -786,6 +786,16 @@ def test_caller_trust_shapes_and_policy_resolution_errors(harness: Any) -> None: ) assert cli._caller_trust(denied, ctx) is None assert cli._resolve_policy_arn("arn:aws:iam::aws:policy/X", ctx).endswith("/X") + with pytest.raises(OperationalError, match="partition"): + cli._resolve_policy_arn("arn:aws-cn:iam::aws:policy/X", ctx) + with pytest.raises(OperationalError, match="authenticated caller account"): + cli._resolve_policy_arn("arn:aws:iam::999999999999:policy/X", ctx) + sensitive_path = "C:/private/agent-policy.json" + with pytest.raises( + OperationalError, match="Invalid IAM managed-policy ARN" + ) as caught: + cli._resolve_policy_arn(f"arn:aws:s3:::{sensitive_path}", ctx) + assert sensitive_path not in str(caught.value) empty = FakeIam() empty.policy_pages = Paginator([{"Policies": []}]) with pytest.raises(OperationalError, match="not found"): @@ -805,6 +815,27 @@ def test_caller_trust_shapes_and_policy_resolution_errors(harness: Any) -> None: cli._resolve_policy_arn("Read", context(iam=ambiguous)) +@pytest.mark.parametrize( + ("partition", "region", "domain"), + [ + ("aws", "us-west-2", "us-west-2.console.aws.amazon.com"), + ("aws-cn", "cn-north-1", "cn-north-1.console.amazonaws.cn"), + ( + "aws-us-gov", + "us-gov-west-1", + "us-gov-west-1.console.amazonaws-us-gov.com", + ), + ], +) +def test_console_links_use_verified_partition_and_canonical_region( + partition: str, region: str, domain: str +) -> None: + ctx = SimpleNamespace(partition=partition, region_name=region) + url = cli._console_url(ctx, "Agent") + assert url.startswith(f"https://{domain}/") + assert f"region={region}" in url + + def test_principal_resolution_forms_and_user_failure( configured: dict[str, Any], ) -> None: diff --git a/hacksaws/tests/test_output_foundation.py b/hacksaws/tests/test_output_foundation.py index 636160b..7fc0661 100644 --- a/hacksaws/tests/test_output_foundation.py +++ b/hacksaws/tests/test_output_foundation.py @@ -248,11 +248,11 @@ def test_status_text_golden_is_compact_and_self_explaining() -> None: } ) assert rendered == ( - "PROFILE STATE AUTH ACCOUNT SCOPE" + "PROFILE REGION STATE AUTH ACCOUNT SCOPE" " TTL\n" - "------- ----- -------- ------------ " + "------- ------ ----- -------- ------------ " "--------------------------------------------- ---\n" - "debug 🟢 web→role 123456789012 " + "debug 🟢 web→role 123456789012 " "TerraformUnlimited → CloudWatchReadOnlyAccess 60m\n" "\n" "State: 1 🟢active" diff --git a/hacksaws/tests/test_regions.py b/hacksaws/tests/test_regions.py new file mode 100644 index 0000000..4970eaf --- /dev/null +++ b/hacksaws/tests/test_regions.py @@ -0,0 +1,667 @@ +"""Offline coverage for canonical AWS region discovery and configuration.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hacksaws import _cli +from hacksaws import _regions +from hacksaws import _sessions +from hacksaws import _state + + +@pytest.fixture +def state_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Keep region configuration tests outside the real user state root.""" + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) + monkeypatch.delenv("AWS_REGION", raising=False) + monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) + return tmp_path + + +def test_registry_is_deterministic_and_operational_by_default() -> None: + operational = _regions.region_registry() + all_regions = _regions.region_registry(all_partitions=True) + + assert operational == tuple(sorted(operational, key=lambda item: item.name)) + assert {item.partition for item in operational} <= _regions.OPERATIONAL_PARTITIONS + assert set(operational) <= set(all_regions) + oregon = next(item for item in operational if item.name == "us-west-2") + assert oregon.compact_alias == "usw2" + assert "oregon" in oregon.geography_aliases + + +@pytest.mark.parametrize( + ("value", "source"), + [ + ("us-west-2", "canonical"), + ("usw2", "compact"), + ("Oregon", "geography"), + ("Pacific", "custom"), + ], +) +def test_resolution_accepts_all_supported_input_forms(value: str, source: str) -> None: + result = _regions.resolve_region( + value, custom_aliases={"pacific": {"region": "us-west-2"}} + ) + + assert result.canonical == "us-west-2" + assert result.partition == "aws" + assert result.source == source + + +def test_unknown_escape_requires_canonical_shape_and_partition() -> None: + with pytest.raises(_regions.RegionError) as alias_error: + _regions.resolve_region("future-west", allow_unknown=True) + assert alias_error.value.code == "REGION_INVALID" + + accepted = _regions.resolve_region("us-future-1", allow_unknown=True) + assert accepted.known is False + assert accepted.partition == "aws" + assert accepted.warning + + with pytest.raises(_regions.RegionError) as mismatch: + _regions.resolve_region("cn-future-1", partition="aws", allow_unknown=True) + assert mismatch.value.code == "REGION_PARTITION_MISMATCH" + gov = _regions.resolve_region("us-gov-future-1", allow_unknown=True) + assert gov.partition == "aws-us-gov" + + with pytest.raises(_regions.RegionError) as unknown_partition: + _regions.resolve_region("zz-future-1", allow_unknown=True) + assert unknown_partition.value.code == "REGION_PARTITION_UNKNOWN" + + with pytest.raises(_regions.RegionError) as nonoperational_partition: + _regions.resolve_region("us-iso-future-1", allow_unknown=True) + assert nonoperational_partition.value.code == "REGION_PARTITION_UNSUPPORTED" + + +def test_compact_helper_and_noninteractive_invalid_branch() -> None: + assert _regions._compact_candidate("invalid") == "" + with pytest.raises(_regions.RegionError) as invalid: + _regions.resolve_region_input("bad-alias", interactive=False) + assert invalid.value.code == "REGION_INVALID" + + +@pytest.mark.parametrize( + "aliases", + [ + {"oregon": {"region": "us-east-1"}}, + {"Pacific Coast": {"region": "us-west-2"}}, + {"pacific": {"region": "oregon"}}, + {"pacific": {"region": "us-west-2", "extra": True}}, + ], +) +def test_custom_aliases_cannot_shadow_chain_or_use_invalid_shape( + aliases: dict[str, object], +) -> None: + with pytest.raises(_regions.RegionError): + _regions.validate_custom_aliases(aliases) + + +def test_alias_helpers_cover_legacy_input_and_normalized_collisions() -> None: + assert _regions.custom_alias_map({"pacific": "US-WEST-2"}) == { + "pacific": ("us-west-2", None) + } + assert _regions.builtin_aliases()["oregon"] == ("us-west-2",) + with pytest.raises(_regions.RegionError, match="unique normalized names"): + _regions.validate_custom_aliases( + { + "west coast": {"region": "us-west-2"}, + "west-coast": {"region": "us-west-2"}, + } + ) + with pytest.raises(_regions.RegionError, match="description must be text"): + _regions.validate_custom_aliases( + {"pacific": {"region": "us-west-2", "description": 1}} + ) + + +def test_resolution_rejects_blank_missing_custom_target_and_ambiguity() -> None: + with pytest.raises(_regions.RegionError, match="cannot be blank"): + _regions.resolve_region(" ") + with pytest.raises(_regions.RegionError, match="references unavailable"): + _regions.resolve_region( + "future", custom_aliases={"future": {"region": "us-future-1"}} + ) + + west = _regions.resolve_region("us-west-2") + east = _regions.resolve_region("us-east-1") + west_info = next( + item for item in _regions.region_registry() if item.name == west.canonical + ) + east_info = next( + item for item in _regions.region_registry() if item.name == east.canonical + ) + registry = ( + {west.canonical: west_info, east.canonical: east_info}, + {"ambiguous": [(west_info, "geography"), (east_info, "geography")]}, + ) + with ( + patch("hacksaws._regions._registry_maps", return_value=registry), + pytest.raises(_regions.RegionError) as ambiguous, + ): + _regions.resolve_region("ambiguous") + assert ambiguous.value.code == "REGION_AMBIGUOUS" + + +def test_nonoperational_partition_is_discovery_only() -> None: + unsupported = next( + ( + item + for item in _regions.region_registry(all_partitions=True) + if not item.operational + ), + None, + ) + if unsupported is None: + pytest.skip("Bundled Botocore metadata has no non-operational partition") + with pytest.raises(_regions.RegionError) as caught: + _regions.resolve_region(unsupported.name) + assert caught.value.code == "REGION_PARTITION_UNSUPPORTED" + assert ( + _regions.resolve_region(unsupported.name, allow_non_operational=True).canonical + == unsupported.name + ) + with pytest.raises(_regions.RegionError) as alias_error: + _regions.validate_custom_aliases({"isolated": {"region": unsupported.name}}) + assert alias_error.value.code == "REGION_PARTITION_UNSUPPORTED" + + +def test_interactive_repair_lists_and_retries() -> None: + answers = iter(["?", "oregon"]) + output: list[str] = [] + + result = _regions.resolve_region_input( + "not-a-region", + interactive=True, + input_fn=lambda _prompt: next(answers), + output_fn=lambda value: output.append(str(value)), + ) + + assert result.canonical == "us-west-2" + assert any("Available regions" in line for line in output) + + +def test_interactive_cancel_eof_attempt_limit_and_default() -> None: + defaulted = _regions.resolve_region_input(None, interactive=True, default="oregon") + assert defaulted.canonical == "us-west-2" + with pytest.raises(_regions.RegionError) as quit_error: + _regions.resolve_region_input( + None, interactive=True, input_fn=lambda _prompt: "quit" + ) + assert quit_error.value.code == "REGION_CANCELLED" + with pytest.raises(_regions.RegionError) as eof_error: + _regions.resolve_region_input( + None, + interactive=True, + input_fn=lambda _prompt: (_ for _ in ()).throw(EOFError), + ) + assert eof_error.value.code == "REGION_CANCELLED" + with pytest.raises(_regions.RegionError) as attempts_error: + _regions.resolve_region_input( + "bad", + interactive=True, + max_attempts=1, + input_fn=lambda _prompt: "still-bad", + output_fn=lambda _value: None, + ) + assert attempts_error.value.code == "REGION_ATTEMPTS_EXCEEDED" + + +def test_noninteractive_error_is_structured() -> None: + with pytest.raises(_regions.RegionError) as caught: + _regions.resolve_region_input(None, interactive=False) + + assert caught.value.code == "REGION_REQUIRED" + assert caught.value.data == {"code": "REGION_REQUIRED", "candidates": []} + assert caught.value.repairs + + +def test_preference_order_and_environment_persistence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_REGION", "oregon") + monkeypatch.setenv("AWS_DEFAULT_REGION", "virginia") + + explicit = _regions.resolve_region_preference( + explicit="ohio", + target="us-east-1", + destination="us-west-1", + ) + environment = _regions.resolve_region_preference(destination="us-east-1") + environment_without_destination = _regions.resolve_region_preference() + + assert (explicit.canonical, explicit.source) == ("us-east-2", "explicit") + assert explicit.persist_to_destination is True + assert (environment.canonical, environment.source) == ( + "us-west-2", + "aws-region-env", + ) + assert environment.persist_to_destination is False + assert environment_without_destination.persist_to_destination is True + + +def test_preference_falls_through_to_prompt() -> None: + with patch( + "hacksaws._regions.resolve_region_input", + return_value=_regions.resolve_region("oregon"), + ) as prompt: + preference = _regions.resolve_region_preference(interactive=True) + assert preference.source == "prompt" + assert preference.canonical == "us-west-2" + prompt.assert_called_once() + + +def test_service_validation_and_canonical_deduplication() -> None: + values = _regions.canonicalize_regions( + ["oregon", "usw2", "virginia"], service="ecr" + ) + + assert tuple(item.canonical for item in values) == ("us-west-2", "us-east-1") + with pytest.raises(_regions.RegionError) as unsupported: + _regions.validate_service_region( + _regions.resolve_region("ap-southeast-7"), "iam" + ) + assert unsupported.value.code == "REGION_SERVICE_UNAVAILABLE" + unknown = _regions.resolve_region("us-future-1", allow_unknown=True) + assert ( + _regions.validate_service_region(unknown, "ecr", allow_unknown=True) is unknown + ) + with pytest.raises(_regions.RegionError, match="Cannot verify"): + _regions.validate_service_region(unknown, "ecr") + assert ( + _regions.validate_service_region( + _regions.resolve_region("us-east-1"), "signin" + ).canonical + == "us-east-1" + ) + + +def test_schema_persists_canonical_regions_and_alias_metadata( + state_home: Path, +) -> None: + data = _state.default_config() + data["aws"] = { + "region": "us-east-1", + "region_aliases": { + "pacific": {"region": "us-west-2", "description": "West coast"} + }, + } + data["accounts"]["Prod"] = { + "id": "123456789012", + "partition": "aws", + "region": "us-east-2", + } + data["targets"]["Agent"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_location": "default", + "region": "us-west-2", + } + + _state.save_config(data) + loaded = _state.load_config() + + assert loaded["aws"]["region"] == "us-east-1" + assert loaded["accounts"]["Prod"]["region"] == "us-east-2" + assert loaded["targets"]["Agent"]["region"] == "us-west-2" + assert (state_home / "config.json").exists() + + +def test_config_export_import_preserves_canonical_region_configuration( + state_home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + data = _state.default_config() + data["aws"] = { + "region": "us-east-1", + "region_aliases": {"pacific": {"region": "us-west-2"}}, + } + data["accounts"]["Prod"] = { + "id": "123456789012", + "partition": "aws", + "region": "us-east-2", + } + data["targets"]["Agent"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_location": "default", + "region": "us-west-2", + } + _state.save_config(data) + archive = _sessions.export_config(str(tmp_path / "portable.zip")) + + imported_home = tmp_path / "imported" + monkeypatch.setenv("HACKSAWS_HOME", str(imported_home)) + local = _state.default_config() + local["aws"]["region"] = "us-west-1" + _state.save_config(local) + with pytest.raises(Exception, match="config:aws"): + _sessions.import_config(archive, replace=False, yes=True) + assert _state.load_config()["aws"]["region"] == "us-west-1" + + _sessions.import_config(archive, replace=True, yes=True) + + imported = _state.load_config() + assert imported["aws"] == data["aws"] + assert imported["accounts"]["Prod"]["region"] == "us-east-2" + assert imported["targets"]["Agent"]["region"] == "us-west-2" + + +def test_schema_rejects_aliases_in_canonical_fields() -> None: + data = _state.default_config() + data["aws"]["region"] = "oregon" + + with pytest.raises(Exception, match="must store canonical region"): + _state.save_config(data) + + data["aws"]["region"] = "US-WEST-2" + with pytest.raises(Exception, match="must store canonical region"): + _state.save_config(data) + + +def test_region_alias_cli_crud_and_explain( + state_home: Path, capsys: pytest.CaptureFixture[str] +) -> None: + added = _cli.console_main( + ["region", "alias", "add", "pacific", "oregon", "--description", "West"] + ) + capsys.readouterr() + explained = _cli.console_main(["region", "explain", "pacific", "--json"]) + rendered = json.loads(capsys.readouterr().out) + + assert added.code == "REGION_ALIAS_SAVED" + assert explained.code == "REGION_EXPLAIN" + assert rendered["data"]["region"] == "us-west-2" + assert _state.load_config()["aws"]["region_aliases"]["pacific"] == { + "region": "us-west-2", + "description": "West", + } + assert ( + _cli.console_main( + ["region", "alias", "rename", "pacific", "west-coast"] + ).exit_code + == 0 + ) + assert _cli.console_main(["region", "alias", "remove", "west-coast"]).exit_code == 0 + + +def test_region_alias_cli_list_get_update_and_conflicts( + state_home: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + _cli.console_main( + ["region", "alias", "add", "pacific", "oregon", "--description", "West"] + ).exit_code + == 0 + ) + capsys.readouterr() + + listed = _cli.console_main(["region", "alias", "list", "pac*", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert listed.code == "REGION_ALIAS_LIST" + assert payload["data"][0]["alias"] == "pacific" + + fetched = _cli.console_main(["region", "alias", "get", "PACIFIC", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert fetched.code == "REGION_ALIAS_GET" + assert payload["data"]["description"] == "West" + + assert ( + _cli.console_main( + ["region", "alias", "update", "pacific", "--region", "ohio"] + ).exit_code + == 0 + ) + assert _state.load_config()["aws"]["region_aliases"]["pacific"] == { + "region": "us-east-2", + "description": "West", + } + assert ( + _cli.console_main( + ["region", "alias", "update", "pacific", "--clear-description"] + ).exit_code + == 0 + ) + assert _state.load_config()["aws"]["region_aliases"]["pacific"] == { + "region": "us-east-2" + } + + assert ( + _cli.console_main(["region", "alias", "add", "atlantic", "virginia"]).exit_code + == 0 + ) + conflict = _cli.console_main(["region", "alias", "rename", "pacific", "atlantic"]) + invalid = _cli.console_main(["region", "alias", "add", "Bad Alias", "ohio"]) + missing = _cli.console_main(["region", "alias", "get", "missing"]) + assert conflict.code == "REGION_ALIAS_CONFLICT" + assert invalid.code == "REGION_ALIAS_INVALID" + assert missing.code == "REGION_ALIAS_NOT_FOUND" + assert _cli.console_main(["region", "alias", "list", "atlantic"]).exit_code == 0 + duplicate = _cli.console_main(["region", "alias", "add", "atlantic", "us-east-2"]) + invalid_rename = _cli.console_main( + ["region", "alias", "rename", "atlantic", "Bad Alias"] + ) + assert duplicate.code == "REGION_ALIAS_CONFLICT" + assert invalid_rename.code == "REGION_ALIAS_INVALID" + + +def test_region_list_scopes_filters_and_help( + state_home: Path, capsys: pytest.CaptureFixture[str] +) -> None: + data = _state.default_config() + data["accounts"]["Prod"] = { + "id": "123456789012", + "partition": "aws", + } + data["aws"]["region_aliases"]["pacific"] = {"region": "us-west-2"} + _state.save_config(data) + + listed = _cli.console_main( + ["region", "list", "pac*", "--account", "prod", "--json"] + ) + payload = json.loads(capsys.readouterr().out) + assert listed.code == "REGION_LIST" + assert payload["data"] == [ + { + "region": "us-west-2", + "name": "US West (Oregon)", + "partition": "aws", + "operational": True, + "compact": "usw2", + "geography": ["oregon"], + "custom": ["pacific"], + } + ] + unknown = _cli.console_main(["region", "list", "--partition", "not-a-partition"]) + assert unknown.code == "REGION_PARTITION_UNKNOWN" + assert _cli.console_main(["region"]).code == "REGION_HELP" + assert _cli.console_main(["region", "alias"]).code == "REGION_ALIAS_HELP" + assert _cli.console_main(["region", "list", "oregon"]).exit_code == 0 + + +def test_account_target_and_config_region_cli_store_canonical_values( + state_home: Path, +) -> None: + assert ( + _cli.console_main(["region", "alias", "add", "pacific", "oregon"]).exit_code + == 0 + ) + assert ( + _cli.console_main( + [ + "account", + "add", + "Prod", + "123456789012", + "--partition", + "aws", + "--no-verify", + "--region", + "pacific", + ] + ).exit_code + == 0 + ) + assert ( + _cli.console_main( + [ + "target", + "add", + "Agent", + "--source-account", + "Prod", + "--region", + "virginia", + ] + ).exit_code + == 0 + ) + assert _cli.console_main(["config", "set", "aws.region", "ohio"]).exit_code == 0 + + loaded = _state.load_config() + assert loaded["accounts"]["Prod"]["region"] == "us-west-2" + assert loaded["targets"]["Agent"]["region"] == "us-east-1" + assert loaded["aws"]["region"] == "us-east-2" + + assert ( + _cli.console_main( + ["account", "update", "Prod", "--region", "ohio", "--no-verify"] + ).exit_code + == 0 + ) + assert ( + _cli.console_main(["target", "update", "Agent", "--region", "oregon"]).exit_code + == 0 + ) + loaded = _state.load_config() + assert loaded["accounts"]["Prod"]["region"] == "us-east-2" + assert loaded["targets"]["Agent"]["region"] == "us-west-2" + + assert ( + _cli.console_main( + ["account", "update", "Prod", "--clear-region", "--no-verify"] + ).exit_code + == 0 + ) + assert ( + _cli.console_main(["target", "update", "Agent", "--clear-region"]).exit_code + == 0 + ) + loaded = _state.load_config() + assert "region" not in loaded["accounts"]["Prod"] + assert "region" not in loaded["targets"]["Agent"] + + +def test_config_option_alias_crud_uses_canonical_target(state_home: Path) -> None: + region_key = "aws.region_aliases.pacific.region" + description_key = "aws.region_aliases.pacific.description" + + assert _cli.console_main(["config", "set", region_key, "oregon"]).exit_code == 0 + assert ( + _cli.console_main( + ["config", "option", "set", description_key, "West"] + ).exit_code + == 0 + ) + assert _state.load_config()["aws"]["region_aliases"]["pacific"] == { + "region": "us-west-2", + "description": "West", + } + assert _cli.console_main(["config", "reset", description_key]).exit_code == 0 + assert _cli.console_main(["config", "reset", region_key]).exit_code == 0 + assert _state.load_config()["aws"]["region_aliases"] == {} + + +def test_direct_region_config_options_and_teaching_commands( + state_home: Path, +) -> None: + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} + data["targets"]["Agent"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_location": "default", + } + _state.save_config(data) + + assert ( + _cli.console_main(["config", "set", "accounts.Prod.region", "oregon"]).exit_code + == 0 + ) + assert ( + _cli.console_main(["config", "set", "targets.Agent.region", "ohio"]).exit_code + == 0 + ) + assert _cli.console_main(["config", "get", "aws.region"]).exit_code == 0 + assert _cli.console_main(["config", "option", "list"]).exit_code == 0 + assert ( + _cli.console_main(["config", "option", "explain", "aws.region"]).exit_code == 0 + ) + assert ( + _cli.console_main(["config", "option", "explain", "unknown.setting"]).exit_code + != 0 + ) + assert _cli.console_main(["config", "option"]).code == "CONFIG_OPTION_HELP" + assert _cli.console_main(["config", "set", "aws.region", "null"]).exit_code == 0 + + loaded = _state.load_config() + assert loaded["accounts"]["Prod"]["region"] == "us-west-2" + assert loaded["targets"]["Agent"]["region"] == "us-east-2" + assert loaded["aws"]["region"] is None + + +def test_unknown_config_region_requires_explicit_escape(state_home: Path) -> None: + refused = _cli.console_main(["config", "set", "aws.region", "us-future-1"]) + accepted = _cli.console_main( + [ + "config", + "set", + "aws.region", + "us-future-1", + "--allow-unknown-region", + ] + ) + + assert refused.code == "REGION_UNKNOWN" + assert accepted.exit_code == 0 + assert _state.load_config()["aws"]["region"] == "us-future-1" + + +def test_region_cli_reports_specific_error_code( + state_home: Path, capsys: pytest.CaptureFixture[str] +) -> None: + result = _cli.console_main(["region", "explain", "future-west", "--json"]) + rendered = json.loads(capsys.readouterr().err) + + assert result.code == "REGION_INVALID" + assert rendered["code"] == "REGION_INVALID" + assert rendered["error"]["data"]["code"] == "REGION_INVALID" + assert rendered["error"]["data"]["candidates"] + assert rendered["error"]["repairs"] + + +def test_region_parser_help_documents_unknown_escape() -> None: + parser = _cli._create_parser() + with patch("sys.stdout") as stdout, pytest.raises(SystemExit): + parser.parse_args(["region", "explain", "--help"]) + assert "--allow-unknown-region" in "".join( + str(call.args[0]) for call in stdout.write.call_args_list if call.args + ) + + with patch("sys.stdout") as stdout, pytest.raises(SystemExit): + parser.parse_args(["profile", "region", "set", "--help"]) + profile_help = "".join( + str(call.args[0]) for call in stdout.write.call_args_list if call.args + ) + assert "Canonical region or compact, geography, or custom alias" in " ".join( + profile_help.split() + ) + + with pytest.raises(SystemExit): + parser.parse_args( + ["target", "update", "Agent", "--region", "oregon", "--clear-region"] + ) diff --git a/hacksaws/tests/test_session_regions.py b/hacksaws/tests/test_session_regions.py new file mode 100644 index 0000000..1638b85 --- /dev/null +++ b/hacksaws/tests/test_session_regions.py @@ -0,0 +1,186 @@ +"""Focused coverage for profile-region session lifecycle behavior.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _sessions +from hacksaws import _state + + +def _configure_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "hacksaws-home")) + _state.save_config(_state.default_config()) + + +def _profile_args( + aws: Path, action: str, region: str | None = None +) -> argparse.Namespace: + arguments = ["profile", "region", action] + if region is not None: + arguments.append(region) + arguments.extend(("--directory", str(aws), "--profile", "debug")) + return _cli._create_parser().parse_args(arguments) + + +def _write_profile(aws: Path, region: str | None) -> None: + parser = _sessions._read_ini(aws / "config") + parser["profile debug"] = {"output": "json"} + if region: + parser["profile debug"]["region"] = region + _sessions._write_ini(aws / "config", parser) + + +def _managed_session( + aws: Path, *, auth_method: str = "assume-role" +) -> dict[str, object]: + config = aws / "config" + return { + "destination": str(aws.absolute()), + "profile": "debug", + "auth_method": auth_method, + "source_partition": "aws", + "section_backup": { + "config": { + "path": str(config.absolute()), + "section": "profile debug", + "original": { + "exists": True, + "values": {"output": "json", "region": "us-east-1"}, + }, + "installed": _sessions._section_state(config, "profile debug"), + } + }, + "ecr": [], + } + + +def test_profile_region_crud_and_same_region_noop( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configure_home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + _write_profile(aws, None) + + result = _cli._run_profile(_profile_args(aws, "set", "usw2")) + assert result.code == "PROFILE_REGION_SET" + assert isinstance(result.data, dict) + assert result.data["region"] == "us-west-2" + assert _sessions._profile_region(aws, "debug") == "us-west-2" + + with patch("hacksaws._sessions._begin") as begin: + unchanged = _sessions.profile_region_change( + _profile_args(aws, "set", "us-west-2") + ) + assert unchanged["changed"] is False + begin.assert_not_called() + + shown = _cli._run_profile(_profile_args(aws, "get")) + assert isinstance(shown.data, dict) + assert shown.data["region"] == "us-west-2" + cleared = _cli._run_profile(_profile_args(aws, "clear")) + assert cleared.code == "PROFILE_REGION_CLEAR" + assert isinstance(cleared.data, dict) + assert cleared.data["region"] is None + cleared_again = _sessions.profile_region_change( + _profile_args(aws, "clear"), clear=True + ) + assert cleared_again["changed"] is False + + +def test_profile_region_rejects_cross_partition_change( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configure_home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + _write_profile(aws, "us-gov-west-1") + + with pytest.raises(_configs.OperationalError, match="partition"): + _sessions.profile_region_change(_profile_args(aws, "set", "us-east-1")) + assert _sessions._profile_region(aws, "debug") == "us-gov-west-1" + + +def test_managed_profile_region_rebases_logout_restore_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configure_home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + _write_profile(aws, "us-east-1") + key = f"{aws.absolute()}::debug" + _state.save_sessions({key: _managed_session(aws)}) # type: ignore[dict-item] + + changed = _sessions.profile_region_change(_profile_args(aws, "set", "us-west-2")) + assert changed["changed"] is True + saved = _state.load_sessions()[key] + assert saved["region"] == "us-west-2" + assert ( + saved["section_backup"]["config"]["original"]["values"]["region"] == "us-west-2" + ) + + cleared = _sessions.profile_region_change(_profile_args(aws, "clear"), clear=True) + assert cleared["region"] is None + cleared_session = _state.load_sessions()[key] + assert "region" not in cleared_session + assert ( + "region" + not in cleared_session["section_backup"]["config"]["original"]["values"] + ) + _sessions.profile_region_change(_profile_args(aws, "set", "us-west-2")) + + logout_args = argparse.Namespace( + target=None, + directory=str(aws), + profile="debug", + aws_account_name=None, + to=None, + to_directory=None, + to_profile="default", + force=False, + keep_ecr=False, + except_profiles=[], + ) + assert _sessions.logout(_configs.Context(logout_args)) is True + assert _sessions._profile_region(aws, "debug") == "us-west-2" + assert key not in _state.load_sessions() + + +def test_profile_region_transaction_rolls_back_config_and_session_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configure_home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + _write_profile(aws, "us-east-1") + key = f"{aws.absolute()}::debug" + _state.save_sessions({key: _managed_session(aws)}) # type: ignore[dict-item] + original_config = (aws / "config").read_bytes() + original_sessions = _state.sessions_path().read_bytes() + + with ( + patch("hacksaws._sessions._state.save_sessions", side_effect=OSError("write")), + pytest.raises(OSError, match="write"), + ): + _sessions.profile_region_change(_profile_args(aws, "set", "us-west-2")) + + assert (aws / "config").read_bytes() == original_config + assert _state.sessions_path().read_bytes() == original_sessions + + +def test_browser_native_region_clear_requires_logout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _configure_home(tmp_path, monkeypatch) + aws = tmp_path / "aws" + _write_profile(aws, "us-east-1") + key = f"{aws.absolute()}::debug" + session = _managed_session(aws, auth_method="browser-native") + _state.save_sessions({key: session}) # type: ignore[dict-item] + + with pytest.raises(_configs.OperationalError, match="log out before clearing"): + _sessions.profile_region_change(_profile_args(aws, "clear"), clear=True) + assert _sessions._profile_region(aws, "debug") == "us-east-1" diff --git a/hacksaws/tests/test_sessions_coverage.py b/hacksaws/tests/test_sessions_coverage.py index b456144..e2b66c7 100644 --- a/hacksaws/tests/test_sessions_coverage.py +++ b/hacksaws/tests/test_sessions_coverage.py @@ -82,6 +82,7 @@ def _configured( root = _home(tmp_path, monkeypatch) aws = tmp_path / "aws" data = _state.default_config() + data["aws"]["region"] = "us-west-2" data["accounts"]["Prod"] = {"id": ACCOUNT, "partition": "aws"} if boundary: data["boundaries"]["Guard"] = { @@ -370,15 +371,31 @@ def test_target_identity_and_role_account_partition_checks( assert boundary == "Guard" data = _state.load_config() - data["accounts"]["Other"] = {"id": OTHER_ACCOUNT, "partition": "aws-cn"} + data["accounts"]["Other"] = {"id": OTHER_ACCOUNT, "partition": "aws"} _state.save_config(data) role, *_ = _sessions._role_details( _args(account="Other", role="Worker"), {}, ACCOUNT, "aws" ) - assert role == f"arn:aws-cn:iam::{OTHER_ACCOUNT}:role/Worker" + assert role == f"arn:aws:iam::{OTHER_ACCOUNT}:role/Worker" + + data["accounts"]["Other"]["partition"] = "aws-cn" + _state.save_config(data) + partition_error = "authenticated caller partition" + with pytest.raises(_configs.OperationalError, match=partition_error): + _sessions._role_details( + _args(account="Other", role="Worker"), {}, ACCOUNT, "aws" + ) with pytest.raises(_configs.OperationalError, match="conflicts with --account"): _sessions._role_details(_args(account="Other", role=ROLE), {}, ACCOUNT, "aws") + with pytest.raises(_configs.OperationalError, match=partition_error): + _sessions._role_details( + _args(role=f"arn:aws-cn:iam::{OTHER_ACCOUNT}:role/Worker"), + {}, + ACCOUNT, + "aws", + ) + target["boundary_data"]["role_arn"] = f"arn:aws:iam::{OTHER_ACCOUNT}:role/Guard" target["boundary_data"]["account"] = "Prod" with pytest.raises(_configs.OperationalError, match="Boundary role account"): @@ -401,6 +418,13 @@ def test_role_validation_rejects_invalid_or_missing_operands( _sessions._require_concrete_role(_args(), None) +def test_invalid_role_error_does_not_echo_pathlike_input() -> None: + sentinel = r"C:\sensitive\agent-boundary.json" + with pytest.raises(_configs.OperationalError) as raised: + _sessions._role_details(_args(role=f"arn:{sentinel}"), {}, ACCOUNT, "aws") + assert sentinel not in str(raised.value) + + def test_configured_role_and_session_name_resolution( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -689,8 +713,18 @@ def test_aws_login_passes_remote_and_wraps_subprocess_errors(tmp_path: Path) -> "dev", remote=True, login_cache=login_cache, + region_name="us-west-2", ) - assert run.call_args.args[0] == ["aws", "login", "--profile", "dev", "--remote"] + assert run.call_args.args[0] == [ + "aws", + "login", + "--profile", + "dev", + "--region", + "us-west-2", + "--no-cli-auto-prompt", + "--remote", + ] assert run.call_args.kwargs["env"]["AWS_CONFIG_FILE"] == str(config) assert run.call_args.kwargs["env"]["AWS_LOGIN_CACHE_DIRECTORY"] == str(login_cache) with ( @@ -707,6 +741,7 @@ def test_aws_login_passes_remote_and_wraps_subprocess_errors(tmp_path: Path) -> "dev", remote=False, login_cache=login_cache, + region_name="us-west-2", ) @@ -747,8 +782,10 @@ def login( *, remote: bool, login_cache: Path, + region_name: str, ) -> None: del remote + assert region_name == "us-west-2" assert ( _write_browser_login(config, login_cache, profile).absolute() == new_cache.absolute() @@ -802,8 +839,10 @@ def login( *, remote: bool, login_cache: Path, + region_name: str, ) -> None: del credentials, remote + assert region_name == "us-east-1" assert profile == "debug" assert login_cache == cache.absolute() config.parent.mkdir(parents=True, exist_ok=True) @@ -812,7 +851,7 @@ def login( == new_cache.absolute() ) - args = _args(directory=str(aws), profile="debug") + args = _args(directory=str(aws), profile="debug", region="us-east-1") with ( patch("hacksaws._sessions._aws_login", side_effect=login), patch("hacksaws._sessions.boto3.Session", return_value=native), @@ -857,15 +896,17 @@ def login( *, remote: bool, login_cache: Path, + region_name: str, ) -> None: del credentials, profile, remote + assert region_name == "us-east-1" assert login_cache == cache.absolute() assert ( _write_browser_login(config_path, login_cache, "debug").absolute() == new_cache.absolute() ) - args = _args(directory=str(aws), profile="debug") + args = _args(directory=str(aws), profile="debug", region="us-east-1") with ( patch("hacksaws._sessions._aws_login", side_effect=login), patch("hacksaws._sessions.boto3.Session", return_value=MagicMock()), @@ -951,8 +992,10 @@ def login( *, remote: bool, login_cache: Path, + region_name: str, ) -> None: del credentials, remote + assert region_name == "us-west-2" assert inherited_cache not in login_cache.parents _write_browser_login(config, login_cache, profile) diff --git a/hacksaws/tests/test_v04.py b/hacksaws/tests/test_v04.py index 140d37d..ff1948d 100644 --- a/hacksaws/tests/test_v04.py +++ b/hacksaws/tests/test_v04.py @@ -17,6 +17,7 @@ from hacksaws import _duration from hacksaws import _ecr from hacksaws import _policies +from hacksaws import _regions from hacksaws import _sessions from hacksaws import _state @@ -49,6 +50,7 @@ def test_parser_supports_target_shorthand_and_web_alias() -> None: def _minimal_target(home: Path, *, boundary: bool = False) -> None: data = _state.default_config() + data["aws"]["region"] = "us-east-1" data["accounts"]["Prod"] = {"id": "123456789012", "partition": "aws"} if boundary: data["boundaries"]["Guard"] = { @@ -290,8 +292,10 @@ def fake_login( *, remote: bool, login_cache: Path, + region_name: str, ) -> None: del remote + assert region_name == "us-east-1" assert _browser_login_files(config, login_cache, profile) == cache_file namespace = _cli._create_parser().parse_args(["web", "in", "+Prod"]) @@ -593,6 +597,8 @@ def test_expanded_mfa_write_failure_restores_existing_destination( str(aws_dir), "--role", "arn:aws:iam::123456789012:role/read", + "--region", + "us-east-1", ] ) _cli._validate_login(namespace) @@ -652,8 +658,10 @@ def browser_login( *, remote: bool, login_cache: Path, + region_name: str, ) -> None: del remote + assert region_name == "us-east-1" _browser_login_files(config, login_cache, profile) with ( @@ -918,6 +926,61 @@ def test_ecr_registry_dns_suffix_is_partition_aware( assert account.ecr_registries[0].endswith(suffix) +def test_ecr_regions_use_effective_primary_alias_and_ordered_canonical_dedupe() -> None: + context = _configs.Context( + argparse.Namespace( + region="pacific", + allow_unknown_region=False, + ) + ) + account = _configs.AwsAccount( + { + "Account": "123456789012", + "Arn": "arn:aws:iam::123456789012:user/test", + }, + "us-east-1", + ("oregon", "virginia", "us-east-1"), + ) + aliases = {"pacific": {"region": "us-west-2"}} + + with ( + patch("hacksaws._ecr._configured_region_aliases", return_value=aliases), + patch( + "hacksaws._ecr._regions.canonicalize_regions", + wraps=_regions.canonicalize_regions, + ) as canonicalize, + ): + assert _ecr._ecr_regions(context, account) == ("us-west-2", "us-east-1") + + assert canonicalize.call_args.kwargs["partition"] == "aws" + assert canonicalize.call_args.kwargs["service"] == "ecr" + + +def test_ecr_region_partition_and_unknown_escape_are_strict( + capsys: pytest.CaptureFixture[str], +) -> None: + account = _configs.AwsAccount( + { + "Account": "123456789012", + "Arn": "arn:aws:iam::123456789012:user/test", + }, + "us-east-1", + (), + ) + args = argparse.Namespace(region="future-west", allow_unknown_region=True) + context = _configs.Context(args) + with pytest.raises(_regions.RegionError, match="Unknown AWS region or alias"): + _ecr._ecr_regions(context, account) + + args.region = "us-future-1" + assert _ecr._ecr_regions(context, account) == ("us-future-1",) + assert "service support cannot be verified" in capsys.readouterr().err + + args.region = "beijing" + with pytest.raises(_regions.RegionError, match="Unknown AWS region or alias"): + _ecr._ecr_regions(context, account) + + def test_direct_policy_requires_role(capsys: pytest.CaptureFixture[str]) -> None: result = _cli.console_main(["mfa", "in", "dev", "123456", "--policy", "x"]) assert result.exit_code == 1 From f47e61d6b2ebd18df0d4b36b9e3de655d26b353b Mon Sep 17 00:00:00 2001 From: Scott Ernst Date: Mon, 3 Aug 2026 09:29:58 -0500 Subject: [PATCH 8/8] Save Reusable AWS Sessions - **Reusable Sessions** - Save successful MFA, browser, and role assumption workflows as account-scoped targets so agents can repeat least-privilege access without rebuilding configuration manually. - **Account Discovery** - Derive stable account identities and useful display metadata from authenticated sessions so account boundaries stay portable and deterministic when optional discovery is denied. - **Recovery And Privacy** - Preserve valid credentials after a failed configuration save, support managed-session recovery, and record only bounded structural history so diagnostics cannot become a secret store. - **Guided Operation** - Expand self-teaching help and focused docs so users can understand save syntax, partial success, account discovery, and safe history behavior directly from the CLI. --- CHEATSHEET.md | 50 +- README.md | 15 + docs/configuration.md | 7 + docs/history.md | 37 +- docs/login.md | 23 + docs/saved-targets.md | 108 +++ hacksaws/_account_discovery.py | 496 ++++++++++ hacksaws/_cli.py | 637 +++++++++++-- hacksaws/_history.py | 720 +++++++++++++- hacksaws/_session_save.py | 750 +++++++++++++++ hacksaws/_sessions.py | 407 +++++++- hacksaws/_state.py | 47 + hacksaws/tests/test_account_discovery.py | 541 +++++++++++ hacksaws/tests/test_cli_history_surface.py | 603 ++++++++++++ hacksaws/tests/test_coverage_closure.py | 7 +- hacksaws/tests/test_history.py | 79 +- hacksaws/tests/test_save_history_cli.py | 365 +++++++ hacksaws/tests/test_session_save.py | 1004 ++++++++++++++++++++ hacksaws/tests/test_v04.py | 12 +- pyproject.toml | 3 + 20 files changed, 5782 insertions(+), 129 deletions(-) create mode 100644 docs/saved-targets.md create mode 100644 hacksaws/_account_discovery.py create mode 100644 hacksaws/_session_save.py create mode 100644 hacksaws/tests/test_account_discovery.py create mode 100644 hacksaws/tests/test_cli_history_surface.py create mode 100644 hacksaws/tests/test_save_history_cli.py create mode 100644 hacksaws/tests/test_session_save.py diff --git a/CHEATSHEET.md b/CHEATSHEET.md index c7b4610..414bb74 100644 --- a/CHEATSHEET.md +++ b/CHEATSHEET.md @@ -46,8 +46,26 @@ Shared login flags: --htl N | --mtl N | --stl N role-only hours/minutes/seconds aliases --ecr [--podman] [--ecr-region REGION]... --remote browser login only +--save=NAME save a reusable +NAME after successful login +--save-name NAME explicit-token form of --save=NAME +--save prompt for NAME after credential commit (TTY only) ``` +Save-only advanced controls: + +```text +--save-source-account NAME override the discovered source-account key +--save-role-account NAME override the discovered role-account key +--save-boundary NAME override the generated boundary name +--save-external-id explicitly persist the supplied --external-id +--store-policy-as NAME copy a local policy into reusable policy storage +``` + +`--save=NAME` is intentionally equals-only. `--save NAME` is rejected as +ambiguous; use `--save-name NAME` when separate tokens are preferable. Bare +`--save` is interactive only and prompts after credentials are committed. +JSON/non-TTY use fails before authentication with `SAVE_NAME_REQUIRED`. + Examples: ```shell @@ -79,6 +97,7 @@ hacksaws assume SOURCE --boundary NAME (--self | --to ... | --to-profile ...) hacksaws assume +TARGET [OPTIONS] hacksaws assume --target TARGET [OPTIONS] hacksaws assume SOURCE DEST --role ROLE_OR_ARN [OPTIONS] +hacksaws assume SOURCE --role ROLE_OR_ARN --to agent:default --save=prod-agent ``` Positional `DEST` means `--to-profile DEST` in the source location. It conflicts @@ -144,9 +163,18 @@ hacksaws target add NAME --source-account ACCOUNT [--source-profile PROFILE] \ [--source-location LOCATION|--source-directory PATH] \ [--to LOCATION:PROFILE|--to-directory PATH --to-profile PROFILE] \ [--boundary BOUNDARY] [--description TEXT] +hacksaws target add NAME --from-session PROFILE \ + [--location LOCATION|-d DIRECTORY] [--policy VALUE] \ + [--save-source-account NAME] [--save-role-account NAME] \ + [--save-boundary NAME] [--external-id VALUE --save-external-id] \ + [--store-policy-as NAME] hacksaws target update NAME [--boundary NAME|--clear-boundary] ``` +`target add --from-session` reconstructs configuration only from a usable, +Hacksaws-managed active session. It is the recovery path when authentication +succeeded but the optional post-login configuration save did not. + ## Regions ```shell @@ -229,7 +257,8 @@ hacksaws config import ARCHIVE.zip [--replace] [--yes] ```shell hacksaws history list [PATTERN]... [--since TIME] [--until TIME] [--wide] -hacksaws history search PATTERN... [--command FAMILY] [--outcome OUTCOME] +hacksaws history search PATTERN... [--command FAMILY] [--outcome OUTCOME] \ + [--failure KIND] [--phase PHASE] hacksaws history show HISTORY_ID hacksaws history report [--since TIME] [--account ACCOUNT] hacksaws history export [PATTERN]... [--format jsonl|json] [--output FILE] @@ -239,11 +268,13 @@ hacksaws history clear (--before TIME|--all) [--dry-run] [--yes] ``` List/search/report/export also accept `--command`, `--outcome`, `--account`, -`--resource`, `--limit`, and `--include-running`. `TIME` is an ISO timestamp or -a duration ago using seconds, minutes, hours, days, or weeks, such as `15m`, -`24h`, `7d`, or `2weeks`. Clear never removes a running command or unresolved -recovery record; interactive apply requires typing exactly `yes`, and -noninteractive/JSON apply requires `--yes`. +`--resource`, `--failure`, `--phase`, `--limit`, and `--include-running`. +`--failure` selects a safe failure category such as `unknown-option` or +`missing-option-value`; `--phase` selects `global`, `selector`, `argparse`, or +`semantic`. `TIME` is an ISO timestamp or a duration ago using seconds, minutes, +hours, days, or weeks, such as `15m`, `24h`, `7d`, or `2weeks`. Clear never +removes a running command or unresolved recovery record; interactive apply +requires typing exactly `yes`, and noninteractive/JSON apply requires `--yes`. History stores safe command metadata and outcomes, never raw arguments, stdout/stderr, prompts, paths, documents, credentials, MFA codes, external IDs, @@ -251,6 +282,13 @@ or exception text. Defaults are 90 days, 10,000 records, and 50 MiB. Inspect or change `history.enabled`, `history.max_age`, `history.max_entries`, and `history.max_bytes` with `hacksaws config options|get|set`. +Future parse failures store only a bounded, allowlisted command shape: canonical +command/recognized alias, known option names and value classes/states, +positional roles, opaque counts, failure phase/kind, and a help command. Unknown +tokens, identifiers, values, paths, and the literal `--` tail are never stored. +Older records remain honestly unavailable; Hacksaws cannot reconstruct prior +failed attempts after the fact. + Human `status` output is a compact, dynamic table. `LOCATION` is hidden when all rows use the default location; `TTL` is hidden when no displayed session has a meaningful expiry; and `VERIFY` is hidden unless `--verify` returns a useful STS diff --git a/README.md b/README.md index 34f0378..3f9d8a5 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,20 @@ hacksaws web in +hacw hacksaws web in --target hacw ``` +A successful login can also teach Hacksaws the complete reusable target: + +```shell +hacksaws web in debug --role AgentSession \ + --policy CloudWatchReadOnlyAccess --save=debug-agent +hacksaws web in +debug-agent +``` + +Use bare `--save` in an interactive terminal to choose the name after the +credentials are committed. Automation must use `--save=NAME` or +`--save-name NAME`. See [Saving login workflows](docs/saved-targets.md) for +account discovery, advanced naming, local-policy storage, and recovery from a +successful session whose configuration save did not complete. + Durations accept forms such as `15m`, `15minutes`, `1h`, `hour`, `600s`, and `600seconds`. Rigid aliases `--htl`, `--mtl`, and `--stl` accept floating-point hours, minutes, and seconds; sub-second results round to whole seconds. @@ -274,6 +288,7 @@ models. - [Command cheat sheet](CHEATSHEET.md) - [Login pathways](docs/login.md) +- [Saving login workflows](docs/saved-targets.md) - [Assume a role from an existing session](docs/assume-role.md) - [Profiles, status, and logout](docs/profiles-and-sessions.md) - [IAM policies](docs/iam-policies.md) diff --git a/docs/configuration.md b/docs/configuration.md index 280eaed..0485b4c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,7 @@ hacksaws account list hacksaws account rename prod production hacksaws boundary add logs AgentSession --account prod --policy LogsRead hacksaws target add debug --source-account prod --source-profile admin +hacksaws target add debug-copy --from-session debug hacksaws target update debug --region oregon hacksaws config set aws.region us-east-2 hacksaws config show --account prod @@ -63,3 +64,9 @@ The limits use seconds, entries, and bytes. See `config export` creates a portable zip excluding temporary caches. `config import` validates the complete archive before replacing state. + +Login, browser login, and role assumption can create this graph automatically +with `--save=NAME`. Account identity is anchored to the verified AWS partition +and 12-digit account ID; friendly names are discovery metadata, not identity. +See [Saving login workflows](saved-targets.md) for discovery precedence, +cross-account behavior, create-only collision rules, and session recovery. diff --git a/docs/history.md b/docs/history.md index cde4782..0ec3b0a 100644 --- a/docs/history.md +++ b/docs/history.md @@ -13,6 +13,7 @@ successful parsing, Hacksaws adds only positively allowlisted metadata: - validated profile, location, account, target, and IAM resource identifiers; - safe booleans and enums such as dry-run, format, and output mode; - input roles and formats, never file paths or file contents; +- automatic account-registration counts, separately from requested target saves; - whether an MFA code or external ID was supplied, never its value; - semantic confirmation state, outcome, result code, timing, and safe counts; - unresolved recovery state, which is protected from automatic retention and @@ -29,12 +30,27 @@ An invocation begins as `running` and is atomically finalized as completed, interrupted, or crashed. A running invocation older than 24 hours is marked abandoned during routine maintenance. +Argument failures use versioned `parse.*` events. These events contain only a +bounded grammar observation: canonical command prefix and recognized alias, +allowlisted option names and counts, value presence/class (and a safe file +format where useful), positional roles, capped opaque counts, stable failure +phase/kind, and a repair help command. They never contain raw or hashed argv, +unknown option names, identifiers, values, paths, exception text, or anything +after literal `--`. SQLite database, WAL, and exports follow the same contract. + +Post-credential configuration saves use `session-save.saved`, `.noop`, +`.failed`, or `.cancelled`. Their payload is limited to status, whether saving +was requested, whether credentials remain active, and validated target/boundary +names. Retry commands, directories, errors, provider notices, and policy or +secret inputs are excluded. + ## Inspect history ```shell hacksaws history list -hacksaws history list --wide --since 7d --outcome operational-error +hacksaws history list --wide --since 7d --failure unknown-option hacksaws history search "*ServiceBuzz*" "*iam.policy*" +hacksaws history search --phase semantic --failure invalid-combination hacksaws history show 12ab34cd hacksaws history report --since 30d --account 123456789012 hacksaws history status @@ -42,16 +58,23 @@ hacksaws history check ``` `list` and `search` default to the 50 newest completed records. Filters include -`--since`, `--until`, `--command`, `--outcome`, `--account`, `--resource`, and -`--limit`. Times may be ISO timestamps or durations meaning “that long ago.” -Duration units accept the same seconds/minutes/hours grammar as session duration -plus days and weeks, including `600s`, `15minutes`, `24h`, `7d`, and `2weeks`. -Add `--include-running` when diagnosing an active process. +`--since`, `--until`, `--command`, `--outcome`, `--account`, `--resource`, +`--failure`, `--phase`, and `--limit`. Failure categories are stable safe names +such as `unknown-command`, `unknown-option`, `misplaced-option`, +`missing-option-value`, `missing-required-option`, and `invalid-combination`. +Phases are `global`, `selector`, `argparse`, and `semantic`. Times may be ISO +timestamps or durations meaning “that long ago.” Duration units accept the same +seconds/minutes/hours grammar as session duration plus days and weeks, including +`600s`, `15minutes`, `24h`, `7d`, and `2weeks`. Add `--include-running` when +diagnosing an active process. `show` accepts a complete history ID or an unambiguous prefix of at least four hexadecimal characters. Its human view includes a reconstructed command template. File and secret inputs appear only as placeholders, so the template is -useful for teaching without becoming a credential-recovery mechanism. +useful for teaching without becoming a credential-recovery mechanism. For parse +failures, it also displays the safe attempted shape, phase/category, and +relevant help command. Records written before parse telemetry was available +remain marked unavailable; no prior raw arguments exist to reconstruct. Every command accepts global `--json`. Machine mode retains the same single Hacksaws result envelope used by the rest of the CLI. diff --git a/docs/login.md b/docs/login.md index d4b03dc..56f7bd1 100644 --- a/docs/login.md +++ b/docs/login.md @@ -61,6 +61,29 @@ To constrain credentials that are already logged in without repeating MFA or browser authentication, use the standalone [`hacksaws assume`](assume-role.md) workflow. +## Save a successful workflow + +Add `--save=NAME` or `--save-name NAME` to `mfa in`, `web`/`pk in`, or `assume` +to create the account, optional boundary, and target configuration needed to +repeat the workflow as `+NAME`: + +```shell +hacksaws web in debug --role AgentSession \ + --policy CloudWatchReadOnlyAccess --save=debug-agent +hacksaws web in +debug-agent +``` + +Bare `--save` defers the name prompt until after credential commit. It is +available only in an interactive terminal; JSON and noninteractive execution +must supply a name and fail before authentication otherwise. The separated form +`--save NAME` is deliberately rejected as ambiguous. + +Authentication and configuration persistence are separate transactions. A +cancelled or failed post-login save does not discard valid credentials. The +result clearly reports the partial success and provides a +`target add --from-session` recovery command. See +[Saving login workflows](saved-targets.md) for the complete contract. + ## Destination aliases `.` and `default` mean `~/.aws` when used as locations and the `default` profile diff --git a/docs/saved-targets.md b/docs/saved-targets.md new file mode 100644 index 0000000..51ec4f3 --- /dev/null +++ b/docs/saved-targets.md @@ -0,0 +1,108 @@ +# Saving login workflows + +A successful MFA login, browser login, or role assumption can be saved as a +reusable target. The next run uses `+NAME` or `--target NAME` instead of +repeating the source, destination, role, policy, and duration choices. + +```shell +hacksaws web in debug \ + --role AgentSession \ + --policy CloudWatchReadOnlyAccess \ + --save=debug-agent + +hacksaws web in +debug-agent +``` + +`--save=NAME` is the concise noninteractive spelling. `--save-name NAME` is the +equivalent separate-token form. Bare `--save` asks for a name only after the +credential transaction succeeds. `--save NAME` is intentionally rejected so a +positional profile or MFA code cannot silently become a target name. + +JSON and other noninteractive execution never prompts. Bare `--save` fails with +exit code 2 and `SAVE_NAME_REQUIRED` before AWS authentication begins. + +## What is saved + +Hacksaws creates a small configuration graph: + +- one source account, keyed independently from its friendly display metadata; +- a second account when the assumed role belongs to another account; +- a boundary when the workflow assumes a role, including its optional policy and + duration; and +- a target containing the source and destination profile locations plus the + optional boundary. + +No live credentials, browser tokens, MFA codes, ECR authorization, or other +login state is copied into configuration. External IDs are omitted unless the +user explicitly supplies `--save-external-id`. A local session policy must be +copied into stored policy storage: use `--store-policy-as NAME`, or choose a +name when prompted interactively. Remote and already-stored policies retain +their canonical reusable references. + +Advanced names are available when an organization's conventions require them: + +```text +--save-source-account NAME +--save-role-account NAME +--save-boundary NAME +--save-external-id +--store-policy-as NAME +``` + +These controls require a save request. Existing resources are reused only when +their stable identity and complete saved values match. A collision with +different settings fails; Hacksaws never silently overwrites or mutates shared +configuration. + +## Account discovery + +Discovery runs with the intermediate authenticated credentials, before a +boundary replaces them. Boundary credentials are never used to teach Hacksaws +about the broader source identity. + +Every successful MFA, browser, and assume command registers or reuses its +verified account records, even without `--save`. This account registration is +reported independently from the optional target bundle: machine results expose +`accountRegistration` counts plus `bundleRequested` and `bundleSaved`, so an +ordinary login never implies that a target was saved. History records the two +outcomes as separate safe event families. + +The verified AWS partition and 12-digit account ID are the stable identity. +Hacksaws prefers an explicit save-name override, then an existing unique account +record for that identity, an IAM account alias, an Organizations account name, +and finally `account-ACCOUNT_ID`. A role or policy ARN supplies its owning +account directly. For cross-account roles, a source-account IAM alias is never +misapplied to the role account; Organizations discovery is used when allowed, +otherwise the account-ID fallback is deterministic. + +Denied optional discovery calls produce a neutral note. Unexpected service +failures produce a warning. Neither prevents a valid login or save because the +verified account-ID fallback remains available. Display names never replace the +stable identity or automatically rename an existing account record. + +## Credential commit and partial success + +The login/assume transaction completes before interactive naming and final +configuration persistence. This keeps the user's valid authenticated or bounded +credentials even if they cancel the save, a name collides after discovery, or a +configuration write cannot complete. Hacksaws reports that as successful +authentication with an incomplete configuration save; it does not claim that the +entire command was rolled back. + +Recover from the active, Hacksaws-managed session without authenticating again: + +```shell +hacksaws target add debug-agent --from-session debug +hacksaws target add debug-agent --from-session admin --location horizon +hacksaws target add debug-agent --from-session debug \ + --policy ./agent.yaml --store-policy-as debug-agent-policy +``` + +Use `-d/--directory` instead of `--location` for an explicit AWS directory. +Manual source/destination shape fields cannot be mixed with `--from-session`. +`--policy` and `--external-id` are recovery-only inputs for metadata that active +session state intentionally does not retain; persist an external ID only with +the additional `--save-external-id` consent flag. + +Recovery requires a usable active session managed by Hacksaws and is idempotent. +It does not authenticate, assume another role, or change the live credentials. diff --git a/hacksaws/_account_discovery.py b/hacksaws/_account_discovery.py new file mode 100644 index 0000000..bcfd69e --- /dev/null +++ b/hacksaws/_account_discovery.py @@ -0,0 +1,496 @@ +"""Best-effort AWS account metadata discovery using intermediate credentials.""" + +from __future__ import annotations + +import re +import unicodedata +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any +from typing import Literal +from typing import Protocol + +from botocore.config import Config +from botocore.exceptions import BotoCoreError +from botocore.exceptions import ClientError + +from hacksaws import _state +from hacksaws._configs import OperationalError + +LabelSource = Literal[ + "user", + "existing", + "iam-alias", + "account-name", + "organizations", + "account-id", +] +NoticeReason = Literal[ + "denied", + "unavailable", + "throttled", + "service-error", + "invalid-response", + "collision", +] +Provider = Literal["iam", "account", "organizations", "config"] + +_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +_SLUG_RE = re.compile(r"[^a-z0-9]+") +_THROTTLING_CODES = { + "Throttling", + "ThrottlingException", + "TooManyRequestsException", + "RequestLimitExceeded", +} +_DENIED_CODES = { + "AccessDenied", + "AccessDeniedException", + "AuthorizationError", + "UnauthorizedException", +} +_UNAVAILABLE_CODES = { + "AccountNotFoundException", + "AWSOrganizationsNotInUseException", + "ResourceNotFoundException", +} +_CLIENT_CONFIG = Config( + retries={"mode": "standard", "total_max_attempts": 3}, +) +_ARN_PARTS = 3 +_MAX_ALIAS_PAGES = 10 + + +class IntermediateSession(Protocol): + """Minimum boto3-compatible session surface used during discovery.""" + + def client(self, service_name: str, **kwargs: object) -> Any: + """Create an AWS service client.""" + + +@dataclass(frozen=True, slots=True) +class AccountIdentity: + """Stable AWS account identity independent of mutable friendly labels.""" + + partition: str + account_id: str + arn: str + verified: bool + + +@dataclass(frozen=True, slots=True) +class AccountDiscoveryNotice: + """Sanitized non-fatal provider or naming notice.""" + + provider: Provider + reason: NoticeReason + message: str + + def as_dict(self) -> dict[str, str]: + """Return a stable machine-output adapter with no AWS exception details.""" + return { + "provider": self.provider, + "reason": self.reason, + "message": self.message, + } + + +@dataclass(frozen=True, slots=True) +class AccountDiscovery: + """Resolved account key, identity, display metadata, and non-fatal notices.""" + + identity: AccountIdentity + source_identity: AccountIdentity + key: str + key_source: LabelSource + display_name: str + display_source: LabelSource + existing: bool + notices: tuple[AccountDiscoveryNotice, ...] = () + _existing_record: Mapping[str, object] | None = None + + @property + def account_id(self) -> str: + """Return the resolved account ID for integration adapters.""" + return self.identity.account_id + + @property + def partition(self) -> str: + """Return the resolved AWS partition for integration adapters.""" + return self.identity.partition + + def account_record(self, *, verified: bool | None = None) -> dict[str, object]: + """Return schema-safe persistent metadata, excluding transient notices.""" + if self._existing_record is not None: + record = dict(self._existing_record) + else: + record = { + "id": self.account_id, + "partition": self.partition, + "display_name": self.display_name, + "display_source": self.display_source, + } + is_verified = self.identity.verified if verified is None else verified + if is_verified: + record.pop("unverified", None) + else: + record["unverified"] = True + return record + + def as_dict(self) -> dict[str, object]: + """Return a typed operational-output adapter without provider payloads.""" + return { + "accountId": self.account_id, + "partition": self.partition, + "key": self.key, + "keySource": self.key_source, + "displayName": self.display_name, + "displaySource": self.display_source, + "existing": self.existing, + "verified": self.identity.verified, + "notices": [notice.as_dict() for notice in self.notices], + } + + +def _safe_display(value: object) -> str | None: + """Normalize untrusted provider text for terminal and config display.""" + if not isinstance(value, str): + return None + without_ansi = _ANSI_RE.sub("", value) + characters = ( + " " if unicodedata.category(character).startswith("C") else character + for character in without_ansi + ) + normalized = " ".join("".join(characters).split()) + return normalized[:128].rstrip() or None + + +def _slug(value: str) -> str | None: + """Convert safe display text to a portable Hacksaws resource key.""" + ascii_value = ( + unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode() + ) + slug = _SLUG_RE.sub("-", ascii_value.casefold()).strip("-") + return slug[:64].rstrip("-") or None + + +def _notice(provider: Provider, error: BaseException) -> AccountDiscoveryNotice: + """Classify an AWS provider failure without leaking its raw response.""" + reason: NoticeReason = "service-error" + if isinstance(error, ClientError): + response = error.response if isinstance(error.response, dict) else {} + error_data = response.get("Error", {}) + code = error_data.get("Code") if isinstance(error_data, dict) else None + if code in _DENIED_CODES: + reason = "denied" + elif code in _UNAVAILABLE_CODES: + reason = "unavailable" + elif code in _THROTTLING_CODES: + reason = "throttled" + messages = { + "denied": "AWS did not allow this optional account-name lookup.", + "unavailable": "This optional AWS account-name source is unavailable.", + "throttled": "AWS throttled this optional account-name lookup.", + "service-error": "AWS could not complete this optional account-name lookup.", + "invalid-response": "AWS returned unusable optional account-name metadata.", + "collision": ( + "The discovered account key was disambiguated with its account ID." + ), + } + return AccountDiscoveryNotice(provider, reason, messages[reason]) + + +def _invalid_response(provider: Provider) -> AccountDiscoveryNotice: + return AccountDiscoveryNotice( + provider, + "invalid-response", + "AWS returned unusable optional account-name metadata.", + ) + + +def _client(session: IntermediateSession, service: str) -> Any: + """Create a retry-configured client, tolerating minimal session adapters. + + Real boto3 sessions accept ``config``. The small session adapters used by + embedders and tests sometimes intentionally expose only ``client(name)``; + retaining that compatibility keeps account registration best-effort and + prevents it from affecting a successful credential exchange. + """ + try: + return session.client(service, config=_CLIENT_CONFIG) + except TypeError: + return session.client(service) + + +def _source_identity(session: IntermediateSession) -> AccountIdentity: + """Verify the intermediate caller; this is the only required discovery call.""" + try: + response = _client(session, "sts").get_caller_identity() + except (AttributeError, BotoCoreError, ClientError, KeyError, TypeError) as error: + raise OperationalError( + "Unable to verify the intermediate AWS account identity." + ) from error + if not isinstance(response, Mapping): + raise OperationalError("AWS returned an invalid intermediate identity.") + account_id = response.get("Account") + arn = response.get("Arn") + if not isinstance(account_id, str) or not re.fullmatch(r"\d{12}", account_id): + raise OperationalError("AWS returned an invalid intermediate account ID.") + if not isinstance(arn, str): + raise OperationalError("AWS returned an invalid intermediate identity ARN.") + parts = arn.split(":", 2) + if ( + len(parts) != _ARN_PARTS + or parts[0] != "arn" + or parts[1] not in _state.PARTITIONS + ): + raise OperationalError("AWS returned an invalid intermediate identity ARN.") + return AccountIdentity( + partition=parts[1], account_id=account_id, arn=arn, verified=True + ) + + +def _target_identity(source: AccountIdentity, role_arn: str | None) -> AccountIdentity: + if role_arn is None: + return source + partition, account_id, _ = _state.parse_role_arn(role_arn) + if partition != source.partition: + raise OperationalError( + "Role ARN partition does not match the intermediate AWS identity." + ) + return AccountIdentity( + partition=partition, + account_id=account_id, + arn=role_arn, + verified=account_id == source.account_id, + ) + + +def _existing_account( + config: Mapping[str, object], identity: AccountIdentity +) -> tuple[str, Mapping[str, object]] | None: + accounts = config.get("accounts", {}) + if not isinstance(accounts, Mapping): + raise OperationalError("Config accounts must be an object.") + matches = [ + (name, record) + for name, record in accounts.items() + if isinstance(name, str) + and isinstance(record, Mapping) + and record.get("id") == identity.account_id + and record.get("partition") == identity.partition + ] + if len(matches) > 1: + raise OperationalError( + "Configuration contains multiple names for the same AWS account identity." + ) + return matches[0] if matches else None + + +def _iam_alias( + session: IntermediateSession, +) -> tuple[str | None, list[AccountDiscoveryNotice]]: + notices: list[AccountDiscoveryNotice] = [] + aliases: list[str] = [] + try: + pages = _client(session, "iam").get_paginator("list_account_aliases").paginate() + for index, page in enumerate(pages): + if index >= _MAX_ALIAS_PAGES or not isinstance(page, Mapping): + notices.append(_invalid_response("iam")) + return None, notices + values = page.get("AccountAliases", []) + if not isinstance(values, list) or any( + not isinstance(value, str) for value in values + ): + notices.append(_invalid_response("iam")) + return None, notices + aliases.extend(values) + except (BotoCoreError, ClientError) as error: + notices.append(_notice("iam", error)) + return None, notices + if not aliases: + return None, notices + if len(aliases) != 1: + notices.append(_invalid_response("iam")) + return None, notices + alias = _safe_display(aliases[0]) + if alias is None or not _state.NAME_RE.fullmatch(alias): + notices.append(_invalid_response("iam")) + return None, notices + return alias, notices + + +def _account_name( + session: IntermediateSession, *, account_id: str | None = None +) -> tuple[str | None, list[AccountDiscoveryNotice]]: + notices: list[AccountDiscoveryNotice] = [] + try: + client = _client(session, "account") + response = ( + client.get_account_information(AccountId=account_id) + if account_id is not None + else client.get_account_information() + ) + except (BotoCoreError, ClientError) as error: + notices.append(_notice("account", error)) + return None, notices + if not isinstance(response, Mapping): + notices.append(_invalid_response("account")) + return None, notices + name = _safe_display(response.get("AccountName")) + if name is None: + notices.append(_invalid_response("account")) + return name, notices + + +def _organization_name( + session: IntermediateSession, account_id: str +) -> tuple[str | None, list[AccountDiscoveryNotice]]: + notices: list[AccountDiscoveryNotice] = [] + try: + response = _client(session, "organizations").describe_account( + AccountId=account_id + ) + except (BotoCoreError, ClientError) as error: + notices.append(_notice("organizations", error)) + return None, notices + if not isinstance(response, Mapping) or not isinstance( + response.get("Account"), Mapping + ): + notices.append(_invalid_response("organizations")) + return None, notices + account = response["Account"] + if account.get("Id") not in {None, account_id}: + notices.append(_invalid_response("organizations")) + return None, notices + name = _safe_display(account.get("Name")) + if name is None: + notices.append(_invalid_response("organizations")) + return name, notices + + +def _unique_key( + config: Mapping[str, object], candidate: str, identity: AccountIdentity +) -> tuple[str, AccountDiscoveryNotice | None]: + accounts = config.get("accounts", {}) + if not isinstance(accounts, Mapping): + raise OperationalError("Config accounts must be an object.") + folded = {str(name).casefold() for name in accounts} + if candidate.casefold() not in folded: + return candidate, None + suffix = f"-{identity.account_id}" + disambiguated = f"{candidate[: 64 - len(suffix)].rstrip('-')}{suffix}" + if disambiguated.casefold() in folded: + partition_suffix = f"-{identity.partition}-{identity.account_id}" + disambiguated = ( + f"{candidate[: 64 - len(partition_suffix)].rstrip('-')}{partition_suffix}" + ) + if disambiguated.casefold() in folded: + raise OperationalError( + "Unable to derive a unique account key from the verified AWS identity." + ) + return disambiguated, AccountDiscoveryNotice( + "config", + "collision", + "The discovered account key was disambiguated with its account ID.", + ) + + +def discover_account( + session: IntermediateSession, + config: Mapping[str, object], + *, + explicit_name: str | None = None, + role_arn: str | None = None, + allow_cross_account_api: bool = False, +) -> AccountDiscovery: + """Discover one account using only the supplied intermediate credentials.""" + source = _source_identity(session) + identity = _target_identity(source, role_arn) + existing = _existing_account(config, identity) + if existing is not None: + key, record = existing + display = _safe_display(record.get("display_name")) or key + source_value = record.get("display_source", "existing") + existing_display_source: LabelSource = ( + source_value + if source_value + in { + "user", + "iam-alias", + "account-name", + "organizations", + "account-id", + } + else "existing" + ) + return AccountDiscovery( + identity=identity, + source_identity=source, + key=key, + key_source="existing", + display_name=display, + display_source=existing_display_source, + existing=True, + _existing_record=record, + ) + + if explicit_name is not None: + candidate = _state.validate_name(explicit_name, kind="account") + key, collision = _unique_key(config, candidate, identity) + explicit_notices = (collision,) if collision is not None else () + return AccountDiscovery( + identity=identity, + source_identity=source, + key=key, + key_source="user", + display_name=candidate, + display_source="user", + existing=False, + notices=explicit_notices, + ) + + notices: list[AccountDiscoveryNotice] = [] + alias: str | None = None + account_name: str | None = None + account_name_source: LabelSource = "account-name" + if identity.account_id == source.account_id: + alias, provider_notices = _iam_alias(session) + notices.extend(provider_notices) + account_name, provider_notices = _account_name(session) + notices.extend(provider_notices) + else: + account_name, provider_notices = _organization_name( + session, identity.account_id + ) + notices.extend(provider_notices) + account_name_source = "organizations" + if account_name is None and allow_cross_account_api: + account_name, provider_notices = _account_name( + session, account_id=identity.account_id + ) + notices.extend(provider_notices) + account_name_source = "account-name" + + slug = _slug(account_name) if account_name else None + candidate = alias or slug or f"account-{identity.account_id}" + key_source: LabelSource = ( + "iam-alias" if alias else account_name_source if slug else "account-id" + ) + display = account_name or alias or candidate + display_source: LabelSource = ( + account_name_source if account_name else "iam-alias" if alias else "account-id" + ) + key, collision = _unique_key(config, candidate, identity) + if collision is not None: + notices.append(collision) + return AccountDiscovery( + identity=identity, + source_identity=source, + key=key, + key_source=key_source, + display_name=display, + display_source=display_source, + existing=False, + notices=tuple(notices), + ) diff --git a/hacksaws/_cli.py b/hacksaws/_cli.py index 071eb9b..2763818 100644 --- a/hacksaws/_cli.py +++ b/hacksaws/_cli.py @@ -48,6 +48,64 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 super().__init__(*args, **kwargs) +class _UsageError(_configs.OperationalError): + """A semantic command-line error discovered after argparse succeeds.""" + + def __init__(self, message: str, *, code: str = "ARGUMENT_ERROR") -> None: + super().__init__(message) + self.code = code + self.exit_code = _configs.EXIT_USAGE + + +def _save_arguments(parser: argparse.ArgumentParser) -> None: + """Add the reusable saved-target controls to one credential workflow.""" + save = parser.add_mutually_exclusive_group() + save.add_argument( + "--save", + action="store_true", + help=( + "After credentials are committed, prompt for a reusable target name. " + "In automation use --save=NAME or --save-name NAME." + ), + ) + save.add_argument( + "--save-name", + metavar="NAME", + help=( + "Save the successful credential workflow as +NAME without prompting; " + "--save=NAME is equivalent." + ), + ) + parser.add_argument( + "--save-source-account", + metavar="NAME", + help="Advanced: name a newly discovered source-account configuration.", + ) + parser.add_argument( + "--save-role-account", + metavar="NAME", + help="Advanced: name a newly discovered role-owning account configuration.", + ) + parser.add_argument( + "--save-boundary", + metavar="NAME", + help="Advanced: name the generated role/policy boundary configuration.", + ) + parser.add_argument( + "--save-external-id", + action="store_true", + help=( + "Explicitly allow the supplied --external-id to be stored in the saved " + "boundary configuration." + ), + ) + parser.add_argument( + "--store-policy-as", + metavar="NAME", + help="Store the resolved session policy locally under this reusable name.", + ) + + def _duration_arguments(parser: argparse.ArgumentParser) -> None: group = parser.add_mutually_exclusive_group() group.add_argument( @@ -113,6 +171,16 @@ def _history_filter_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--resource", help="Match a recorded resource name or ARN fragment." ) + parser.add_argument( + "--failure", + choices=_history.PARSE_FAILURE_KINDS, + help="Include only this safe argument-failure category.", + ) + parser.add_argument( + "--phase", + choices=_history.PARSE_PHASES, + help="Include only failures from this parsing or validation phase.", + ) parser.add_argument( "--limit", type=int, @@ -218,6 +286,7 @@ def _login_arguments(parser: argparse.ArgumentParser, *, browser: bool = False) ) _duration_arguments(parser) _ecr_arguments(parser) + _save_arguments(parser) if browser: parser.add_argument( "--remote", @@ -357,6 +426,7 @@ def _assume_arguments(parser: argparse.ArgumentParser) -> None: action="store_true", help="Approve the displayed assumption plan without prompting.", ) + _save_arguments(parser) parser.set_defaults( action="assume", directory="~/.aws", @@ -429,27 +499,59 @@ def _resource_parser(parent: argparse._SubParsersAction[Any], kind: str) -> None "boundary": "Manage saved role and optional session-policy boundaries.", "target": "Manage saved login source, destination, and boundary presets.", } + plural = {"account": "accounts", "boundary": "boundaries", "target": "targets"}[ + kind + ] parser = parent.add_parser(kind, help=purposes[kind], description=purposes[kind]) actions = parser.add_subparsers(dest="resource_action") - add = actions.add_parser("add") - add.add_argument("resource_name") - add.add_argument("--description") - update = actions.add_parser("update") - update.add_argument("resource_name") - update.add_argument("--description") - update.add_argument("--clear-description", action="store_true") + add = actions.add_parser( + "add", help=f"Add a named {kind}.", description=f"Add a named {kind}." + ) + add.add_argument("resource_name", metavar="NAME", help=f"Name for the new {kind}.") + add.add_argument("--description", help=f"Human description of this {kind}.") + update = actions.add_parser( + "update", + help=f"Update a named {kind}.", + description=f"Update a named {kind}.", + ) + update.add_argument("resource_name", metavar="NAME", help=f"Existing {kind} name.") + update.add_argument("--description", help="Replace the human description.") + update.add_argument( + "--clear-description", + action="store_true", + help="Remove the human description.", + ) for action in ("get", "remove"): - item = actions.add_parser(action) - item.add_argument("resource_name") - item.add_argument("--json", action="store_true") + purpose = f"{'Show' if action == 'get' else 'Remove'} a named {kind}." + item = actions.add_parser(action, help=purpose, description=purpose) + item.add_argument( + "resource_name", metavar="NAME", help=f"Existing {kind} name." + ) + item.add_argument( + "--json", action="store_true", help="Emit the stable JSON result envelope." + ) if action == "remove": - item.add_argument("--cascade", action="store_true") - item.add_argument("--yes", action="store_true") - listing = actions.add_parser("list") - listing.add_argument("--json", action="store_true") - rename = actions.add_parser("rename") - rename.add_argument("resource_name") - rename.add_argument("new_name") + item.add_argument( + "--cascade", + action="store_true", + help="Also remove local resources that reference this one.", + ) + item.add_argument( + "--yes", + action="store_true", + help="Approve the displayed removal without prompting.", + ) + listing = actions.add_parser( + "list", help=f"List named {plural}.", description=f"List named {plural}." + ) + listing.add_argument( + "--json", action="store_true", help="Emit the stable JSON result envelope." + ) + rename = actions.add_parser( + "rename", help=f"Rename a {kind}.", description=f"Rename a {kind}." + ) + rename.add_argument("resource_name", metavar="NAME", help=f"Existing {kind} name.") + rename.add_argument("new_name", metavar="NEW_NAME", help=f"New {kind} name.") if kind == "account": add.add_argument("account_id") @@ -499,15 +601,66 @@ def _resource_parser(parent: argparse._SubParsersAction[Any], kind: str) -> None update.add_argument("--clear-duration", action="store_true") _credential_selector(update) elif kind == "target": - add.add_argument("--source-account", required=True) - add.add_argument("--source-profile", default="default") + add.add_argument( + "--from-session", + metavar="PROFILE", + help=( + "Build the target from one active Hacksaws-managed session instead " + "of repeating its source, destination, and boundary fields." + ), + ) + session_folder = add.add_mutually_exclusive_group() + session_folder.add_argument( + "--location", + help=( + "Logical AWS folder containing --from-session PROFILE " + "(default: default, meaning ~/.aws)." + ), + ) + session_folder.add_argument( + "-d", + "--directory", + help="Explicit AWS directory containing --from-session PROFILE.", + ) + add.add_argument( + "--source-account", + help=( + "Manual configured account owning the source credentials; required " + "unless --from-session is used." + ), + ) + add.add_argument( + "--source-profile", + help="Manual source AWS profile (default: default).", + ) source = add.add_mutually_exclusive_group() - source.add_argument("--source-location", default="default") - source.add_argument("--source-directory") - add.add_argument("--to") - add.add_argument("--to-directory") - add.add_argument("--to-profile") - add.add_argument("--boundary") + source.add_argument( + "--source-location", + help="Manual source logical AWS folder (default: default).", + ) + source.add_argument( + "--source-directory", + help="Manual explicit AWS directory containing the source profile.", + ) + add.add_argument( + "--to", + metavar="LOCATION:PROFILE", + help="Manual destination AWS location and profile.", + ) + add.add_argument( + "--to-directory", + metavar="PATH", + help="Manual explicit destination AWS directory; requires --to-profile.", + ) + add.add_argument( + "--to-profile", + metavar="PROFILE", + help="Manual destination profile, paired with --to-directory when used.", + ) + add.add_argument( + "--boundary", + help="Manual saved boundary to attach to this target.", + ) add.add_argument( "--region", metavar="REGION_OR_ALIAS", @@ -518,6 +671,45 @@ def _resource_parser(parent: argparse._SubParsersAction[Any], kind: str) -> None action="store_true", help="Accept a canonical-shaped region absent from bundled metadata.", ) + add.add_argument( + "--save-source-account", + metavar="NAME", + help="Advanced: name a source account reconstructed from the session.", + ) + add.add_argument( + "--save-role-account", + metavar="NAME", + help="Advanced: name a role-owning account reconstructed from the session.", + ) + add.add_argument( + "--save-boundary", + metavar="NAME", + help="Advanced: name a boundary reconstructed from the session.", + ) + add.add_argument( + "--save-external-id", + action="store_true", + help="Permit a recoverable external ID to be stored with the boundary.", + ) + add.add_argument( + "--store-policy-as", + metavar="NAME", + help="Store the session policy locally under this reusable name.", + ) + add.add_argument( + "--external-id", + help=( + "Recovery-only external ID when the session cannot retain that " + "secret; requires --from-session." + ), + ) + add.add_argument( + "--policy", + help=( + "Recovery-only policy name, ARN, stored name, or local file when " + "the session lacks a reusable policy reference." + ), + ) update.add_argument("--boundary") update.add_argument("--clear-boundary", action="store_true") target_region = update.add_mutually_exclusive_group() @@ -1098,6 +1290,99 @@ def _json_requested(arguments: Sequence[str]) -> bool: return False +def _normalize_login_save_options(arguments: list[str]) -> list[str]: + """Support equals-only ``--save=NAME`` without accepting ``--save NAME``.""" + command_prefix = tuple(arguments[:2]) + is_login = command_prefix in { + (auth, action) for auth in ("mfa", "pk", "web") for action in ("login", "in") + } + if not (is_login or (arguments and arguments[0] == "assume")): + return arguments + normalized: list[str] = [] + save_forms = 0 + index = 0 + while index < len(arguments): + argument = arguments[index] + if argument == "--": + normalized.extend(arguments[index:]) + break + if argument.startswith("--save="): + save_forms += 1 + value = argument.partition("=")[2] + if not value: + raise _UsageError( + "--save=NAME requires a non-empty name; use bare --save only " + "for an interactive post-login prompt." + ) + normalized.extend(("--save-name", value)) + else: + if argument in {"--save", "--save-name"} or argument.startswith( + "--save-name=" + ): + save_forms += 1 + if ( + argument == "--save" + and index + 1 < len(arguments) + and not arguments[index + 1].startswith("-") + ): + raise _UsageError( + "Ambiguous '--save NAME' is not supported. Use --save=NAME or " + "--save-name NAME; use bare --save only at the end of an " + "interactive command." + ) + normalized.append(argument) + index += 1 + if save_forms > 1: + raise _UsageError("Specify a saved-target name only once.") + return normalized + + +_SAVE_OVERRIDE_FIELDS = ( + "save_source_account", + "save_role_account", + "save_boundary", + "save_external_id", + "store_policy_as", +) + + +def _validate_save_arguments(namespace: argparse.Namespace) -> None: + """Validate reusable-target controls before any AWS authentication occurs.""" + save_requested = bool( + getattr(namespace, "save", False) or getattr(namespace, "save_name", None) + ) + overrides = [ + field for field in _SAVE_OVERRIDE_FIELDS if getattr(namespace, field, None) + ] + if overrides and not save_requested: + rendered = ", ".join(f"--{field.replace('_', '-')}" for field in overrides) + raise _UsageError( + f"{rendered} require --save=NAME, --save-name NAME, or bare --save." + ) + for field in ( + "save_name", + "save_source_account", + "save_role_account", + "save_boundary", + "store_policy_as", + ): + value = getattr(namespace, field, None) + if value: + _state.validate_name(value, kind=field.replace("_", " ")) + if getattr(namespace, "save_external_id", False) and not getattr( + namespace, "external_id", None + ): + raise _UsageError("--save-external-id requires an explicit --external-id.") + if getattr(namespace, "save", False) and ( + bool(getattr(namespace, "json", False)) or not sys.stdin.isatty() + ): + raise _UsageError( + "Bare --save needs an interactive post-login name prompt. In JSON or " + "non-interactive use, specify --save=NAME or --save-name NAME.", + code="SAVE_NAME_REQUIRED", + ) + + @contextlib.contextmanager def _redirect_stdin(stream: object) -> Iterator[None]: """Temporarily provide a non-TTY input stream for strict machine mode.""" @@ -1127,15 +1412,16 @@ def _print_help(command: Sequence[str] = ()) -> None: def _validate_login(namespace: argparse.Namespace) -> None: + _validate_save_arguments(namespace) profile = getattr(namespace, "profile", None) if profile in {".", "default"}: namespace.profile = "default" profile = "default" if profile and not profile[0].isalnum(): if getattr(namespace, "target", None): - raise _configs.OperationalError("Specify a target only once.") + raise _UsageError("Specify a target only once.") if len(profile) == 1: - raise _configs.OperationalError("A target shorthand requires a name.") + raise _UsageError("A target shorthand requires a name.") namespace.target = "+" + profile[1:] namespace.profile = None if getattr(namespace, "policy", None) and not ( @@ -1143,7 +1429,7 @@ def _validate_login(namespace: argparse.Namespace) -> None: or getattr(namespace, "boundary", None) or getattr(namespace, "target", None) ): - raise _configs.OperationalError("--policy requires --role or --boundary.") + raise _UsageError("--policy requires --role or --boundary.") if ( getattr(namespace, "external_id", None) or getattr(namespace, "session_name", None) @@ -1152,32 +1438,28 @@ def _validate_login(namespace: argparse.Namespace) -> None: or getattr(namespace, "boundary", None) or getattr(namespace, "target", None) ): - raise _configs.OperationalError( - "Role-only options require --role or --boundary." - ) + raise _UsageError("Role-only options require --role or --boundary.") if getattr(namespace, "to", None) and ( getattr(namespace, "to_directory", None) or getattr(namespace, "to_profile", None) ): - raise _configs.OperationalError( + raise _UsageError( "--to is mutually exclusive with --to-directory/--to-profile." ) if getattr(namespace, "to_directory", None) and not getattr( namespace, "to_profile", None ): - raise _configs.OperationalError("--to-directory requires --to-profile.") + raise _UsageError("--to-directory requires --to-profile.") if getattr(namespace, "target", None) and not namespace.target.startswith("+"): namespace.target = "+" + namespace.target if getattr(namespace, "role", None) and getattr(namespace, "boundary", None): - raise _configs.OperationalError( - "--role and --boundary/--as are mutually exclusive." - ) + raise _UsageError("--role and --boundary/--as are mutually exclusive.") if getattr(namespace, "target", None): data = _state.load_config() _, target = _state.get_resource(data, "target", namespace.target.lstrip("+")) direct_boundary = getattr(namespace, "boundary", None) if direct_boundary and target.get("boundary"): - raise _configs.OperationalError( + raise _UsageError( "A target with a saved boundary cannot accept --boundary/--as." ) overrides = [ @@ -1196,13 +1478,14 @@ def _validate_login(namespace: argparse.Namespace) -> None: any(value is not None for value in overrides) or getattr(namespace, "directory", "~/.aws") != "~/.aws" ): - raise _configs.OperationalError( + raise _UsageError( "A saved target is a secure preset; source, destination, role, and policy cannot be overridden." ) def _validate_assume(namespace: argparse.Namespace) -> None: """Validate assume-only grammar before AWS discovery or confirmation.""" + _validate_save_arguments(namespace) positional_destination = getattr(namespace, "destination", None) if positional_destination: if ( @@ -1212,7 +1495,7 @@ def _validate_assume(namespace: argparse.Namespace) -> None: or namespace.to_directory or namespace.to_profile ): - raise _configs.OperationalError( + raise _UsageError( "Positional DEST is mutually exclusive with saved targets, --self, " "--to, --to-directory, and --to-profile." ) @@ -1227,48 +1510,46 @@ def _validate_assume(namespace: argparse.Namespace) -> None: profile = "default" if profile and not profile[0].isalnum(): if namespace.target: - raise _configs.OperationalError("Specify a saved target only once.") + raise _UsageError("Specify a saved target only once.") if len(profile) == 1: - raise _configs.OperationalError("A target shorthand requires a name.") + raise _UsageError("A target shorthand requires a name.") namespace.target = "+" + profile[1:] namespace.profile = None profile = None if namespace.target and not namespace.target.startswith("+"): namespace.target = "+" + namespace.target if not (profile or namespace.target): - raise _configs.OperationalError( - "Assume requires a source profile or saved target." - ) + raise _UsageError("Assume requires a source profile or saved target.") if namespace.self_destination and namespace.keep_source: - raise _configs.OperationalError("--self cannot be combined with --keep-source.") + raise _UsageError("--self cannot be combined with --keep-source.") if namespace.self_destination and (namespace.to_directory or namespace.to_profile): - raise _configs.OperationalError( + raise _UsageError( "--self is mutually exclusive with --to-directory/--to-profile." ) if namespace.to and (namespace.to_directory or namespace.to_profile): - raise _configs.OperationalError( + raise _UsageError( "--to is mutually exclusive with --to-directory/--to-profile." ) if namespace.to_directory and not namespace.to_profile: - raise _configs.OperationalError("--to-directory requires --to-profile.") + raise _UsageError("--to-directory requires --to-profile.") if namespace.to: location, separator, destination_profile = namespace.to.partition(":") if not separator or not location or not destination_profile: - raise _configs.OperationalError("--to must be LOCATION:PROFILE.") + raise _UsageError("--to must be LOCATION:PROFILE.") data = _state.load_config() target: dict[str, Any] | None = None if namespace.target: _, target = _state.get_resource(data, "target", namespace.target.lstrip("+")) if namespace.self_destination: - raise _configs.OperationalError( + raise _UsageError( "A saved target owns its destination and cannot be combined with --self." ) if namespace.aws_account_name: - raise _configs.OperationalError( + raise _UsageError( "A saved target supplies its source location; omit --name." ) if namespace.to or namespace.to_directory or namespace.to_profile: - raise _configs.OperationalError( + raise _UsageError( "A saved target supplies its destination; use --self for an explicit " "in-place assumption." ) @@ -1287,7 +1568,7 @@ def _validate_assume(namespace: argparse.Namespace) -> None: if value ] if overrides: - raise _configs.OperationalError( + raise _UsageError( "A bounded target supplies its role contract and cannot be " "combined with " + ", ".join(overrides) + "." ) @@ -1305,7 +1586,7 @@ def _validate_assume(namespace: argparse.Namespace) -> None: if value ] if overrides: - raise _configs.OperationalError( + raise _UsageError( "An unbounded target may add one saved --boundary/--as, not " + ", ".join(overrides) + "." @@ -1315,7 +1596,7 @@ def _validate_assume(namespace: argparse.Namespace) -> None: or target.get("destination_directory") or target.get("destination_profile") ): - raise _configs.OperationalError( + raise _UsageError( "Saved target has no destination; update it or use --self." ) elif not ( @@ -1324,13 +1605,13 @@ def _validate_assume(namespace: argparse.Namespace) -> None: or namespace.to_directory or namespace.to_profile ): - raise _configs.OperationalError( + raise _UsageError( "Assume requires --self, --to LOCATION:PROFILE, --to-profile PROFILE, " "or a saved target destination." ) concrete_boundary = namespace.boundary or (target or {}).get("boundary") if not (namespace.role or concrete_boundary): - raise _configs.OperationalError( + raise _UsageError( "Assume requires a concrete --role, saved --boundary/--as, or bounded " "target." ) @@ -2105,6 +2386,44 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: return _configs.Result( "RESOURCE_HELP", f"Choose an action for {kind}.", 2, "stderr" ) + if kind == "target" and action == "add" and args.from_session: + manual = [ + option + for option, supplied in ( + ("--source-account", args.source_account), + ("--source-profile", args.source_profile is not None), + ("--source-location", args.source_location is not None), + ("--source-directory", args.source_directory), + ("--to", args.to), + ("--to-directory", args.to_directory), + ("--to-profile", args.to_profile), + ("--boundary", args.boundary), + ("--region", args.region), + ("--allow-unknown-region", args.allow_unknown_region), + ) + if supplied + ] + if manual: + raise _UsageError( + "--from-session reconstructs the target shape and cannot be combined " + f"with {', '.join(manual)}." + ) + return _sessions.save_target_from_session(args) + if kind == "target" and action == "add": + if not args.source_account: + raise _UsageError( + "target add requires --source-account unless --from-session is used." + ) + recovery_only = [ + field + for field in (*_SAVE_OVERRIDE_FIELDS, "external_id", "policy") + if getattr(args, field, None) + ] + if recovery_only: + rendered = ", ".join( + f"--{field.replace('_', '-')}" for field in recovery_only + ) + raise _UsageError(f"{rendered} require --from-session PROFILE.") data = _state.load_config() if action == "list": values = [ @@ -2222,7 +2541,7 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: else: value = { "source_account": args.source_account, - "source_profile": args.source_profile, + "source_profile": args.source_profile or "default", } if args.source_directory: value["source_directory"] = str( @@ -2230,7 +2549,7 @@ def _run_resource(args: argparse.Namespace) -> _configs.Result: ) else: value["source_location"] = _state.normalize_location( - args.source_location + args.source_location or "default" ) if args.to: location, separator, profile = args.to.partition(":") @@ -3081,12 +3400,12 @@ def _run_config(args: argparse.Namespace) -> _configs.Result: _HISTORY_OUTCOMES = { - "success": ("✓", "success"), + "success": ("OK", "success"), "usage-error": ("?", "usage error"), - "policy-refusal": ("⊘", "policy refusal"), - "cancelled": ("○", "cancelled"), + "policy-refusal": ("-", "policy refusal"), + "cancelled": ("o", "cancelled"), "operational-error": ("!", "operational error"), - "interrupted": ("↯", "interrupted"), + "interrupted": ("^", "interrupted"), "crashed": ("X", "crashed/abandoned"), } @@ -3102,6 +3421,8 @@ def _history_records(args: argparse.Namespace) -> list[dict[str, object]]: outcome=getattr(args, "outcome", None), account=getattr(args, "account", None), resource=getattr(args, "resource", None), + failure=getattr(args, "failure", None), + phase=getattr(args, "phase", None), limit=getattr(args, "limit", 50), include_running=bool(getattr(args, "include_running", False)), ) @@ -3110,12 +3431,12 @@ def _history_records(args: argparse.Namespace) -> list[dict[str, object]]: def _history_list_text(records: list[dict[str, object]], *, wide: bool = False) -> str: columns = ["ID", "STARTED", "S", "COMMAND", "PROFILE"] if wide: - columns.extend(("ACCOUNT", "RESOURCE", "RESULT", "MS")) + columns.extend(("ACCOUNT", "RESOURCE", "RESULT", "FAILURE", "MS")) rows: list[list[object]] = [] used_symbols: set[str] = set() for record in records: outcome = str(record.get("outcome") or "") - symbol = _HISTORY_OUTCOMES.get(outcome, ("…", "running/unknown"))[0] + symbol = _HISTORY_OUTCOMES.get(outcome, ("...", "running/unknown"))[0] used_symbols.add(symbol) row: list[object] = [ str(record["id"])[:8], @@ -3125,11 +3446,15 @@ def _history_list_text(records: list[dict[str, object]], *, wide: bool = False) record.get("profile"), ] if wide: + failure = _history.parse_failure_event(record) row.extend( ( record.get("accountId"), record.get("resourceName") or record.get("resourceArn"), record.get("resultCode"), + str(failure.get("kind", "")).removeprefix("parse.") + if failure + else None, record.get("durationMs"), ) ) @@ -3142,8 +3467,8 @@ def _history_list_text(records: list[dict[str, object]], *, wide: bool = False) for outcome, (symbol, meaning) in _HISTORY_OUTCOMES.items() if symbol in used_symbols and outcome ] - if "…" in used_symbols: - meanings.append(("…", "running/unknown")) + if "..." in used_symbols: + meanings.append(("...", "running/unknown")) return f"{table}\n\nKey: " + " ".join( f"{symbol} {meaning}" for symbol, meaning in meanings ) @@ -3202,6 +3527,47 @@ def _history_show_text(record: dict[str, object]) -> str: ) if record.get("recoveryUnresolved") is True: lines.append("Recovery: unresolved; retention and clear preserve this record.") + save_event = _history.session_save_event(record) + if save_event is not None: + event_data = save_event.get("data") + save_data = event_data if isinstance(event_data, dict) else {} + save_context = [ + f"{label}={save_data.get(key)}" + for key, label in (("target", "target"), ("boundary", "boundary")) + if save_data.get(key) + ] + save_suffix = f" ({', '.join(save_context)})" if save_context else "" + lines.append( + "Session save: " + f"{save_data.get('status') or '-'}{save_suffix}; " + f"requested={'yes' if save_data.get('requested') is True else 'no'}; " + "credentials active=" + f"{'yes' if save_data.get('credentialsActive') is True else 'no'}" + ) + parse_event = _history.parse_failure_event(record) + if parse_event is not None: + event_data = parse_event.get("data") + data = event_data if isinstance(event_data, dict) else {} + repair = data.get("repair") + repair_data = repair if isinstance(repair, dict) else {} + lines.extend( + ( + f"Parse phase: {data.get('phase') or '-'}", + ( + "Parse failure: " + + str( + parse_event.get("kind") or "parse.invalid-syntax" + ).removeprefix("parse.") + ), + f"Safe attempted shape: {_history.parse_template(parse_event)}", + f"Repair: {repair_data.get('helpCommand') or 'hacksaws --help'}", + "Privacy: raw arguments and values were never stored.", + ) + ) + elif record.get("command") == "unknown" and record.get("outcome") == "usage-error": + lines.append( + "Parse detail: unavailable (recorded before safe structural capture)." + ) return "\n".join(lines) @@ -3209,6 +3575,9 @@ def _history_report(records: list[dict[str, object]]) -> dict[str, object]: outcomes: dict[str, int] = {} commands: dict[str, int] = {} duration = 0 + failures: dict[str, int] = {} + session_saves: dict[str, int] = {} + account_registrations: dict[str, int] = {} for record in records: outcome = str(record.get("outcome") or record.get("state") or "unknown") command = str(record.get("command") or "unknown") @@ -3217,25 +3586,65 @@ def _history_report(records: list[dict[str, object]]) -> dict[str, object]: value = record.get("durationMs") if type(value) is int: duration += value + parse_event = _history.parse_failure_event(record) + if parse_event is not None: + failure = str( + parse_event.get("kind") or "parse.invalid-syntax" + ).removeprefix("parse.") + failures[failure] = failures.get(failure, 0) + 1 + save_event = _history.session_save_event(record) + if save_event is not None: + save_status = str( + save_event.get("kind") or "session-save.unknown" + ).removeprefix("session-save.") + session_saves[save_status] = session_saves.get(save_status, 0) + 1 + registration_event = _history.account_registration_event(record) + if registration_event is not None: + registration_status = str( + registration_event.get("kind") or "account-registration.unknown" + ).removeprefix("account-registration.") + account_registrations[registration_status] = ( + account_registrations.get(registration_status, 0) + 1 + ) return { "count": len(records), "durationMs": duration, "outcomes": dict(sorted(outcomes.items())), "commands": dict(sorted(commands.items())), + "argumentFailures": dict(sorted(failures.items())), + "sessionSaves": dict(sorted(session_saves.items())), + "accountRegistrations": dict(sorted(account_registrations.items())), } def _history_report_text(report: dict[str, object]) -> str: outcomes = cast("dict[str, int]", report["outcomes"]) commands = cast("dict[str, int]", report["commands"]) - return "\n\n".join( - ( - f"Commands: {report['count']} Total duration: {report['durationMs']} ms", - "Outcomes\n" + _text_table(("OUTCOME", "COUNT"), list(outcomes.items())), - "Command families\n" - + _text_table(("COMMAND", "COUNT"), list(commands.items())), + failures = cast("dict[str, int]", report["argumentFailures"]) + session_saves = cast("dict[str, int]", report["sessionSaves"]) + account_registrations = cast("dict[str, int]", report["accountRegistrations"]) + sections = [ + f"Commands: {report['count']} Total duration: {report['durationMs']} ms", + "Outcomes\n" + _text_table(("OUTCOME", "COUNT"), list(outcomes.items())), + "Command families\n" + + _text_table(("COMMAND", "COUNT"), list(commands.items())), + ] + if failures: + sections.append( + "Argument failures\n" + + _text_table(("FAILURE", "COUNT"), list(failures.items())) ) - ) + if session_saves: + sections.append( + "Session saves\n" + + _text_table(("STATUS", "COUNT"), list(session_saves.items())) + ) + if account_registrations: + sections.append( + "Account registrations\n" + + _text_table(("STATUS", "COUNT"), list(account_registrations.items())) + ) + return "\n\n".join(sections) def _history_status_text(report: dict[str, object]) -> str: @@ -3245,6 +3654,12 @@ def _history_status_text(report: dict[str, object]) -> str: f"History database: {report['database']}", f"Health: {report['integrity']}", f"Records: {report['count']} ({report['running']} running)", + ( + f"Events: {report['events']} " + f"({report['parseFailures']} parse failures; " + f"{report['sessionSaves']} session saves; " + f"{report['accountRegistrations']} account registrations)" + ), f"Logical size: {report['logicalBytes']} bytes", f"Range: {report['oldest'] or '-'} to {report['newest'] or '-'}", ( @@ -3310,7 +3725,11 @@ def _run_history(args: argparse.Namespace) -> _configs.Result: ( "History database and safe records are valid." if report["ok"] - else f"History check found {report['corruptRecords']} corrupt records." + else ( + "History check found " + f"{report['corruptRecords']} corrupt records and " + f"{report['corruptEvents']} corrupt events." + ) ), 0 if report["ok"] else 1, data=report, @@ -3376,19 +3795,53 @@ def _console_main_invocation( raw_arguments = list(sys.argv[1:] if arguments is None else arguments) preselected_json = _json_requested(raw_arguments) _configs.configure_output(color="auto", json_output=preselected_json) + parser = _create_parser() + parse_observation = _history.observe_arguments_safely(parser, raw_arguments) try: normalized_arguments, requested_color, use_json = _extract_global_options( raw_arguments ) + except _configs.OperationalError as error: + if history_handle is not None: + _history.note_parse_failure( + history_handle, parse_observation, phase="global", kind="invalid-value" + ) + return _configs.Result( + "ARGUMENT_ERROR", f"Error: {error}", _configs.EXIT_USAGE, "stderr" + ).echo() + try: + normalized_arguments = _normalize_login_save_options(normalized_arguments) + except _configs.OperationalError as error: + if history_handle is not None: + _history.note_parse_failure( + history_handle, + parse_observation, + phase="semantic", + kind=( + "missing-option-value" + if getattr(error, "code", None) == "SAVE_NAME_REQUIRED" + else "invalid-combination" + ), + ) + return _configs.Result( + "ARGUMENT_ERROR", f"Error: {error}", _configs.EXIT_USAGE, "stderr" + ).echo() + try: _iam_cli.validate_selector_arguments(normalized_arguments) except _configs.OperationalError as error: + if history_handle is not None: + _history.note_parse_failure( + history_handle, + parse_observation, + phase="selector", + kind="conflicting-option", + ) return _configs.Result( "ARGUMENT_ERROR", f"Error: {error}", _configs.EXIT_USAGE, "stderr" ).echo() _configs.configure_output( color=cast("Any", requested_color or "auto"), json_output=use_json ) - parser = _create_parser() parse_stderr = io.StringIO() parse_stdout = io.StringIO() try: @@ -3402,6 +3855,10 @@ def _console_main_invocation( ): namespace = parser.parse_args(normalized_arguments) except SystemExit as error: + if error.code != 0 and history_handle is not None: + _history.note_parse_failure( + history_handle, parse_observation, phase="argparse" + ) result = _configs.Result( "HELP" if error.code == 0 else "ARGUMENT_ERROR", "" if error.code == 0 else parse_stderr.getvalue().strip(), @@ -3441,6 +3898,8 @@ def _console_main_invocation( ): namespace.mfa_code = namespace.profile namespace.profile = None + if history_handle is not None: + _history.enrich(history_handle, namespace) if namespace.access_type == "mfa" and namespace.action in {"login", "in"}: missing_code = namespace.mfa_code is None and not bool( getattr(namespace, "mfa_code_stdin", False) @@ -3450,6 +3909,13 @@ def _console_main_invocation( usage = parser.format_usage().strip() if not use_json: parser.print_usage(sys.stderr) + if history_handle is not None: + _history.note_parse_failure( + history_handle, + parse_observation, + phase="semantic", + kind="missing-required-option", + ) return _configs.Result( "ARGUMENT_ERROR", "the following arguments are required: PROFILE CODE or +TARGET CODE.", @@ -3457,8 +3923,6 @@ def _console_main_invocation( "stderr", {"usage": usage} if use_json else None, ).echo() - if history_handle is not None: - _history.enrich(history_handle, namespace) captured_stdout = io.StringIO() captured_stderr = io.StringIO() machine_stdin = _NonInteractiveStdin() @@ -3524,10 +3988,25 @@ def _console_main_invocation( else: result = _run_config(namespace) except _configs.OperationalError as error: + if ( + history_handle is not None + and int(getattr(error, "exit_code", _configs.EXIT_ERROR)) + == _configs.EXIT_USAGE + ): + _history.note_parse_failure( + history_handle, + parse_observation, + phase="semantic", + kind=( + "missing-option-value" + if getattr(error, "code", None) == "SAVE_NAME_REQUIRED" + else "invalid-combination" + ), + ) result = _configs.Result( getattr(error, "code", "OPERATIONAL_ERROR"), f"Error: {error}", - 1, + int(getattr(error, "exit_code", _configs.EXIT_ERROR)), "stderr", error.data, error.details, diff --git a/hacksaws/_history.py b/hacksaws/_history.py index ecdaf7b..e427b2b 100644 --- a/hacksaws/_history.py +++ b/hacksaws/_history.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import contextlib import fnmatch import json @@ -17,6 +18,7 @@ from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING +from typing import Any from typing import TypedDict from hacksaws import _audit @@ -26,11 +28,11 @@ from hacksaws._duration import parse_duration if TYPE_CHECKING: - import argparse from collections.abc import Iterator -SCHEMA_VERSION = 1 -REDACTION_VERSION = 1 +SCHEMA_VERSION = 2 +REDACTION_VERSION = 2 +EVENT_SCHEMA_VERSION = 1 DEFAULT_MAX_AGE = 90 * 24 * 60 * 60 DEFAULT_MAX_ENTRIES = 10_000 DEFAULT_MAX_BYTES = 50 * 1024 * 1024 @@ -50,6 +52,66 @@ _initialization_lock = threading.Lock() _initialized_databases: set[Path] = set() +_SECRET_DESTS = {"external_id", "mfa_code"} +_PATH_DESTS = { + "directory", + "file", + "metadata_file", + "output", + "source_directory", + "to_directory", + "trust_policy", + "zip", +} +_IDENTIFIER_DESTS = { + "account", + "aws_account_name", + "boundary", + "destination", + "location", + "policy", + "profile", + "region", + "resource_name", + "role", + "save_boundary", + "save_name", + "save_role_account", + "save_source_account", + "source_account", + "source_profile", + "store_policy_as", + "target", + "target_role", + "to", + "to_profile", +} +_DURATION_DESTS = {"duration", "htl", "lifespan", "mtl", "stl"} +_SAFE_FORMATS = {"json", "yaml", "yml", "toml", "zip"} +_MAX_OBSERVED_ITEMS = 64 +_MAX_OPAQUE_COUNT = 255 +_MAX_EVENT_BYTES = 4096 +_MAX_REGISTERED_ACCOUNTS = 2 +PARSE_FAILURE_KINDS = ( + "unknown-command", + "unknown-subcommand", + "unknown-option", + "misplaced-option", + "missing-command", + "missing-subcommand", + "missing-required-option", + "missing-option-value", + "invalid-choice", + "invalid-value", + "extra-positional", + "mutually-exclusive", + "duplicate-option", + "conflicting-option", + "invalid-combination", + "invalid-syntax", +) +PARSE_PHASES = ("global", "selector", "argparse", "semantic") + class HistoryError(RuntimeError): """Raised internally when best-effort history cannot be recorded.""" @@ -209,7 +271,15 @@ def _migrate(connection: sqlite3.Connection) -> None: "CREATE INDEX invocation_outcome " "ON invocations(outcome, started_at DESC)" ) - connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + connection.execute("PRAGMA user_version = 1") + version = 1 + if version == 1: + with connection: + connection.execute( + "CREATE INDEX IF NOT EXISTS event_invocation_kind " + "ON events(invocation_id, kind, occurred_at, id)" + ) + connection.execute("PRAGMA user_version = 2") def _schema_too_new_message(version: int) -> str: @@ -249,6 +319,418 @@ def _canonical_command(args: argparse.Namespace) -> tuple[str, str | None]: return ".".join(parts), alias +def _parser_children( + parser: argparse.ArgumentParser, +) -> tuple[argparse._SubParsersAction[Any] | None, dict[str, argparse.ArgumentParser]]: + for action in parser._actions: # noqa: SLF001 - argparse exposes no public tree API + if isinstance(action, argparse._SubParsersAction): # noqa: SLF001 + return action, dict(action.choices) + return None, {} + + +def _option_catalog( + parser: argparse.ArgumentParser, +) -> dict[str, tuple[argparse.Action, str]]: + """Return every registered spelling with a stable canonical long name.""" + catalog: dict[str, tuple[argparse.Action, str]] = {} + seen: set[int] = set() + pending = [parser] + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + for action in current._actions: # noqa: SLF001 + if action.option_strings: + canonical = next( + ( + value + for value in action.option_strings + if value.startswith("--") + ), + action.option_strings[0], + ).lstrip("-") + for spelling in action.option_strings: + catalog.setdefault(spelling, (action, canonical)) + if isinstance(action, argparse._SubParsersAction): # noqa: SLF001 + pending.extend(action.choices.values()) + return catalog + + +def _active_options( + parsers: list[argparse.ArgumentParser], +) -> dict[str, tuple[argparse.Action, str]]: + result: dict[str, tuple[argparse.Action, str]] = {} + for parser in parsers: + for action in parser._actions: # noqa: SLF001 + if not action.option_strings: + continue + canonical = next( + (value for value in action.option_strings if value.startswith("--")), + action.option_strings[0], + ).lstrip("-") + for spelling in action.option_strings: + result[spelling] = (action, canonical) + return result + + +def _value_class(action: argparse.Action) -> str: + if action.dest in _SECRET_DESTS: + return "secret" + if action.dest in _PATH_DESTS: + return "path" + if action.choices is not None: + return "enum" + if action.dest in _DURATION_DESTS: + return "duration" + if action.dest in _IDENTIFIER_DESTS: + return "identifier" + return "value" + + +def _takes_value(action: argparse.Action) -> bool: + return action.nargs != 0 + + +def _safe_format(value: str) -> str | None: + suffix = Path(value).suffix.casefold().removeprefix(".") + return suffix if suffix in _SAFE_FORMATS else ("other" if suffix else None) + + +def _canonical_choice( + choices: dict[str, argparse.ArgumentParser], value: str +) -> tuple[str, argparse.ArgumentParser] | None: + selected = choices.get(value) + if selected is None: + return None + canonical = next( + (name for name, parser in choices.items() if parser is selected), value + ) + return canonical, selected + + +def observe_arguments( # noqa: C901, PLR0912, PLR0915 + parser: argparse.ArgumentParser, arguments: list[str] +) -> dict[str, object]: + """Build a bounded grammar-only observation without retaining raw values.""" + catalog = _option_catalog(parser) + catalog.update( + { + "--json": (argparse.Action([], "json", nargs=0), "json"), + "--no-color": (argparse.Action([], "color", nargs=0), "no-color"), + "--color": (argparse.Action([], "color", nargs=None), "color"), + } + ) + parsers = [parser] + current = parser + command: list[str] = [] + aliases: list[str] = [] + options: dict[str, dict[str, object]] = {} + positionals: list[dict[str, object]] = [] + positional_index = 0 + opaque_options = 0 + opaque_positionals = 0 + opaque_tail = 0 + misplaced: list[str] = [] + missing_values: list[str] = [] + truncated = False + index = 0 + while index < len(arguments): + token = arguments[index] + if token == "--": # noqa: S105 + opaque_tail = min(len(arguments) - index - 1, _MAX_OPAQUE_COUNT) + truncated = truncated or len(arguments) - index - 1 > _MAX_OPAQUE_COUNT + break + spelling, equals, attached = token.partition("=") + if token.startswith("-"): + active = _active_options(parsers) + found = active.get(spelling) + misplaced_option = False + if found is None: + found = catalog.get(spelling) + misplaced_option = found is not None + if found is None: + opaque_options = min(opaque_options + 1, _MAX_OPAQUE_COUNT) + truncated = truncated or opaque_options == _MAX_OPAQUE_COUNT + index += 1 + continue + action, canonical = found + if misplaced_option and len(misplaced) < _MAX_OBSERVED_ITEMS: + misplaced.append(canonical) + item = options.setdefault( + canonical, + { + "name": canonical, + "count": 0, + "valueClass": _value_class(action), + "valueState": "none" if not _takes_value(action) else "missing", + }, + ) + previous_count = item.get("count") + count = previous_count if isinstance(previous_count, int) else 0 + item["count"] = min(count + 1, _MAX_OPAQUE_COUNT) + if _takes_value(action): + value: str | None = attached if equals else None + if value is None and index + 1 < len(arguments): + candidate = arguments[index + 1] + if candidate != "--" and not candidate.startswith("-"): + value = candidate + index += 1 + if value is None or value == "": + if canonical not in missing_values: + missing_values.append(canonical) + else: + item["valueState"] = "present" + if action.choices is not None: + item["valueState"] = ( + "valid" if value in action.choices else "invalid" + ) + if item["valueClass"] == "path": + format_name = _safe_format(value) + if format_name: + item["format"] = format_name + index += 1 + continue + subparsers, choices = _parser_children(current) + if subparsers is not None: + choice = _canonical_choice(choices, token) + if choice is not None: + canonical, selected = choice + command.append(canonical) + if canonical != token: + aliases.append(token) + elif len(command) == 1 and token == "web": # noqa: S105 + command[-1] = "pk" + aliases.append("web") + current = selected + parsers.append(selected) + positional_index = 0 + index += 1 + continue + opaque_positionals = min(opaque_positionals + 1, _MAX_OPAQUE_COUNT) + index += 1 + continue + positional_actions = [ + action + for action in current._actions # noqa: SLF001 + if not action.option_strings + and not isinstance(action, argparse._SubParsersAction) # noqa: SLF001 + and action.dest != argparse.SUPPRESS + ] + if positional_index < len(positional_actions): + action = positional_actions[positional_index] + if len(positionals) < _MAX_OBSERVED_ITEMS: + positionals.append( + { + "role": action.dest.replace("_", "-"), + "valueClass": _value_class(action), + "present": True, + } + ) + if action.nargs not in {"*", "+"}: + positional_index += 1 + else: + opaque_positionals = min(opaque_positionals + 1, _MAX_OPAQUE_COUNT) + index += 1 + if not command and opaque_positionals: + inferred = "unknown-command" + elif missing_values: + inferred = "missing-option-value" + elif misplaced: + inferred = "misplaced-option" + elif opaque_options: + inferred = "unknown-option" + elif opaque_positionals: + inferred = "extra-positional" + else: + inferred = "invalid-syntax" + canonical_command = ".".join(command) if command else "unknown" + help_command = "hacksaws " + canonical_command.replace(".", " ") + if canonical_command != "unknown": + help_command += " --help" + return { + "command": canonical_command, + "alias": ".".join(aliases) or None, + "options": list(options.values())[:_MAX_OBSERVED_ITEMS], + "positionals": positionals, + "opaque": { + "options": opaque_options, + "positionals": opaque_positionals, + "tail": opaque_tail, + "truncated": truncated, + }, + "misplacedOptions": sorted(set(misplaced)), + "missingValues": missing_values[:_MAX_OBSERVED_ITEMS], + "inferredKind": inferred, + "helpCommand": help_command, + } + + +def observe_arguments_safely( + parser: argparse.ArgumentParser, arguments: list[str] +) -> dict[str, object]: + """Isolate history observation failures from command execution.""" + try: + return observe_arguments(parser, arguments) + except Exception: # noqa: BLE001 - telemetry must never change CLI behavior. + return { + "command": "unknown", + "alias": None, + "options": [], + "positionals": [], + "opaque": { + "options": 0, + "positionals": 0, + "tail": 0, + "truncated": True, + }, + "misplacedOptions": [], + "missingValues": [], + "inferredKind": "invalid-syntax", + "helpCommand": "hacksaws --help", + } + + +def note_parse_failure( + handle: HistoryHandle, + observation: dict[str, object], + *, + phase: str, + kind: str | None = None, +) -> None: + """Persist one bounded parse event without raw argv or error text.""" + if not handle.enabled or handle.id is None: + return + selected_kind = kind or str(observation.get("inferredKind") or "invalid-syntax") + if not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", selected_kind): + selected_kind = "invalid-syntax" + safe_phase = ( + phase if phase in {"global", "selector", "argparse", "semantic"} else "argparse" + ) + payload = { + "eventSchemaVersion": EVENT_SCHEMA_VERSION, + "redactionVersion": REDACTION_VERSION, + "phase": safe_phase, + "command": observation.get("command", "unknown"), + "alias": observation.get("alias"), + "structure": { + key: observation.get(key) + for key in ( + "options", + "positionals", + "opaque", + "misplacedOptions", + "missingValues", + ) + }, + "repair": {"helpCommand": observation.get("helpCommand", "hacksaws --help")}, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")) + if len(encoded.encode("utf-8")) > _MAX_EVENT_BYTES: + payload["structure"] = { + "opaque": observation.get("opaque", {}), + "truncated": True, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")) + try: + with _database() as connection: + connection.execute( + "UPDATE invocations SET command = ?, " + "alias_used = COALESCE(?, alias_used), updated_at = ? WHERE id = ?", + ( + observation.get("command", "unknown"), + observation.get("alias"), + _now(), + handle.id, + ), + ) + connection.execute( + "INSERT INTO events (invocation_id, occurred_at, kind, data_json) " + "VALUES (?, ?, ?, ?)", + (handle.id, _now(), f"parse.{selected_kind}", encoded), + ) + except (OSError, sqlite3.Error, HistoryError): + return + + +def note_session_save( + *, + status: str, + target: object = None, + boundary: object = None, + requested: bool, + credentials_active: bool, +) -> None: + """Record only the safe outcome of a post-credential configuration save.""" + if status not in {"saved", "noop", "failed", "cancelled"}: + return + identifier = _current.get() + if identifier is None: + return + payload = { + "eventSchemaVersion": EVENT_SCHEMA_VERSION, + "redactionVersion": REDACTION_VERSION, + "status": status, + "target": _safe_identifier(target), + "boundary": _safe_identifier(boundary), + "requested": requested, + "credentialsActive": credentials_active, + } + try: + with _database() as connection: + connection.execute( + "INSERT INTO events (invocation_id, occurred_at, kind, data_json) " + "VALUES (?, ?, ?, ?)", + ( + identifier, + _now(), + f"session-save.{status}", + json.dumps(payload, sort_keys=True, separators=(",", ":")), + ), + ) + except (OSError, sqlite3.Error, HistoryError): + return + + +def note_account_registration( + *, status: str, created: int, reused: int, refreshed: int +) -> None: + """Record safe account-registration counts separately from bundle saves.""" + if status not in {"completed", "failed"}: + return + counts = (created, reused, refreshed) + if any( + type(value) is not int or not 0 <= value <= _MAX_REGISTERED_ACCOUNTS + for value in counts + ): + return + identifier = _current.get() + if identifier is None: + return + payload = { + "eventSchemaVersion": EVENT_SCHEMA_VERSION, + "redactionVersion": REDACTION_VERSION, + "status": status, + "created": created, + "reused": reused, + "refreshed": refreshed, + } + try: + with _database() as connection: + connection.execute( + "INSERT INTO events (invocation_id, occurred_at, kind, data_json) " + "VALUES (?, ?, ?, ?)", + ( + identifier, + _now(), + f"account-registration.{status}", + json.dumps(payload, sort_keys=True, separators=(",", ":")), + ), + ) + except (OSError, sqlite3.Error, HistoryError): + return + + def _safe_identifier(value: object) -> str | None: if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value): return None @@ -302,12 +784,17 @@ def _safe_namespace(args: argparse.Namespace) -> dict[str, object]: value = values.get(key) if value is None: continue - suffix = Path(str(value)).suffix.casefold().removeprefix(".") or "unknown" - input_kinds.append({"role": key.replace("_", "-"), "format": suffix}) + input_kinds.append( + { + "role": key.replace("_", "-"), + "format": _safe_format(str(value)) or "other", + } + ) policy = values.get("policy") if isinstance(policy, str) and _looks_like_file(policy): - suffix = Path(policy).suffix.casefold().removeprefix(".") or "unknown" - input_kinds.append({"role": "policy-file", "format": suffix}) + input_kinds.append( + {"role": "policy-file", "format": _safe_format(policy) or "other"} + ) identifiers: dict[str, str] = {} for key in ( "profile", @@ -577,6 +1064,13 @@ def finish(handle: HistoryHandle, result: object) -> None: and "yes" in flags ): confirmation = "yes-flag:bypassed" + event_bytes = int( + connection.execute( + "SELECT COALESCE(SUM(LENGTH(data_json)), 0) FROM events " + "WHERE invocation_id = ?", + (handle.id,), + ).fetchone()[0] + ) connection.execute( """ UPDATE invocations SET @@ -602,7 +1096,7 @@ def finish(handle: HistoryHandle, result: object) -> None: selected.get("accountId"), selected.get("partition"), confirmation, - len(encoded.encode("utf-8")) + 512, + len(encoded.encode("utf-8")) + event_bytes + 512, ended, handle.id, ), @@ -702,7 +1196,38 @@ def _maintain() -> None: ) -def _row_data(row: sqlite3.Row) -> dict[str, object]: +def _event_data(row: sqlite3.Row) -> dict[str, object]: + try: + data = json.loads(row["data_json"]) + except json.JSONDecodeError: + data = {"corrupt": True} + return { + "kind": row["kind"], + "occurredAt": row["occurred_at"], + "data": data, + } + + +def _events_for( + connection: sqlite3.Connection, identifiers: list[str] +) -> dict[str, list[dict[str, object]]]: + if not identifiers: + return {} + placeholders = ",".join("?" for _identifier in identifiers) + query = ( + "SELECT invocation_id, occurred_at, kind, data_json FROM events " # noqa: S608 + f"WHERE invocation_id IN ({placeholders}) ORDER BY occurred_at, id" + ) + rows = connection.execute(query, identifiers).fetchall() + result: dict[str, list[dict[str, object]]] = {} + for row in rows: + result.setdefault(str(row["invocation_id"]), []).append(_event_data(row)) + return result + + +def _row_data( + row: sqlite3.Row, events: list[dict[str, object]] | None = None +) -> dict[str, object]: return { "schemaVersion": SCHEMA_VERSION, "redactionVersion": REDACTION_VERSION, @@ -729,11 +1254,12 @@ def _row_data(row: sqlite3.Row) -> dict[str, object]: "exitCode": row["exit_code"], "durationMs": row["duration_ms"], "safe": json.loads(row["safe_json"]), + "events": events or [], "recoveryUnresolved": bool(row["recovery_unresolved"]), } -def list_records( # noqa: PLR0913 +def list_records( # noqa: C901, PLR0913 *, patterns: tuple[str, ...] = (), since: datetime | None = None, @@ -742,6 +1268,8 @@ def list_records( # noqa: PLR0913 outcome: str | None = None, account: str | None = None, resource: str | None = None, + failure: str | None = None, + phase: str | None = None, limit: int = 50, include_running: bool = False, ) -> list[dict[str, object]]: @@ -769,7 +1297,8 @@ def list_records( # noqa: PLR0913 if resource: clauses.append("(resource_name LIKE ? OR resource_arn LIKE ?)") params.extend((f"%{resource}%", f"%{resource}%")) - params.append(max(1, min(limit, 10_000))) + selected_limit = max(1, min(limit, 10_000)) + params.append(10_000 if failure or phase else selected_limit) try: with _database() as connection: rows = connection.execute( @@ -778,9 +1307,34 @@ def list_records( # noqa: PLR0913 + " ORDER BY started_at DESC, id DESC LIMIT ?", params, ).fetchall() + events = _events_for(connection, [str(row["id"]) for row in rows]) except (OSError, sqlite3.Error, HistoryError) as error: raise OperationalError(_read_error_message(error)) from error - values = [_row_data(row) for row in rows] + values = [_row_data(row, events.get(str(row["id"]), [])) for row in rows] + if failure or phase: + filtered: list[dict[str, object]] = [] + for item in values: + item_events = item.get("events") + if not isinstance(item_events, list): + continue + parse_events = [ + event + for event in item_events + if isinstance(event, dict) + and str(event.get("kind", "")).startswith("parse.") + ] + if failure and not any( + event.get("kind") == f"parse.{failure}" for event in parse_events + ): + continue + if phase and not any( + isinstance(event.get("data"), dict) + and event["data"].get("phase") == phase + for event in parse_events + ): + continue + filtered.append(item) + values = filtered[:selected_limit] if not patterns: return values folded = tuple(pattern.casefold() for pattern in patterns) @@ -799,6 +1353,7 @@ def list_records( # noqa: PLR0913 "resourceArn", "profile", "target", + "events", ) ).casefold(), pattern @@ -822,7 +1377,9 @@ def get_record(identifier: str) -> dict[str, object]: raise OperationalError(_missing_id_message(identifier)) if len(rows) > 1: raise OperationalError(_ambiguous_id_message(identifier)) - return _row_data(rows[0]) + with _database() as connection: + events = _events_for(connection, [str(rows[0]["id"])]) + return _row_data(rows[0], events.get(str(rows[0]["id"]), [])) def command_template(record: dict[str, object]) -> str: @@ -846,6 +1403,84 @@ def command_template(record: dict[str, object]) -> str: return " ".join(parts) +def parse_failure_event(record: dict[str, object]) -> dict[str, object] | None: + """Return the first safe parse-failure event attached to one invocation.""" + events = record.get("events") + if not isinstance(events, list): + return None + return next( + ( + event + for event in events + if isinstance(event, dict) + and str(event.get("kind", "")).startswith("parse.") + ), + None, + ) + + +def session_save_event(record: dict[str, object]) -> dict[str, object] | None: + """Return the safe post-credential configuration-save event, when present.""" + events = record.get("events") + if not isinstance(events, list): + return None + return next( + ( + event + for event in reversed(events) + if isinstance(event, dict) + and str(event.get("kind", "")).startswith("session-save.") + ), + None, + ) + + +def account_registration_event( + record: dict[str, object], +) -> dict[str, object] | None: + """Return the safe automatic account-registration outcome, when present.""" + events = record.get("events") + if not isinstance(events, list): + return None + return next( + ( + event + for event in reversed(events) + if isinstance(event, dict) + and str(event.get("kind", "")).startswith("account-registration.") + ), + None, + ) + + +def parse_template(event: dict[str, object]) -> str: + """Render only structural placeholders from one redacted parse event.""" + data = event.get("data") + payload = data if isinstance(data, dict) else {} + command = str(payload.get("command") or "unknown").replace(".", " ") + parts = ["hacksaws", command] + structure = payload.get("structure") + safe_structure = structure if isinstance(structure, dict) else {} + positionals = safe_structure.get("positionals") + if isinstance(positionals, list): + parts.extend( + f"<{item['role']}>" + for item in positionals + if isinstance(item, dict) and isinstance(item.get("role"), str) + ) + options = safe_structure.get("options") + if isinstance(options, list): + for item in options: + if not isinstance(item, dict) or not isinstance(item.get("name"), str): + continue + parts.append(f"--{item['name']}") + if ( + item.get("valueClass") != "value" or item.get("valueState") != "none" + ) and item.get("valueState") != "none": + parts.append(f"<{item.get('valueClass') or 'value'}>") + return " ".join(parts) + + def _template_identifiers(identifiers: dict[object, object]) -> list[str]: parts: list[str] = [] for key in ( @@ -894,6 +1529,13 @@ def status() -> dict[str, object]: query += " WHERE id != ?" parameters = (current,) row = connection.execute(query, parameters).fetchone() + event_query = ( + "SELECT COUNT(*), SUM(kind LIKE 'parse.%'), " + "SUM(kind LIKE 'session-save.%'), " + "SUM(kind LIKE 'account-registration.%') FROM events " + "WHERE invocation_id IS NOT NULL" + ) + event_row = connection.execute(event_query).fetchone() except (OSError, sqlite3.Error, HistoryError) as error: raise OperationalError(_inspect_error_message(error)) from error return { @@ -904,6 +1546,10 @@ def status() -> dict[str, object]: "newest": row[2], "running": int(row[3] or 0), "logicalBytes": int(row[4]), + "events": int(event_row[0] or 0), + "parseFailures": int(event_row[1] or 0), + "sessionSaves": int(event_row[2] or 0), + "accountRegistrations": int(event_row[3] or 0), "retention": settings, } @@ -953,10 +1599,54 @@ def check() -> dict[str, object]: corrupt += 1 except json.JSONDecodeError: corrupt += 1 + corrupt_events = 0 + for row in connection.execute("SELECT kind, data_json FROM events"): + if row["kind"] == "retention": + continue + try: + value = json.loads(row["data_json"]) + except json.JSONDecodeError: + corrupt_events += 1 + continue + kind = str(row["kind"]) + versioned = ( + isinstance(value, dict) + and value.get("eventSchemaVersion") == EVENT_SCHEMA_VERSION + and value.get("redactionVersion") == REDACTION_VERSION + ) + parse_valid = kind.startswith("parse.") and versioned + save_status = kind.removeprefix("session-save.") + save_valid = ( + kind.startswith("session-save.") + and versioned + and save_status in {"saved", "noop", "failed", "cancelled"} + and value.get("status") == save_status + and type(value.get("requested")) is bool + and type(value.get("credentialsActive")) is bool + and all( + item is None or _safe_identifier(item) == item + for item in (value.get("target"), value.get("boundary")) + ) + ) + registration_status = kind.removeprefix("account-registration.") + registration_valid = ( + kind.startswith("account-registration.") + and versioned + and registration_status in {"completed", "failed"} + and value.get("status") == registration_status + and all( + type(value.get(field)) is int + and 0 <= value[field] <= _MAX_REGISTERED_ACCOUNTS + for field in ("created", "reused", "refreshed") + ) + ) + if not (parse_valid or save_valid or registration_valid): + corrupt_events += 1 return { **report, "corruptRecords": corrupt, - "ok": report["integrity"] == "ok" and corrupt == 0, + "corruptEvents": corrupt_events, + "ok": report["integrity"] == "ok" and corrupt == 0 and corrupt_events == 0, } diff --git a/hacksaws/_session_save.py b/hacksaws/_session_save.py new file mode 100644 index 0000000..8aa52db --- /dev/null +++ b/hacksaws/_session_save.py @@ -0,0 +1,750 @@ +"""Plan and persist reusable configuration from successful credential sessions. + +The authentication transaction deliberately does not include this module's writes. +Callers prepare before authentication, discover accounts with the intermediate +credentials, commit the credential transaction, and only then call :func:`persist`. +""" + +# ruff: noqa: C901, N818, PLR0912, PLR0913, PLR0915, SLF001, TRY003, TRY301 + +from __future__ import annotations + +import copy +import dataclasses +import re +import sys +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any + +import yaml + +from hacksaws import _account_discovery +from hacksaws import _duration +from hacksaws import _policies +from hacksaws import _state +from hacksaws._configs import OperationalError + +if TYPE_CHECKING: + import argparse + from collections.abc import Callable + + +class SaveCancelled(OperationalError): + """Raised after login when an interactive save is declined.""" + + +class SavePlanChanged(OperationalError): + """Raised when configuration changes between save planning and persistence.""" + + +@dataclasses.dataclass(frozen=True) +class SavePlan: + """Secret-bearing, in-memory-only plan for an optional post-login save.""" + + requested: bool + name: str | None + source_directory: Path + source_profile: str + destination_directory: Path + destination_profile: str + region: str + source_account_name: str | None + role_account_name: str | None + boundary_name: str | None + save_external_id: bool + store_policy_as: str | None + role: str | None + policy: str | None + external_id: str | None = dataclasses.field(default=None, repr=False) + duration: int | None = None + description: str | None = None + interactive: bool = False + config_fingerprint: str | None = dataclasses.field(default=None, repr=False) + + +@dataclasses.dataclass(frozen=True) +class SaveAccounts: + """Account discoveries made with the pre-boundary credential tier.""" + + source: _account_discovery.AccountDiscovery + role: _account_discovery.AccountDiscovery | None + + +@dataclasses.dataclass(frozen=True) +class SaveOutcome: + """Safe description of one completed configuration save.""" + + target: str | None + boundary: str | None + source_account: str + role_account: str | None + changed: bool + policy: str | None = None + bundle_requested: bool = True + bundle_changed: bool | None = None + accounts_created: int = 0 + accounts_reused: int = 0 + accounts_refreshed: int = 0 + + +def _fingerprint(path: Path) -> str | None: + return _state.digest(path.read_bytes()) if path.exists() else None + + +def _logical_location(directory: Path) -> str | None: + absolute = directory.expanduser().absolute() + default = (Path.home() / ".aws").absolute() + if absolute == default: + return "default" + if absolute.parent == Path.home().absolute() and absolute.name.startswith(".aws-"): + return absolute.name[5:] or None + return None + + +def _endpoint(directory: Path, profile: str, *, prefix: str) -> dict[str, str]: + location = _logical_location(directory) + result = {f"{prefix}_profile": profile} + if location is None: + result[f"{prefix}_directory"] = str(directory.expanduser().absolute()) + else: + result[f"{prefix}_location"] = location + return result + + +def _configured_boundary( + args: argparse.Namespace, config: dict[str, Any] +) -> tuple[dict[str, Any], dict[str, Any]]: + target: dict[str, Any] = {} + if getattr(args, "target", None): + _, configured = _state.get_resource( + config, "target", str(args.target).lstrip("+") + ) + target = dict(configured) + boundary_name = getattr(args, "boundary", None) or target.get("boundary") + boundary: dict[str, Any] = {} + if boundary_name: + _, configured = _state.get_resource(config, "boundary", str(boundary_name)) + boundary = dict(configured) + return target, boundary + + +def _requested_duration( + args: argparse.Namespace, boundary: dict[str, Any] +) -> int | None: + supplied = any( + getattr(args, key, None) is not None + for key in ("duration", "htl", "mtl", "stl") + ) + if supplied: + return _duration.session_duration( + duration=getattr(args, "duration", None), + htl=getattr(args, "htl", None), + mtl=getattr(args, "mtl", None), + stl=getattr(args, "stl", None), + ) + configured = boundary.get("duration") + return configured if isinstance(configured, int) else None + + +def _known_target_conflict( + config: dict[str, Any], name: str, expected: dict[str, str] +) -> None: + key = _state._find_key(config["targets"], name) + if key is None: + return + current = config["targets"][key] + for field, value in expected.items(): + if field in current and current[field] != value: + raise OperationalError( + f"Target {key!r} already exists with a different " + f"{field.replace('_', ' ')}. " + "Choose another --save name; login was not attempted." + ) + + +def prepare( + args: argparse.Namespace, + *, + source_directory: Path, + source_profile: str, + destination_directory: Path, + destination_profile: str, + region: str, +) -> SavePlan: + """Freeze locally knowable save inputs and reject known conflicts pre-auth.""" + config = _state.load_config() + requested = bool(getattr(args, "save", False) or getattr(args, "save_name", None)) + advanced = { + "--save-source-account": getattr(args, "save_source_account", None), + "--save-role-account": getattr(args, "save_role_account", None), + "--save-boundary": getattr(args, "save_boundary", None), + "--save-external-id": getattr(args, "save_external_id", False), + "--store-policy-as": getattr(args, "store_policy_as", None), + } + if any(advanced.values()) and not requested: + names = ", ".join(name for name, value in advanced.items() if value) + raise OperationalError(f"{names} require --save or --save-name.") + name = getattr(args, "save_name", None) + for value, kind in ( + (name, "target"), + (getattr(args, "save_source_account", None), "account"), + (getattr(args, "save_role_account", None), "account"), + (getattr(args, "save_boundary", None), "boundary"), + (getattr(args, "store_policy_as", None), "policy"), + ): + if value is not None: + _state.validate_name(str(value), kind=kind) + _target, boundary = _configured_boundary(args, config) + role = getattr(args, "role", None) or boundary.get("role_arn") + policy = getattr(args, "policy", None) or boundary.get("policy") + external_id = getattr(args, "external_id", None) or boundary.get("external_id") + boundary_name = getattr(args, "save_boundary", None) + if requested and role and not boundary_name and name: + boundary_name = name + if requested and not role and boundary_name: + raise OperationalError("--save-boundary requires a role to save.") + if requested and not role and getattr(args, "save_role_account", None): + raise OperationalError("--save-role-account requires a role to save.") + if requested and not role and bool(getattr(args, "save_external_id", False)): + raise OperationalError("--save-external-id requires a role to save.") + if requested and name: + known_target = { + **_endpoint(source_directory, source_profile, prefix="source"), + **( + _endpoint( + destination_directory, + destination_profile, + prefix="destination", + ) + if ( + source_directory.absolute() != destination_directory.absolute() + or source_profile != destination_profile + ) + else {} + ), + "region": region, + } + if boundary_name: + known_target["boundary"] = str(boundary_name) + _known_target_conflict(config, str(name), known_target) + store_policy_as = getattr(args, "store_policy_as", None) + if store_policy_as and policy and not _looks_local_policy(str(policy)): + raise OperationalError( + "--store-policy-as is only valid when the session policy is a local file." + ) + if store_policy_as and not policy: + raise OperationalError("--store-policy-as requires a session policy.") + return SavePlan( + requested=requested, + name=str(name) if name else None, + source_directory=source_directory.expanduser().absolute(), + source_profile=source_profile, + destination_directory=destination_directory.expanduser().absolute(), + destination_profile=destination_profile, + region=region, + source_account_name=( + str(args.save_source_account) + if getattr(args, "save_source_account", None) + else None + ), + role_account_name=( + str(args.save_role_account) + if getattr(args, "save_role_account", None) + else None + ), + boundary_name=str(boundary_name) if boundary_name else None, + save_external_id=bool(getattr(args, "save_external_id", False)), + store_policy_as=str(store_policy_as) if store_policy_as else None, + role=str(role) if role else None, + policy=str(policy) if policy else None, + external_id=str(external_id) if external_id else None, + duration=_requested_duration(args, boundary), + description=( + str(args.description) if getattr(args, "description", None) else None + ), + interactive=not bool(getattr(args, "json", False)) and sys.stdin.isatty(), + config_fingerprint=_fingerprint(_state.root() / "config.json"), + ) + + +def _looks_local_policy(value: str) -> bool: + if _policies.POLICY_ARN.fullmatch(value): + return False + candidate = Path(value).expanduser() + return ( + value == "-" + or value.startswith((".", "~", "/", "\\")) + or "/" in value + or "\\" in value + or candidate.suffix.casefold() in _policies.PATH_SUFFIXES + ) + + +def discover_accounts( + plan: SavePlan, + session: _account_discovery.IntermediateSession, + *, + role_arn: str | None, +) -> SaveAccounts: + """Discover source and optional role accounts with the intermediate session.""" + config = _state.load_config() + source = _account_discovery.discover_account( + session, + config, + explicit_name=plan.source_account_name, + ) + role = None + if role_arn: + role_config = copy.deepcopy(config) + source_record = source.account_record(verified=True) + if not source.existing: + source_record.setdefault("region", plan.region) + role_config["accounts"].setdefault(source.key, source_record) + role = _account_discovery.discover_account( + session, + role_config, + explicit_name=plan.role_account_name, + role_arn=role_arn, + ) + return SaveAccounts(source=source, role=role) + + +def _session_discovery( + config: dict[str, Any], + *, + account_id: str, + partition: str, + explicit_name: str | None, +) -> _account_discovery.AccountDiscovery: + existing = next( + ( + (name, value) + for name, value in config["accounts"].items() + if value.get("id") == account_id and value.get("partition") == partition + ), + None, + ) + if existing: + key = existing[0] + source = "existing" + elif explicit_name: + collision = _state._find_key(config["accounts"], explicit_name) + if collision is not None: + value = config["accounts"][collision] + if value.get("id") != account_id or value.get("partition") != partition: + raise OperationalError( + f"Account {collision!r} already refers to a different AWS account." + ) + key = collision or explicit_name + source = "existing" if collision else "user" + else: + key = f"account-{account_id}" + source = "account-id" + record = config["accounts"].get(key, {}) + display = str(record.get("display_name") or key) + display_source = str(record.get("display_source") or source) + identity = _account_discovery.AccountIdentity( + partition=partition, + account_id=account_id, + arn=f"arn:{partition}:iam::{account_id}:root", + verified=True, + ) + return _account_discovery.AccountDiscovery( + identity=identity, + source_identity=identity, + key=key, + key_source=source, # type: ignore[arg-type] + display_name=display, + display_source=display_source, # type: ignore[arg-type] + existing=bool(record), + _existing_record=record or None, + ) + + +def accounts_from_session(plan: SavePlan, session: dict[str, Any]) -> SaveAccounts: + """Reconstruct account discoveries from verified managed-session lineage.""" + config = _state.load_config() + source_id = str(session.get("source_account") or "") + source_partition = str(session.get("source_partition") or "") + if ( + not re.fullmatch(r"\d{12}", source_id) + or source_partition not in _state.PARTITIONS + ): + raise OperationalError( + "Managed session lacks a valid source account and partition." + ) + source = _session_discovery( + config, + account_id=source_id, + partition=source_partition, + explicit_name=plan.source_account_name, + ) + role = None + role_arn = session.get("role") + if isinstance(role_arn, str) and role_arn: + role_partition, role_account, _resource = _state.parse_role_arn(role_arn) + role_config = copy.deepcopy(config) + source_record = source.account_record(verified=True) + if not source.existing: + source_record.setdefault("region", plan.region) + role_config["accounts"].setdefault(source.key, source_record) + role = _session_discovery( + role_config, + account_id=role_account, + partition=role_partition, + explicit_name=plan.role_account_name, + ) + return SaveAccounts(source=source, role=role) + + +def accounts_from_identity( + plan: SavePlan, + *, + source_account: str, + source_partition: str, + role_arn: str | None, +) -> SaveAccounts: + """Build deterministic account records when optional discovery is unavailable.""" + config = _state.load_config() + source = _session_discovery( + config, + account_id=source_account, + partition=source_partition, + explicit_name=plan.source_account_name, + ) + role = None + if role_arn: + role_partition, role_account, _resource = _state.parse_role_arn(role_arn) + role_config = copy.deepcopy(config) + source_record = source.account_record(verified=True) + if not source.existing: + source_record.setdefault("region", plan.region) + role_config["accounts"].setdefault(source.key, source_record) + role = _session_discovery( + role_config, + account_id=role_account, + partition=role_partition, + explicit_name=plan.role_account_name, + ) + return SaveAccounts(source=source, role=role) + + +def _prompt_name(prompt: str, default: str) -> str: + try: + value = input(f"{prompt} [{default}]: ").strip() + except (EOFError, KeyboardInterrupt) as error: + raise SaveCancelled("Configuration save cancelled.") from error + if value.casefold() in {"cancel", "quit", "q"}: + raise SaveCancelled("Configuration save cancelled.") + return _state.validate_name(value or default) + + +def _policy_output( + plan: SavePlan, *, target_name: str +) -> tuple[str | None, Path | None, bytes | None]: + policy = plan.policy + if not policy: + return None, None, None + if not _looks_local_policy(policy): + return policy, None, None + source = Path(policy).expanduser().absolute() + document, raw = _policies.parse_policy(source) + stored_name = plan.store_policy_as + if stored_name is None: + if not plan.interactive: + raise OperationalError( + "Saving a local session policy noninteractively requires " + "--store-policy-as NAME." + ) + stem = re.sub(r"[^A-Za-z0-9._-]+", "-", source.stem).strip("-._") + default = (stem or f"{target_name}-policy")[:64] + if not default[0].isalnum(): + default = f"policy-{default}"[:64] + stored_name = _prompt_name("Stored policy name", default) + _state.validate_name(stored_name, kind="policy") + encoded = ( + raw + if source.suffix.casefold() in {".yaml", ".yml"} + else yaml.safe_dump(document, sort_keys=False).encode() + ) + return stored_name, _policies.stored_directory() / f"{stored_name}.yaml", encoded + + +def _canonical_policy( + plan: SavePlan, + session: dict[str, Any], + *, + target_name: str, +) -> tuple[str | None, Path | None, bytes | None]: + origin = session.get("policy_origin") + if origin == "local" or (plan.policy and _looks_local_policy(plan.policy)): + if not plan.policy: + raise OperationalError( + "The managed session used a local policy whose source path was not " + "retained; supply --policy FILE and --store-policy-as NAME." + ) + return _policy_output(plan, target_name=target_name) + if origin in {"aws-managed", "remote-customer"}: + arn = session.get("policy_arn") + if not isinstance(arn, str) or not _policies.POLICY_ARN.fullmatch(arn): + raise OperationalError( + "The active session lacks a canonical managed-policy ARN; retry the " + "save with `target add --from-session` after supplying the policy." + ) + return arn, None, None + if origin == "stored": + reference = session.get("policy_reference") or session.get("policy") + if isinstance(reference, str): + return reference, None, None + return None, None, None + + +def _add_exact( + data: dict[str, Any], kind: str, name: str, value: dict[str, Any] +) -> bool: + collection = data[_state.collection_name(kind)] + existing = _state._find_key(collection, name) + if existing is None: + collection[name] = value + return True + if collection[existing] != value: + raise OperationalError( + f"{kind.title()} {existing!r} already exists with different settings; " + "the active credentials were left unchanged." + ) + return False + + +def _account_values( + discovery: _account_discovery.AccountDiscovery, *, region: str, verified: bool +) -> tuple[str, dict[str, Any]]: + record = copy.deepcopy(discovery.account_record(verified=verified)) + if not discovery.existing: + record.setdefault("region", region) + return discovery.key, record + + +def persist( + plan: SavePlan, + accounts: SaveAccounts, + session: dict[str, Any], + *, + begin: Callable[[list[Path]], dict[str, Any]], + commit: Callable[[], None], + rollback: Callable[[dict[str, Any]], None], +) -> SaveOutcome: + """Persist account plus optional boundary/target after credentials are active.""" + target_name = plan.name + if plan.requested and target_name is None: + if not plan.interactive: + raise OperationalError("A noninteractive save requires --save=NAME.") + target_name = _prompt_name("Saved target name", plan.destination_profile) + source_name, source_record = _account_values( + accounts.source, region=plan.region, verified=True + ) + role_name: str | None = None + role_record: dict[str, Any] | None = None + if accounts.role is not None: + role_name, role_record = _account_values( + accounts.role, region=plan.region, verified=True + ) + + policy_reference: str | None = None + policy_path: Path | None = None + policy_bytes: bytes | None = None + if plan.requested and target_name and session.get("role"): + policy_reference, policy_path, policy_bytes = _canonical_policy( + plan, session, target_name=target_name + ) + config_path = _state.root() / "config.json" + current_fingerprint = _fingerprint(config_path) + data = _state.load_config() + changed = False + account_results: dict[str, bool] = {} + account_results[source_name.casefold()] = _add_exact( + data, "account", source_name, source_record + ) + changed |= account_results[source_name.casefold()] + if role_name and role_record: + role_key = role_name.casefold() + if role_key not in account_results: + account_results[role_key] = _add_exact( + data, "account", role_name, role_record + ) + changed |= account_results[role_key] + + boundary_name = plan.boundary_name + bundle_changed = False + if plan.requested and target_name and session.get("role"): + boundary_name = boundary_name or target_name + boundary = { + "role_arn": str(session["role"]), + "account": role_name or source_name, + **({"policy": policy_reference} if policy_reference else {}), + **({"duration": plan.duration} if plan.duration is not None else {}), + **( + {"external_id": plan.external_id} + if plan.save_external_id and plan.external_id + else {} + ), + "verified": True, + } + boundary_changed = _add_exact(data, "boundary", boundary_name, boundary) + bundle_changed |= boundary_changed + changed |= boundary_changed + if plan.requested and target_name: + target = { + "source_account": source_name, + **_endpoint(plan.source_directory, plan.source_profile, prefix="source"), + **( + _endpoint( + plan.destination_directory, + plan.destination_profile, + prefix="destination", + ) + if ( + plan.source_directory != plan.destination_directory + or plan.source_profile != plan.destination_profile + ) + else {} + ), + **({"boundary": boundary_name} if boundary_name else {}), + **({"description": plan.description} if plan.description else {}), + "region": plan.region, + } + target_changed = _add_exact(data, "target", target_name, target) + bundle_changed |= target_changed + changed |= target_changed + if policy_path is not None and policy_bytes is not None and policy_reference: + policy_value = {"file": f"stored_session_policies/{policy_reference}.yaml"} + policy_changed = _add_exact(data, "policy", policy_reference, policy_value) + bundle_changed |= policy_changed + changed |= policy_changed + if policy_path.exists() and policy_path.read_bytes() != policy_bytes: + raise OperationalError( + f"Stored policy {policy_reference!r} already exists with different " + "content; the active credentials were left unchanged." + ) + + _state._validate_config(data) + if not changed and (policy_path is None or policy_path.exists()): + return SaveOutcome( + target=target_name, + boundary=boundary_name, + source_account=source_name, + role_account=role_name, + policy=policy_reference, + changed=False, + bundle_requested=plan.requested, + bundle_changed=False, + accounts_created=sum(account_results.values()), + accounts_reused=len(account_results) - sum(account_results.values()), + ) + paths = [config_path, *([policy_path] if policy_path is not None else [])] + journal = begin(paths) + write_started = False + try: + if _fingerprint(config_path) != current_fingerprint: + commit() + raise SavePlanChanged( + "Hacksaws configuration changed while the session save was being " + "prepared; active credentials were left unchanged." + ) + if policy_path is not None and policy_bytes is not None: + write_started = True + _state.atomic_write(policy_path, policy_bytes) + write_started = True + _state.save_config(data) + commit() + except Exception: + if write_started: + rollback(journal) + raise + return SaveOutcome( + target=target_name, + boundary=boundary_name, + source_account=source_name, + role_account=role_name, + policy=policy_reference, + changed=True, + bundle_requested=plan.requested, + bundle_changed=bundle_changed, + accounts_created=sum(account_results.values()), + accounts_reused=len(account_results) - sum(account_results.values()), + ) + + +def recovery_plan( + args: argparse.Namespace, session: dict[str, Any], *, name: str +) -> SavePlan: + """Build a post-login save plan from secret-free managed-session metadata.""" + destination = Path(str(session["destination"])).expanduser().absolute() + source = Path(str(session.get("source_destination") or destination)).absolute() + source_profile = str( + session.get("source_profile") or session.get("profile") or "default" + ) + destination_profile = str(session.get("profile") or "default") + role = session.get("role") + policy = getattr(args, "policy", None) or session.get("policy_reference") + external_id = getattr(args, "external_id", None) + namespace = copy.copy(args) + namespace.save = True + namespace.save_name = name + namespace.role = role + namespace.policy = policy + namespace.external_id = external_id + namespace.save_external_id = bool(external_id) + namespace.profile = source_profile + namespace.duration = getattr(args, "duration", None) + namespace.htl = getattr(args, "htl", None) + namespace.mtl = getattr(args, "mtl", None) + namespace.stl = getattr(args, "stl", None) + namespace.target = None + namespace.boundary = None + return prepare( + namespace, + source_directory=source, + source_profile=source_profile, + destination_directory=destination, + destination_profile=destination_profile, + region=str(session.get("region") or "us-east-1"), + ) + + +def outcome_data(outcome: SaveOutcome) -> dict[str, Any]: + """Return the documented, credential-free save result payload.""" + bundle_changed = ( + outcome.changed if outcome.bundle_changed is None else outcome.bundle_changed + ) + return { + "accountRegistration": { + "created": outcome.accounts_created, + "reused": outcome.accounts_reused, + "refreshed": outcome.accounts_refreshed, + }, + "bundleRequested": outcome.bundle_requested, + "bundleSaved": outcome.bundle_requested, + "bundleChanged": bundle_changed if outcome.bundle_requested else False, + "changed": outcome.changed, + "target": outcome.target, + "boundary": outcome.boundary, + "sourceAccount": outcome.source_account, + "roleAccount": outcome.role_account, + "policy": outcome.policy, + } + + +def retry_command(plan: SavePlan) -> str: + """Return a credential-free recovery command for a partial save.""" + name = plan.name or "NAME" + location = _logical_location(plan.destination_directory) + selector = ( + f" --location {location}" + if location is not None + else f' --directory "{plan.destination_directory}"' + ) + return ( + f"hacksaws target add {name} --from-session {plan.destination_profile}" + f"{selector}" + ) diff --git a/hacksaws/_sessions.py b/hacksaws/_sessions.py index 51b4d40..24536b5 100644 --- a/hacksaws/_sessions.py +++ b/hacksaws/_sessions.py @@ -38,6 +38,7 @@ from hacksaws import _history from hacksaws import _policies from hacksaws import _regions +from hacksaws import _session_save from hacksaws import _state if TYPE_CHECKING: @@ -125,9 +126,10 @@ def _restore(snapshot: dict[str, Any]) -> None: path.unlink(missing_ok=True) -def _begin(paths: list[Path]) -> dict[str, Any]: +def _begin(paths: list[Path], *, kind: str = "login") -> dict[str, Any]: journal = { "schema_version": 1, + "kind": kind, "started_at": _state.iso_now(), "files": [_snapshot(path) for path in paths], "safe_to_rollback": True, @@ -171,8 +173,11 @@ def _rollback(journal: dict[str, Any]) -> None: except OSError as error: failures.append(f"{snapshot['path']}: {error}") if failures: + operation = ( + "Configuration save" if journal.get("kind") == "session-save" else "Login" + ) raise _configs.OperationalError( - "Login failed and automatic recovery was incomplete. Restore these files " + f"{operation} failed and automatic recovery was incomplete. Restore these files " f"from {_journal_path()}: {'; '.join(failures)}" ) _journal_path().unlink(missing_ok=True) @@ -978,6 +983,188 @@ def _save_credentials(path: Path, profile: str, credentials: dict[str, Any]) -> _write_ini(path, parser) +def _session_save_plan( + args: Any, + *, + source_directory: Path, + source_profile: str, + destination_directory: Path, + destination_profile: str, + region: str, +) -> _session_save.SavePlan: + """Preflight the locally knowable part of a post-login configuration save.""" + return _session_save.prepare( + args, + source_directory=source_directory, + source_profile=source_profile, + destination_directory=destination_directory, + destination_profile=destination_profile, + region=region, + ) + + +def _discover_save_accounts( + plan: _session_save.SavePlan, + session: Any, + *, + role: str | None, + source_account: str, + source_partition: str, +) -> tuple[_session_save.SaveAccounts | None, _configs.OperationalError | None]: + """Keep account-discovery trouble out of the credential transaction.""" + try: + return _session_save.discover_accounts(plan, session, role_arn=role), None + except _configs.OperationalError: + try: + fallback = _session_save.accounts_from_identity( + plan, + source_account=source_account, + source_partition=source_partition, + role_arn=role, + ) + except _configs.OperationalError as error: + return None, error + return fallback, None + + +def _finish_session_save( + result: _configs.Result, + *, + plan: _session_save.SavePlan, + accounts: _session_save.SaveAccounts | None, + discovery_error: _configs.OperationalError | None, + destination: Path, + profile: str, +) -> _configs.Result: + """Persist reusable configuration after credentials have already committed.""" + try: + outcome = _persist_committed_session_save( + plan, + accounts, + discovery_error, + destination, + profile, + ) + except _configs.OperationalError as error: + _history.note_account_registration( + status="failed", created=0, reused=0, refreshed=0 + ) + retry = _session_save.retry_command(plan) if plan.requested else None + if plan.requested: + _history.note_session_save( + status=( + "cancelled" + if isinstance(error, _session_save.SaveCancelled) + else "failed" + ), + target=plan.name, + boundary=plan.boundary_name, + requested=True, + credentials_active=True, + ) + existing = dict(result.data) if isinstance(result.data, dict) else {} + failure_data: dict[str, Any] = { + **existing, + "credentialsActive": True, + "credentialResult": result.code, + "accountRegistration": { + "status": "failed", + "created": 0, + "reused": 0, + "refreshed": 0, + }, + "bundleRequested": plan.requested, + "bundleSaved": False, + } + if retry is not None: + failure_data["save"] = {"status": "failed", "retryCommand": retry} + subject = ( + "reusable configuration was not saved" + if plan.requested + else "automatic account registration did not complete" + ) + return _configs.Result( + "PARTIAL_SUCCESS", + f"{result.message} Credentials remain active, but {subject}: {error}", + _configs.EXIT_ERROR, + "stderr", + data=failure_data, + repairs=(f"Retry without logging in again: {retry}",) if retry else None, + kind="warning", + ) + existing = dict(result.data) if isinstance(result.data, dict) else {} + summary = _session_save.outcome_data(outcome) + registration = cast("dict[str, int]", summary["accountRegistration"]) + _history.note_account_registration( + status="completed", + created=registration["created"], + reused=registration["reused"], + refreshed=registration["refreshed"], + ) + bundle_changed = bool(summary["bundleChanged"]) + if plan.requested: + _history.note_session_save( + status="saved" if bundle_changed else "noop", + target=outcome.target, + boundary=outcome.boundary, + requested=True, + credentials_active=True, + ) + suffix = ( + f" Saved target {outcome.target!r}." + if outcome.target and bundle_changed + else f" Target {outcome.target!r} was already current." + if outcome.target + else "" + ) + output_data: dict[str, Any] = { + **existing, + "accountRegistration": registration, + "bundleRequested": plan.requested, + "bundleSaved": plan.requested, + } + if plan.requested: + output_data["save"] = summary + return _configs.Result( + result.code, + result.message + suffix, + result.exit_code, + result.stream, + data=output_data, + details=result.details, + repairs=result.repairs, + kind=result.kind, + ) + + +def _persist_committed_session_save( + plan: _session_save.SavePlan, + accounts: _session_save.SaveAccounts | None, + discovery_error: _configs.OperationalError | None, + destination: Path, + profile: str, +) -> _session_save.SaveOutcome: + if discovery_error is not None: + raise discovery_error + if accounts is None: + raise _configs.OperationalError( + "Account discovery did not produce a reusable configuration record." + ) + session = _state.load_sessions().get(f"{destination.absolute()}::{profile}") + if not isinstance(session, dict): + raise _configs.OperationalError( + "The committed credential session could not be found for saving." + ) + return _session_save.persist( + plan, + accounts, + session, + begin=lambda paths: _begin(paths, kind="session-save"), + commit=_commit, + rollback=_rollback, + ) + + def _copy_region( source_config: Path, source_profile: str, @@ -1208,6 +1395,14 @@ def mfa_login(context: _configs.Context) -> _configs.Result: destination_directory=destination_dir, destination_profile=destination_profile, ) + save_plan = _session_save_plan( + args, + source_directory=source_dir, + source_profile=source_profile, + destination_directory=destination_dir, + destination_profile=destination_profile, + region=region.canonical, + ) raw, source_config = _persistent_source( source_dir, source_profile, region_name=region.canonical ) @@ -1225,6 +1420,13 @@ def mfa_login(context: _configs.Context) -> _configs.Result: args.lifespan, region_name=region.canonical, ) + save_accounts, save_discovery_error = _discover_save_accounts( + save_plan, + intermediate, + role=role, + source_account=source_account, + source_partition=partition, + ) journal = _begin( [ destination_dir / "credentials", @@ -1290,6 +1492,8 @@ def mfa_login(context: _configs.Context) -> _configs.Result: metadata.update(_region_metadata(region)) metadata["source_account"] = source_account metadata["source_partition"] = partition + metadata["source_profile"] = source_profile + metadata["source_destination"] = str(source_dir.absolute()) metadata["target"] = target.get("target_name") _record( destination_dir, @@ -1304,7 +1508,14 @@ def mfa_login(context: _configs.Context) -> _configs.Result: except Exception: _rollback(journal) raise - return _configs.Result("MFA_LOGIN", f"Logged into profile {destination_profile}") + return _finish_session_save( + _configs.Result("MFA_LOGIN", f"Logged into profile {destination_profile}"), + plan=save_plan, + accounts=save_accounts, + discovery_error=save_discovery_error, + destination=destination_dir, + profile=destination_profile, + ) def _aws_cli_version() -> tuple[int, int, int]: @@ -1446,6 +1657,14 @@ def browser_login(context: _configs.Context) -> _configs.Result: "signin", allow_unknown=bool(getattr(args, "allow_unknown_region", False)), ) + save_plan = _session_save_plan( + args, + source_directory=source_dir, + source_profile=source_profile, + destination_directory=destination_dir, + destination_profile=destination_profile, + region=region.canonical, + ) if not has_boundary: native_cache = _native_login_cache() journal = _begin( @@ -1491,6 +1710,13 @@ def browser_login(context: _configs.Context) -> _configs.Result: profile_name=destination_profile, region_name=region.canonical ) account, partition, principal = _identity(native, label="browser login") + save_accounts, save_discovery_error = _discover_save_accounts( + save_plan, + native, + role=None, + source_account=account, + source_partition=partition, + ) lineage = _browser_cache_lineage( destination_dir / "config", destination_profile, @@ -1525,6 +1751,8 @@ def browser_login(context: _configs.Context) -> _configs.Result: { "source_account": account, "source_partition": partition, + "source_profile": source_profile, + "source_destination": str(source_dir.absolute()), "target_account": account, "target_partition": partition, "target": target.get("target_name"), @@ -1550,9 +1778,16 @@ def browser_login(context: _configs.Context) -> _configs.Result: f"{destination_profile!r} were rolled back." ) from error raise - return _configs.Result( - "BROWSER_LOGIN", - f"AWS-native browser login active for profile {destination_profile}.", + return _finish_session_save( + _configs.Result( + "BROWSER_LOGIN", + f"AWS-native browser login active for profile {destination_profile}.", + ), + plan=save_plan, + accounts=save_accounts, + discovery_error=save_discovery_error, + destination=destination_dir, + profile=destination_profile, ) staging = _state.root() / "staging" / uuid.uuid4().hex @@ -1607,6 +1842,13 @@ def browser_login(context: _configs.Context) -> _configs.Result: args, target, source_account, partition ) role = _require_bounded_browser_role(role) + save_accounts, save_discovery_error = _discover_save_accounts( + save_plan, + intermediate, + role=role, + source_account=source_account, + source_partition=partition, + ) if args.ecr: aws_account = _configs.AwsAccount( { @@ -1652,6 +1894,8 @@ def browser_login(context: _configs.Context) -> _configs.Result: metadata.update( source_account=source_account, source_partition=partition, + source_profile=source_profile, + source_destination=str(source_dir.absolute()), target=target.get("target_name"), ) metadata.update(_region_metadata(region)) @@ -1677,9 +1921,16 @@ def browser_login(context: _configs.Context) -> _configs.Result: raise finally: shutil.rmtree(staging, ignore_errors=True) - return _configs.Result( - "BROWSER_LOGIN", - f"Bounded browser login active for profile {destination_profile}.", + return _finish_session_save( + _configs.Result( + "BROWSER_LOGIN", + f"Bounded browser login active for profile {destination_profile}.", + ), + plan=save_plan, + accounts=save_accounts, + discovery_error=save_discovery_error, + destination=destination_dir, + profile=destination_profile, ) @@ -2005,6 +2256,13 @@ def _assume_arguments_fingerprint(args: Any) -> str: "htl", "mtl", "stl", + "save", + "save_name", + "save_source_account", + "save_role_account", + "save_boundary", + "save_external_id", + "store_policy_as", "keep_source", "keep_ecr", "replace", @@ -2074,6 +2332,22 @@ def prepare_assume_role(context: _configs.Context) -> AssumeRolePlan: match = re.fullmatch(r"arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/.+", role) if match is None: raise _configs.OperationalError(f"Invalid role ARN {role!r}.") + preference = cast("_regions.RegionPreference", data["region_preference"]) + data["save_plan"] = _session_save_plan( + context.args, + source_directory=cast("Path", data["source"]), + source_profile=str(data["source_profile"]), + destination_directory=cast("Path", data["destination"]), + destination_profile=str(data["destination_profile"]), + region=preference.canonical, + ) + data["save_accounts"], data["save_discovery_error"] = _discover_save_accounts( + data["save_plan"], + data["authenticated"], + role=role, + source_account=str(data["source_account"]), + source_partition=str(data["source_partition"]), + ) data["effective_duration"] = _effective_assume_duration( data["authenticated"], role, args=context.args, target=data["target"] ) @@ -3006,7 +3280,7 @@ def assume_role( ) if cache_residue: public["browserCacheResidue"] = cache_residue - return _configs.Result( + result = _configs.Result( "ASSUME_ROLE_BROWSER_CACHE_RESIDUE", "Role credentials were installed and the broad source credentials were " "removed, but a browser cache with unknown ownership was preserved.", @@ -3015,8 +3289,8 @@ def assume_role( public, kind="warning", ) - if failures: - return _configs.Result( + elif failures: + result = _configs.Result( "ASSUME_ROLE_ECR_RESIDUE", "Role credentials were installed and the local credential handoff " "completed, but one or more ECR logouts failed; tracked residue remains.", @@ -3025,10 +3299,21 @@ def assume_role( public, kind="warning", ) - return _configs.Result( - "ASSUME_ROLE", - f"Assumed {data['role']} into profile {data['destination_profile']}.", - data=public, + else: + result = _configs.Result( + "ASSUME_ROLE", + f"Assumed {data['role']} into profile {data['destination_profile']}.", + data=public, + ) + return _finish_session_save( + result, + plan=cast("_session_save.SavePlan", data["save_plan"]), + accounts=cast("_session_save.SaveAccounts | None", data["save_accounts"]), + discovery_error=cast( + "_configs.OperationalError | None", data["save_discovery_error"] + ), + destination=cast("Path", data["destination"]), + profile=str(data["destination_profile"]), ) @@ -3546,6 +3831,96 @@ def status_report( return {"sessions": sessions, "counts": counts, "warnings": []} +def save_target_from_session(args: Any) -> _configs.Result: + """Idempotently reconstruct one reusable target from a managed session.""" + profile = _normalize_profile(getattr(args, "from_session", None)) + directory_value = getattr(args, "directory", None) + location = getattr(args, "location", None) + if directory_value: + directory = Path(str(directory_value)).expanduser().absolute() + elif location: + directory = _state.aws_directory(str(location)).absolute() + else: + matches = [ + Path(str(record.get("destination", ""))).absolute() + for record in _state.load_sessions().values() + if record.get("profile") == profile and record.get("destination") + ] + matches = list(dict.fromkeys(matches)) + if not matches: + raise _configs.OperationalError( + f"No Hacksaws-managed session exists for profile {profile!r}." + ) + if len(matches) > 1: + raise _configs.OperationalError( + f"Profile {profile!r} has managed sessions in multiple AWS folders; " + "specify --location or --directory." + ) + directory = matches[0] + key = f"{directory.absolute()}::{profile}" + session = _state.load_sessions().get(key) + if not isinstance(session, dict): + raise _configs.OperationalError(f"No Hacksaws-managed session exists at {key}.") + _session_is_usable_source(session) + if bool(getattr(args, "save_external_id", False)) and not getattr( + args, "external_id", None + ): + raise _configs.OperationalError( + "--save-external-id requires the recovery-only --external-id value." + ) + plan = _session_save.recovery_plan(args, session, name=str(args.resource_name)) + accounts = _session_save.accounts_from_session(plan, session) + try: + outcome = _session_save.persist( + plan, + accounts, + session, + begin=lambda paths: _begin(paths, kind="session-save"), + commit=_commit, + rollback=_rollback, + ) + except _configs.OperationalError: + _history.note_account_registration( + status="failed", created=0, reused=0, refreshed=0 + ) + _history.note_session_save( + status="failed", + target=plan.name, + boundary=plan.boundary_name, + requested=True, + credentials_active=True, + ) + raise + _history.note_account_registration( + status="completed", + created=outcome.accounts_created, + reused=outcome.accounts_reused, + refreshed=outcome.accounts_refreshed, + ) + bundle_changed = ( + outcome.changed if outcome.bundle_changed is None else outcome.bundle_changed + ) + _history.note_session_save( + status="saved" if bundle_changed else "noop", + target=outcome.target, + boundary=outcome.boundary, + requested=True, + credentials_active=True, + ) + data = _session_save.outcome_data(outcome) + data["fromSession"] = {"directory": str(directory), "profile": profile} + return _configs.Result( + "TARGET_FROM_SESSION", + ( + f"Saved target {outcome.target!r} from active profile {profile!r}." + if bundle_changed + else f"Target {outcome.target!r} already matches active profile {profile!r}." + ), + data=data, + kind="info" if not bundle_changed else "success", + ) + + def _known_directories() -> dict[Path, str | None]: directories: dict[Path, str | None] = {(Path.home() / ".aws").absolute(): "default"} try: diff --git a/hacksaws/_state.py b/hacksaws/_state.py index aaa99d0..ffc2118 100644 --- a/hacksaws/_state.py +++ b/hacksaws/_state.py @@ -7,6 +7,7 @@ import os import re import tempfile +import unicodedata from copy import deepcopy from datetime import UTC from datetime import datetime @@ -22,6 +23,10 @@ r"^arn:(aws|aws-us-gov|aws-cn):iam::(\d{12}):role/" r"((?:[A-Za-z0-9_+=,.@-]+/)*[A-Za-z0-9_+=,.@-]{1,64})$" ) +POLICY_ARN_RE = re.compile( + r"^arn:(aws|aws-us-gov|aws-cn):iam::(aws|\d{12}):policy/" + r"[A-Za-z0-9_+=,.@/-]+$" +) PARTITIONS = {"aws", "aws-us-gov", "aws-cn"} TOP_LEVEL = { "schema_version", @@ -41,6 +46,13 @@ NAMING_CASES = {"Pascal", "camel", "snake", "kebab"} ENFORCEMENT_LEVELS = {"off", "warn", "error"} COLOR_MODES = {"auto", "always", "never"} +ACCOUNT_DISPLAY_SOURCES = { + "user", + "iam-alias", + "account-name", + "organizations", + "account-id", +} def collection_name(kind: str) -> str: @@ -320,11 +332,14 @@ def _validate_resources(data: dict[str, Any]) -> None: raise OperationalError( f"{collection[:-1].title()} {name!r} must be an object." ) + account_identities: dict[tuple[str, str], str] = {} for name, account in data["accounts"].items(): unknown = set(account) - { "id", "partition", "description", + "display_name", + "display_source", "unverified", "credential_target", "region", @@ -342,8 +357,39 @@ def _validate_resources(data: dict[str, Any]) -> None: or account["partition"] not in PARTITIONS ): raise OperationalError(f"Account {name!r} has an unsupported partition.") + identity = (account["partition"], account["id"]) + previous = account_identities.get(identity) + if previous is not None: + raise OperationalError( + f"Accounts {previous!r} and {name!r} have the same AWS identity." + ) + account_identities[identity] = name if "description" in account and type(account["description"]) is not str: raise OperationalError(f"Account {name!r} description must be text.") + display_name = account.get("display_name") + display_source = account.get("display_source") + if (display_name is None) != (display_source is None): + raise OperationalError( + f"Account {name!r} display_name and display_source must be set together." + ) + if display_name is not None: + if ( + type(display_name) is not str + or not display_name + or display_name != display_name.strip() + or len(display_name) > 128 + or any( + unicodedata.category(character).startswith("C") + for character in display_name + ) + ): + raise OperationalError( + f"Account {name!r} display_name must be 1-128 safe text characters." + ) + if display_source not in ACCOUNT_DISPLAY_SOURCES: + raise OperationalError( + f"Account {name!r} has an unsupported display_source." + ) if "credential_target" in account and ( type(account["credential_target"]) is not str or not account["credential_target"] @@ -409,6 +455,7 @@ def _validate_resources(data: dict[str, Any]) -> None: if ( policy and _find_key(data["policies"], policy) is None + and POLICY_ARN_RE.fullmatch(policy) is None and policy_path is not None and policy_path.suffix.lower() not in {".json", ".yaml", ".yml", ".toml"} ): diff --git a/hacksaws/tests/test_account_discovery.py b/hacksaws/tests/test_account_discovery.py new file mode 100644 index 0000000..38cddf6 --- /dev/null +++ b/hacksaws/tests/test_account_discovery.py @@ -0,0 +1,541 @@ +"""Security and behavior tests for intermediate account discovery.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from botocore.config import Config +from botocore.exceptions import ClientError +from botocore.exceptions import EndpointConnectionError + +from hacksaws import _account_discovery +from hacksaws import _configs +from hacksaws import _state + +SOURCE_ID = "111111111111" +TARGET_ID = "222222222222" + + +class _Paginator: + def __init__(self, pages: object) -> None: + self.pages = pages + + def paginate(self) -> object: + if isinstance(self.pages, BaseException): + raise self.pages + return self.pages + + +@dataclass +class _Sts: + account_id: str = SOURCE_ID + partition: str = "aws" + error: BaseException | None = None + + def get_caller_identity(self) -> dict[str, str]: + if self.error: + raise self.error + return { + "Account": self.account_id, + "Arn": f"arn:{self.partition}:iam::{self.account_id}:user/test", + "UserId": "not-persisted", + } + + +@dataclass +class _RawSts: + response: object + + def get_caller_identity(self) -> object: + return self.response + + +@dataclass +class _Iam: + pages: object + + def get_paginator(self, operation: str) -> _Paginator: + assert operation == "list_account_aliases" + return _Paginator(self.pages) + + +@dataclass +class _Account: + response: object + calls: list[dict[str, str]] + + def get_account_information(self, **kwargs: str) -> object: + self.calls.append(kwargs) + if isinstance(self.response, BaseException): + raise self.response + return self.response + + +@dataclass +class _Organizations: + response: object + calls: list[str] + + def describe_account(self, *, AccountId: str) -> object: + self.calls.append(AccountId) + if isinstance(self.response, BaseException): + raise self.response + return self.response + + +class _Session: + def __init__(self, **clients: object) -> None: + self.clients = clients + self.calls: list[str] = [] + self.configs: list[object] = [] + + def client(self, service_name: str, **kwargs: object) -> object: + self.calls.append(service_name) + self.configs.append(kwargs.get("config")) + return self.clients[service_name] + + +def error( + code: str, message: str = "secret request-id email@example.com" +) -> ClientError: + return ClientError( + { + "Error": {"Code": code, "Message": message}, + }, + "OptionalLookup", + ) + + +def configured() -> dict[str, object]: + return _state.default_config() + + +def same_account_session( + *, + pages: object | None = None, + account_response: object | None = None, + partition: str = "aws", +) -> _Session: + return _Session( + sts=_Sts(partition=partition), + iam=_Iam([{"AccountAliases": ["source-alias"]}] if pages is None else pages), + account=_Account( + {"AccountId": SOURCE_ID, "AccountName": "Source Production"} + if account_response is None + else account_response, + [], + ), + ) + + +def test_same_account_uses_paginated_alias_for_key_and_account_name_for_display() -> ( + None +): + session = same_account_session( + pages=[{"AccountAliases": []}, {"AccountAliases": ["source-alias"]}] + ) + + result = _account_discovery.discover_account(session, configured()) + + assert result.account_id == SOURCE_ID + assert result.partition == "aws" + assert result.key == "source-alias" + assert result.key_source == "iam-alias" + assert result.display_name == "Source Production" + assert result.display_source == "account-name" + assert result.identity.verified is True + assert result.account_record() == { + "id": SOURCE_ID, + "partition": "aws", + "display_name": "Source Production", + "display_source": "account-name", + } + assert session.calls == ["sts", "iam", "account"] + assert all( + isinstance(config, Config) + and getattr(config, "retries", {}).get("mode") == "standard" + for config in session.configs + ) + + +def test_explicit_key_skips_optional_providers_and_collision_uses_full_id() -> None: + config = configured() + config["accounts"] = { + "Production": {"id": TARGET_ID, "partition": "aws"}, + } + session = _Session(sts=_Sts()) + + result = _account_discovery.discover_account( + session, + config, + explicit_name="Production", + ) + + assert result.key == f"Production-{SOURCE_ID}" + assert result.key_source == "user" + assert result.display_name == "Production" + assert result.display_source == "user" + assert result.notices[0].reason == "collision" + assert session.calls == ["sts"] + + +def test_existing_identity_is_stable_and_user_display_is_never_overwritten() -> None: + config = configured() + existing = { + "id": SOURCE_ID, + "partition": "aws", + "display_name": "My Production", + "display_source": "user", + "description": "keep", + } + config["accounts"] = {"StableKey": existing} + session = _Session(sts=_Sts()) + + result = _account_discovery.discover_account( + session, + config, + explicit_name="IgnoredRename", + ) + + assert result.key == "StableKey" + assert result.key_source == "existing" + assert result.display_name == "My Production" + assert result.display_source == "user" + assert result.account_record() == existing + assert session.calls == ["sts"] + + +def test_cross_account_uses_only_intermediate_organizations_lookup() -> None: + organizations = _Organizations( + { + "Account": { + "Id": TARGET_ID, + "Name": "Target / Production", + "Email": "must-not-persist@example.com", + } + }, + [], + ) + session = _Session(sts=_Sts(), organizations=organizations) + + result = _account_discovery.discover_account( + session, + configured(), + role_arn=f"arn:aws:iam::{TARGET_ID}:role/AgentSession", + ) + + assert result.source_identity.account_id == SOURCE_ID + assert result.account_id == TARGET_ID + assert result.key == "target-production" + assert result.display_name == "Target / Production" + assert result.display_source == "organizations" + assert result.identity.verified is False + assert result.account_record()["unverified"] is True + assert organizations.calls == [TARGET_ID] + assert session.calls == ["sts", "organizations"] + assert "Email" not in str(result.as_dict()) + + +def test_cross_account_optional_account_api_is_explicit_and_uses_target_id() -> None: + organizations = _Organizations(error("AccessDeniedException"), []) + account = _Account({"AccountName": "Target Fallback"}, []) + session = _Session(sts=_Sts(), organizations=organizations, account=account) + + result = _account_discovery.discover_account( + session, + configured(), + role_arn=f"arn:aws:iam::{TARGET_ID}:role/AgentSession", + allow_cross_account_api=True, + ) + + assert result.key == "target-fallback" + assert result.display_source == "account-name" + assert account.calls == [{"AccountId": TARGET_ID}] + assert [notice.reason for notice in result.notices] == ["denied"] + + +@pytest.mark.parametrize( + ("code", "reason"), + [ + ("AccessDenied", "denied"), + ("AccessDeniedException", "denied"), + ("AWSOrganizationsNotInUseException", "unavailable"), + ("AccountNotFoundException", "unavailable"), + ("TooManyRequestsException", "throttled"), + ("ThrottlingException", "throttled"), + ("ServiceException", "service-error"), + ], +) +def test_provider_error_matrix_is_sanitized(code: str, reason: str) -> None: + session = same_account_session( + pages=error(code), + account_response=error(code), + ) + + result = _account_discovery.discover_account(session, configured()) + + assert result.key == f"account-{SOURCE_ID}" + assert [notice.reason for notice in result.notices] == [reason, reason] + rendered = str(result.as_dict()) + assert "secret" not in rendered + assert "request-id" not in rendered + assert "example.com" not in rendered + + +def test_transport_failure_is_nonfatal_for_optional_sources() -> None: + unavailable = EndpointConnectionError(endpoint_url="https://not-shown.invalid") + session = same_account_session(pages=unavailable, account_response=unavailable) + + result = _account_discovery.discover_account(session, configured()) + + assert result.key == f"account-{SOURCE_ID}" + assert {notice.reason for notice in result.notices} == {"service-error"} + assert "not-shown" not in str(result.as_dict()) + + +@pytest.mark.parametrize( + "pages", + [ + [{"AccountAliases": ["one", "two"]}], + [{"AccountAliases": "not-a-list"}], + ["not-a-page"], + [{"AccountAliases": ["bad\x1b[31m/alias"]}], + ], +) +def test_malformed_aliases_are_ignored(pages: object) -> None: + session = same_account_session( + pages=pages, + account_response={"AccountName": "Safe Account"}, + ) + + result = _account_discovery.discover_account(session, configured()) + + assert result.key == "safe-account" + assert result.notices[0].reason == "invalid-response" + + +def test_untrusted_account_name_is_terminal_safe_and_slugged() -> None: + session = same_account_session( + pages=[{"AccountAliases": []}], + account_response={"AccountName": "\x1b[31mPrød\u202e\n / Billing\x00 Account"}, + ) + + result = _account_discovery.discover_account(session, configured()) + + assert result.display_name == "Prød / Billing Account" + assert result.key == "prd-billing-account" + assert "\x1b" not in str(result.as_dict()) + assert "\u202e" not in str(result.as_dict()) + + +def test_missing_names_fall_back_to_account_id_without_failure() -> None: + session = same_account_session( + pages=[{"AccountAliases": []}], account_response={"AccountName": None} + ) + + result = _account_discovery.discover_account(session, configured()) + + assert result.key == f"account-{SOURCE_ID}" + assert result.key_source == "account-id" + assert result.display_source == "account-id" + assert result.notices[0].provider == "account" + + +def test_cross_account_does_not_fall_back_to_source_alias_or_account_api() -> None: + session = _Session( + sts=_Sts(), + organizations=_Organizations(error("AccessDeniedException"), []), + iam=_Iam([{"AccountAliases": ["source-alias"]}]), + account=_Account({"AccountName": "Source Name"}, []), + ) + + result = _account_discovery.discover_account( + session, + configured(), + role_arn=f"arn:aws:iam::{TARGET_ID}:role/AgentSession", + ) + + assert result.key == f"account-{TARGET_ID}" + assert session.calls == ["sts", "organizations"] + + +def test_account_record_can_be_marked_verified_after_role_identity_check() -> None: + session = _Session( + sts=_Sts(), + organizations=_Organizations( + {"Account": {"Id": TARGET_ID, "Name": "Target"}}, [] + ), + ) + result = _account_discovery.discover_account( + session, + configured(), + role_arn=f"arn:aws:iam::{TARGET_ID}:role/AgentSession", + ) + + assert "unverified" not in result.account_record(verified=True) + + +def test_same_account_partition_is_taken_from_sts_arn() -> None: + session = same_account_session(partition="aws-cn") + + result = _account_discovery.discover_account(session, configured()) + + assert result.partition == "aws-cn" + + +def test_cross_partition_role_is_rejected_before_optional_lookups() -> None: + session = _Session(sts=_Sts()) + + with pytest.raises(_configs.OperationalError, match="partition"): + _account_discovery.discover_account( + session, + configured(), + role_arn=f"arn:aws-cn:iam::{TARGET_ID}:role/AgentSession", + ) + assert session.calls == ["sts"] + + +def test_sts_errors_and_malformed_identity_are_sanitized() -> None: + session = _Session(sts=_Sts(error=error("AccessDenied", "do-not-show"))) + with pytest.raises(_configs.OperationalError) as failure: + _account_discovery.discover_account(session, configured()) + assert "do-not-show" not in str(failure.value) + + malformed = _Session(sts=_Sts(account_id="invalid")) + with pytest.raises(_configs.OperationalError, match="account ID"): + _account_discovery.discover_account(malformed, configured()) + + +@pytest.mark.parametrize( + ("response", "message"), + [ + ([], "invalid intermediate identity"), + ({"Account": SOURCE_ID}, "identity ARN"), + ({"Account": SOURCE_ID, "Arn": "not-an-arn"}, "identity ARN"), + ], +) +def test_malformed_sts_response_shapes_are_rejected( + response: object, message: str +) -> None: + session = _Session(sts=_RawSts(response)) + with pytest.raises(_configs.OperationalError, match=message): + _account_discovery.discover_account(session, configured()) + + +def test_discovery_rejects_invalid_or_ambiguous_account_collections() -> None: + session = _Session(sts=_Sts()) + with pytest.raises(_configs.OperationalError, match="accounts must be"): + _account_discovery.discover_account(session, {"accounts": []}) + + duplicates = configured() + duplicates["accounts"] = { + "One": {"id": SOURCE_ID, "partition": "aws"}, + "Two": {"id": SOURCE_ID, "partition": "aws"}, + } + with pytest.raises(_configs.OperationalError, match="multiple names"): + _account_discovery.discover_account(_Session(sts=_Sts()), duplicates) + + +def test_optional_provider_response_shapes_are_best_effort() -> None: + same = same_account_session(account_response=[]) + same_result = _account_discovery.discover_account(same, configured()) + assert same_result.display_name == "source-alias" + assert same_result.notices[0].provider == "account" + + for response in ( + {}, + {"Account": {"Id": SOURCE_ID, "Name": "Wrong"}}, + {"Account": {"Id": TARGET_ID}}, + ): + session = _Session(sts=_Sts(), organizations=_Organizations(response, [])) + result = _account_discovery.discover_account( + session, + configured(), + role_arn=f"arn:aws:iam::{TARGET_ID}:role/AgentSession", + ) + assert result.key == f"account-{TARGET_ID}" + assert result.notices[0].reason == "invalid-response" + + +def test_unique_key_defensively_rejects_exhausted_collision_forms() -> None: + config = configured() + config["accounts"] = { + "prod": {"id": "333333333333", "partition": "aws"}, + f"prod-{SOURCE_ID}": {"id": "444444444444", "partition": "aws"}, + f"prod-aws-{SOURCE_ID}": {"id": "555555555555", "partition": "aws"}, + } + identity = _account_discovery.AccountIdentity( + partition="aws", + account_id=SOURCE_ID, + arn=f"arn:aws:iam::{SOURCE_ID}:user/test", + verified=True, + ) + with pytest.raises(_configs.OperationalError, match="unique account key"): + _account_discovery._unique_key(config, "prod", identity) + + with pytest.raises(_configs.OperationalError, match="accounts must be"): + _account_discovery._unique_key({"accounts": []}, "prod", identity) + + +@pytest.mark.parametrize( + "record", + [ + { + "id": SOURCE_ID, + "partition": "aws", + "display_name": "Missing Source", + }, + { + "id": SOURCE_ID, + "partition": "aws", + "display_source": "user", + }, + { + "id": SOURCE_ID, + "partition": "aws", + "display_name": "Unsafe\nName", + "display_source": "user", + }, + { + "id": SOURCE_ID, + "partition": "aws", + "display_name": "Name", + "display_source": "unknown", + }, + ], +) +def test_account_display_schema_rejects_invalid_metadata( + record: dict[str, str], +) -> None: + config = _state.default_config() + config["accounts"]["Bad"] = record + with pytest.raises(_configs.OperationalError): + _state._validate_config(config) + + +def test_account_schema_rejects_duplicate_stable_identity() -> None: + config = _state.default_config() + config["accounts"] = { + "One": {"id": SOURCE_ID, "partition": "aws"}, + "Two": {"id": SOURCE_ID, "partition": "aws"}, + } + with pytest.raises(_configs.OperationalError, match="same AWS identity"): + _state._validate_config(config) + + +def test_partition_collision_suffix_remains_deterministic() -> None: + config = configured() + config["accounts"] = { + "prod": {"id": "999999999999", "partition": "aws"}, + f"prod-{SOURCE_ID}": {"id": SOURCE_ID, "partition": "aws-cn"}, + } + session = _Session( + sts=_Sts(), + iam=_Iam([{"AccountAliases": ["prod"]}]), + account=_Account({}, []), + ) + + result = _account_discovery.discover_account(session, config) + + assert result.key == f"prod-aws-{SOURCE_ID}" diff --git a/hacksaws/tests/test_cli_history_surface.py b/hacksaws/tests/test_cli_history_surface.py new file mode 100644 index 0000000..5ccd66d --- /dev/null +++ b/hacksaws/tests/test_cli_history_surface.py @@ -0,0 +1,603 @@ +"""Behavioral coverage for human history, status, and configuration surfaces.""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +from contextlib import closing +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _history +from hacksaws import _state + + +def _isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + + +def _history_args(*arguments: str) -> argparse.Namespace: + parsed = _cli._create_parser().parse_args(["history", *arguments]) + parsed.json = False + return parsed + + +def _command_args(*arguments: str) -> argparse.Namespace: + parsed = _cli._create_parser().parse_args(list(arguments)) + parsed.json = bool(getattr(parsed, "json", False)) + return parsed + + +def _result_data(result: _configs.Result) -> dict[str, object]: + assert isinstance(result.data, dict) + return result.data + + +def _record_parse_failure() -> str: + handle = _history.begin(json_mode=False, interactive=False) + observation = _history.observe_arguments( + _cli._create_parser(), ["web", "in", "debug", "--save-name"] + ) + _history.note_parse_failure( + handle, observation, phase="argparse", kind="missing-option-value" + ) + _history.finish(handle, _configs.Result("ARGUMENT_ERROR", "ignored", 2)) + assert handle.id is not None + return handle.id + + +def _record_save(status: str = "saved") -> str: + handle = _history.begin(json_mode=False, interactive=True) + _history.note_session_save( + status=status, + target="DebugAgent", + boundary="AgentBoundary", + requested=True, + credentials_active=True, + ) + _history.finish(handle, _configs.Result("OK", "ignored")) + assert handle.id is not None + return handle.id + + +def test_history_terminal_commands_cover_safe_inspection_and_export( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + parse_id = _record_parse_failure() + _record_save() + + listed = _cli._run_history( + _history_args( + "list", + "--wide", + "--failure", + "missing-option-value", + "--phase", + "argparse", + ) + ) + assert listed.code == "HISTORY_LIST" + assert "missing-option-value" in listed.message + + searched = _cli._run_history( + _history_args("search", "session-save.saved", "--wide") + ) + assert searched.code == "HISTORY_SEARCH" + assert _result_data(searched)["count"] == 1 + + shown = _cli._run_history(_history_args("show", parse_id[:8])) + assert shown.code == "HISTORY_SHOW" + assert "Safe attempted shape:" in shown.message + + report = _cli._run_history(_history_args("report")) + assert report.code == "HISTORY_REPORT" + assert "Argument failures" in report.message + assert "Session saves" in report.message + + exported = _cli._run_history(_history_args("export", "--format", "json")) + assert exported.code == "HISTORY_EXPORT" + assert len(json.loads(exported.message)) == 2 + + output = tmp_path / "history.jsonl" + written = _cli._run_history( + _history_args("export", "--format", "jsonl", "--output", str(output)) + ) + assert written.code == "HISTORY_EXPORT" + assert _result_data(written)["output"] == str(output.absolute()) + assert len(output.read_text(encoding="utf-8").splitlines()) == 2 + + status = _cli._run_history(_history_args("status")) + assert status.code == "HISTORY_STATUS" + assert ( + "1 parse failures; 1 session saves; 0 account registrations" in status.message + ) + + checked = _cli._run_history(_history_args("check")) + assert checked.code == "HISTORY_CHECK_OK" + assert checked.exit_code == 0 + + +def test_history_check_reports_corrupt_record_and_event( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + identifier = _record_save() + with closing(sqlite3.connect(_history.database_path())) as connection: + connection.execute( + "UPDATE invocations SET safe_json = '[]' WHERE id = ?", (identifier,) + ) + connection.execute( + "INSERT INTO events (invocation_id, occurred_at, kind, data_json) " + "VALUES (?, '2026-01-01T00:00:00Z', 'session-save.saved', '{}')", + (identifier,), + ) + connection.commit() + + result = _cli._run_history(_history_args("check")) + assert result.code == "HISTORY_CHECK_FAILED" + assert result.exit_code == 1 + assert "1 corrupt records and 1 corrupt events" in result.message + + +def test_history_clear_plan_confirmation_cancel_and_apply( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + _record_save() + dry_run = _cli._run_history(_history_args("clear", "--all", "--dry-run")) + assert dry_run.code == "HISTORY_CLEAR_PLAN" + assert _result_data(dry_run)["applied"] is False + + with patch("hacksaws._cli.sys.stdin.isatty", return_value=False): + required = _cli._run_history(_history_args("clear", "--all")) + assert required.code == "CONFIRMATION_REQUIRED" + assert required.exit_code == _configs.EXIT_CANCELLED + + with ( + patch("hacksaws._cli.sys.stdin.isatty", return_value=True), + patch("builtins.input", return_value="no"), + ): + cancelled = _cli._run_history(_history_args("clear", "--all")) + assert cancelled.code == "HISTORY_CLEAR_CANCELLED" + + applied = _cli._run_history(_history_args("clear", "--all", "--yes")) + assert applied.code == "HISTORY_CLEAR" + assert _result_data(applied)["count"] == 1 + + empty = _cli._run_history(_history_args("clear", "--all")) + assert empty.code == "HISTORY_CLEAR_PLAN" + assert _result_data(empty)["count"] == 0 + + +def test_history_help_and_rendering_edge_states( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated(monkeypatch, tmp_path) + help_result = _cli._run_history(_history_args()) + assert help_result.code == "HISTORY_HELP" + help_text = capsys.readouterr().out + assert "Inspect" in help_text + assert "list,search,show,report,export,status,check,clear" in help_text + + records = [ + { + "id": "a" * 32, + "startedAt": "2026-01-01T00:00:00Z", + "outcome": outcome, + "command": "pk.login", + "profile": "debug", + "durationMs": 1, + "events": [], + } + for outcome in _cli._HISTORY_OUTCOMES + ] + records.append( + { + "id": "b" * 32, + "startedAt": "2026-01-01T00:00:00Z", + "outcome": None, + "command": "unknown", + "profile": None, + "events": [], + } + ) + rendered = _cli._history_list_text(records, wide=True) + assert "running/unknown" in rendered + assert _cli._history_list_text([], wide=False) == "(none)" + + legacy = { + "id": "c" * 32, + "command": "unknown", + "state": "completed", + "outcome": "usage-error", + "resultCode": "ARGUMENT_ERROR", + "exitCode": 2, + "startedAt": "2026-01-01T00:00:00Z", + "endedAt": "2026-01-01T00:00:01Z", + "durationMs": 1, + "confirmation": "not-requested", + "safe": { + "inputKinds": [{"role": "policy", "format": "yaml"}], + "secretPresence": {"mfaCode": True, "externalId": True}, + }, + "events": [], + "recoveryUnresolved": True, + "profile": "debug", + "accountId": "123456789012", + } + detail = _cli._history_show_text(legacy) + assert "Inputs: policy (yaml)" in detail + assert "MFA code, external ID" in detail + assert "Recovery: unresolved" in detail + assert "Parse detail: unavailable" in detail + + +def test_status_human_rendering_teaches_dynamic_fields() -> None: + sessions = [ + { + "location": "default", + "profile": "native", + "profile_region": "us-east-1", + "state": "active", + "auth_method": "browser-native", + "source_account": "111111111111", + "effective_scope": {"kind": "account-login"}, + "remaining_seconds": 30, + "verification": {"status": "verified"}, + }, + { + "location": "horizon", + "profile": "bounded", + "region": "us-west-2", + "state": "expiring", + "auth_method": "mfa", + "role": "arn:aws:iam::222222222222:role/path/AgentRole", + "target_account": "222222222222", + "effective_scope": { + "kind": "role-session", + "role_label": "AgentRole", + "boundary_label": "Logs", + "policy_label": "ReadOnly", + }, + "remaining_seconds": 5400, + "verification": { + "status": "mismatch", + "expected_role": "AgentRole", + "actual_role": "OtherRole", + }, + }, + { + "location": "horizon", + "profile": "odd", + "state": "new-state", + "auth_method": "new-auth", + "effective_scope": {"kind": "new-kind"}, + "remaining_seconds": "bad", + "verification": {"status": "mismatch", "actual_account": "333"}, + }, + ] + rendered = _cli._status_text({"sessions": sessions}) + assert "LOCATION" in rendered + assert "TTL" in rendered + assert "VERIFY" in rendered + assert "AgentRole (@Logs)" in rendered + assert "mismatch (role OtherRole)" in rendered + assert "mismatch (333)" in rendered + assert "unknown/inconclusive" in rendered + + assert _cli._status_text({"sessions": []}) == "(none)" + assert _cli._status_ttl({"state": "expired", "remaining_seconds": 20}) == "" + assert _cli._status_ttl({"state": "active", "remaining_seconds": 0}) == "" + assert _cli._status_ttl({"state": "active", "remaining_seconds": 90}) == "2m" + assert _cli._status_ttl({"state": "active", "remaining_seconds": 8000}) == "2h" + assert _cli._status_verification({}) == "" + assert _cli._status_verification({"verification": {"status": "error"}}) == "error" + + +def test_config_human_account_scope_and_terminal_adapters( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + data = _state.default_config() + data["accounts"] = { + "Prod": { + "id": "111111111111", + "partition": "aws", + "region": "us-west-2", + "description": "Production", + }, + "Dev": { + "id": "222222222222", + "partition": "aws", + "unverified": True, + }, + } + data["policies"] = { + "Logs": { + "file": "stored_session_policies/Logs.yaml", + "description": "Read logs", + }, + "Unused": {"file": "stored_session_policies/Unused.yaml"}, + } + data["boundaries"] = { + "Guard": { + "role_arn": "arn:aws:iam::111111111111:role/Agent", + "account": "Prod", + "policy": "Logs", + "duration": 900, + "verified": True, + } + } + data["targets"] = { + "Debug": { + "source_account": "Prod", + "source_profile": "admin", + "source_location": "horizon", + "destination_profile": "debug", + "destination_location": "default", + "boundary": "Guard", + "region": "us-west-2", + } + } + _state.save_config(data) + + all_text = _cli._config_text(data) + assert "Settings" in all_text + assert "Unused" in all_text + prod_text = _cli._config_text(data, account="prod") + assert "Production" in prod_text + assert "Debug" in prod_text + assert "Unused" not in prod_text + assert "Settings" not in prod_text + with pytest.raises(_configs.OperationalError, match="Unknown configured account"): + _cli._config_text(data, account="Missing") + + assert "error: denied" in _cli._logout_report_text( + { + "outcomes": [ + {"profile": "debug", "destination": "C:/aws", "state": "cleared"} + ], + "errors": [{"key": "C:/other", "message": "denied"}], + } + ) + assert "fresh" in _cli._cache_list_text( + [ + { + "identity": "aws-ReadOnly", + "state": "fresh", + "origin": "aws-managed", + "source_identity": "ReadOnly", + "age_seconds": 12, + "size": 100, + } + ] + ) + assert "1 fresh, 2 stale, 3 invalid" in _cli._cache_status_text( + { + "root": str(tmp_path), + "max_age": 3600, + "counts": {"fresh": 1, "stale": 2, "invalid": 3}, + "total_bytes": 100, + } + ) + + +def test_config_terminal_commands_cover_human_and_machine_management( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated(monkeypatch, tmp_path) + data = _state.default_config() + data["accounts"]["Prod"] = { + "id": "111111111111", + "partition": "aws", + } + data["boundaries"]["Guard"] = { + "role_arn": "arn:aws:iam::111111111111:role/Agent", + "account": "Prod", + "verified": True, + } + data["targets"]["Debug"] = { + "source_account": "Prod", + "source_profile": "admin", + "source_location": "default", + "boundary": "Guard", + } + _state.save_config(data) + + shown = _cli._run_config(_command_args("config", "show", "--account", "Prod")) + assert shown.code == "CONFIG_SHOW" + assert "Debug" in shown.message + shown_json = _cli._run_config(_command_args("config", "show", "--json")) + assert json.loads(shown_json.message)["accounts"]["Prod"]["id"] == "111111111111" + + options = _cli._run_config(_command_args("config", "option", "list")) + assert options.code == "CONFIG_OPTION_LIST" + explained = _cli._run_config( + _command_args("config", "option", "explain", "history.max_age") + ) + assert explained.code == "CONFIG_OPTION_EXPLAIN" + with pytest.raises(_configs.OperationalError, match="Unknown config option"): + _cli._run_config(_command_args("config", "option", "explain", "not.real")) + + nested_set = _cli._run_config( + _command_args("config", "option", "set", "history.max_entries", "123") + ) + assert _result_data(nested_set)["value"] == 123 + nested_reset = _cli._run_config( + _command_args("config", "option", "reset", "history.max_entries") + ) + assert nested_reset.code == "CONFIG_OPTION_RESET" + + direct_set = _cli._run_config( + _command_args("config", "set", "output.color", "never") + ) + assert _result_data(direct_set)["value"] == "never" + direct_get = _cli._run_config( + _command_args("config", "get", "output.color", "--json") + ) + assert _result_data(direct_get)["value"] == "never" + direct_reset = _cli._run_config(_command_args("config", "reset", "output.color")) + assert direct_reset.code == "CONFIG_OPTION_RESET" + + no_option_action = _cli._run_config(_command_args("config", "option")) + assert no_option_action.code == "CONFIG_OPTION_HELP" + no_action = _cli._run_config(_command_args("config")) + assert no_action.code == "CONFIG_HELP" + assert "hacksaws config" in capsys.readouterr().out + + +def test_config_terminal_adapters_dispatch_without_exposing_backend_details( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + _state.save_config(_state.default_config()) + with ( + patch("hacksaws._sessions.explain_target", return_value={"target": "Debug"}), + patch( + "hacksaws._sessions.check_config", + side_effect=({"errors": [], "warnings": []}, {"errors": ["bad"]}), + ), + patch( + "hacksaws._sessions.fix_config", + return_value=_configs.Result("CONFIG_FIXED", "fixed"), + ), + patch("hacksaws._sessions.export_config", return_value=tmp_path / "backup.zip"), + patch("hacksaws._sessions.import_config", return_value="Imported safely."), + ): + explained = _cli._run_config( + _command_args("config", "explain", "Debug", "--json") + ) + checked = _cli._run_config(_command_args("config", "check")) + failed = _cli._run_config(_command_args("config", "check")) + fixed = _cli._run_config(_command_args("config", "fix")) + exported = _cli._run_config( + _command_args("config", "export", str(tmp_path / "backup.zip")) + ) + imported = _cli._run_config( + _command_args("config", "import", str(tmp_path / "backup.zip"), "--yes") + ) + assert explained.code == "CONFIG_EXPLAIN" + assert checked.exit_code == 0 + assert failed.exit_code == 1 + assert fixed.code == "CONFIG_FIXED" + assert exported.code == "CONFIG_EXPORT" + assert imported.message == "Imported safely." + + +def test_named_target_resource_dispatch_and_recovery_validation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated(monkeypatch, tmp_path) + data = _state.default_config() + data["accounts"]["Prod"] = {"id": "111111111111", "partition": "aws"} + _state.save_config(data) + + added = _cli._run_resource( + _command_args( + "target", + "add", + "Debug", + "--source-account", + "Prod", + "--source-profile", + "admin", + "--source-location", + "horizon", + "--to", + "default:debug", + "--description", + "Agent debug target", + ) + ) + assert added.code == "RESOURCE_SAVED" + listed = _cli._run_resource(_command_args("target", "list")) + assert listed.code == "RESOURCE_LIST" + fetched = _cli._run_resource(_command_args("target", "get", "Debug")) + assert fetched.code == "RESOURCE_GET" + renamed = _cli._run_resource(_command_args("target", "rename", "Debug", "Agent")) + assert renamed.code == "RESOURCE_RENAME" + removed = _cli._run_resource(_command_args("target", "remove", "Agent")) + assert removed.code == "RESOURCE_REMOVE" + + help_result = _cli._run_resource(_command_args("target")) + assert help_result.code == "RESOURCE_HELP" + assert "saved login" in capsys.readouterr().out + + with pytest.raises(_configs.OperationalError, match="requires --source-account"): + _cli._run_resource(_command_args("target", "add", "Missing")) + with pytest.raises(_configs.OperationalError, match="require --from-session"): + _cli._run_resource( + _command_args( + "target", + "add", + "Missing", + "--source-account", + "Prod", + "--store-policy-as", + "Stored", + ) + ) + + +def test_target_from_session_dispatches_complete_recovery_namespace( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + expected = _configs.Result("TARGET_FROM_SESSION", "saved") + parsed = _command_args( + "target", + "add", + "Recovered", + "--from-session", + "debug", + "--directory", + str(tmp_path / "aws"), + "--save-source-account", + "Prod", + "--save-role-account", + "Tools", + "--save-boundary", + "Guard", + "--policy", + "ReadOnly", + ) + with patch( + "hacksaws._sessions.save_target_from_session", return_value=expected + ) as save: + assert _cli._run_resource(parsed) is expected + save.assert_called_once_with(parsed) + + +@pytest.mark.parametrize( + ("arguments", "phase"), + [ + (["--color", "rainbow", "status"], "global"), + (["web", "in", "debug", "--save=One", "--save-name", "Two"], "semantic"), + (["web", "in", "debug", "--not-a-real-option"], "argparse"), + (["mfa", "in", "debug", "--json"], "semantic"), + ], +) +def test_console_usage_failures_are_recorded_by_phase( + arguments: list[str], + phase: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated(monkeypatch, tmp_path) + result = _cli.console_main(arguments) + assert result.exit_code == _configs.EXIT_USAGE + capsys.readouterr() + assert _history.list_records(phase=phase) diff --git a/hacksaws/tests/test_coverage_closure.py b/hacksaws/tests/test_coverage_closure.py index 968a040..87a6edf 100644 --- a/hacksaws/tests/test_coverage_closure.py +++ b/hacksaws/tests/test_coverage_closure.py @@ -163,7 +163,11 @@ def test_prettier_wrapper_forwards_paths_without_scanning_ignored_cache() -> Non assert ".tmp/" in ignored.splitlines() -def test_prettier_wrapper_terminates_options_before_git_filenames() -> None: +def test_prettier_wrapper_terminates_options_before_git_filenames( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "--foo.md").write_text("# Option-like filename\n", encoding="utf-8") git_result: subprocess.CompletedProcess[bytes] = subprocess.CompletedProcess( ["git"], 0, b"--foo.md\0" ) @@ -176,7 +180,6 @@ def test_prettier_wrapper_terminates_options_before_git_filenames() -> None: side_effect=[git_result, prettier_result], ) as run, patch("scripts.prettier.shutil.which", side_effect=["git", "npx"]), - patch("scripts.prettier.os.path.isfile", return_value=True), ): assert prettier.main(["write", "."]) == 0 diff --git a/hacksaws/tests/test_history.py b/hacksaws/tests/test_history.py index bee0cbc..0275f2e 100644 --- a/hacksaws/tests/test_history.py +++ b/hacksaws/tests/test_history.py @@ -120,6 +120,56 @@ def test_allowlist_never_persists_sensitive_or_free_form_values( ] +def test_unknown_path_extensions_are_not_persisted( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + canary = "private-extension-canary" + handle = _history.begin(json_mode=False, interactive=False) + _history.enrich( + handle, + argparse.Namespace( + access_type="iam", + iam_action="policy", + policy_action="add", + file=f"C:\\private\\policy.{canary}", + policy=f"C:\\private\\boundary.{canary}", + ), + ) + _history.finish(handle, _result()) + + record = _history.list_records()[0] + exported = _history.export_records([record], format_name="json") + assert canary not in exported + assert canary.encode() not in _history.database_path().read_bytes() + safe = cast("dict[str, object]", record["safe"]) + assert safe["inputKinds"] == [ + {"format": "other", "role": "file"}, + {"format": "other", "role": "policy-file"}, + ] + + +def test_argument_observer_failure_is_safe_and_does_not_block_cli( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + canary = "OBSERVER-FAILURE-CANARY" + + def fail_observer( + _parser: argparse.ArgumentParser, _arguments: list[str] + ) -> dict[str, object]: + raise RuntimeError(canary) + + monkeypatch.setattr(_history, "observe_arguments", fail_observer) + result = _cli.console_main(["--help"]) + + assert result.exit_code == 0 + raw_database = _history.database_path().read_bytes() + assert canary.encode() not in raw_database + record = _history.list_records()[0] + assert record["command"] == "unknown" + + def test_finish_preserves_safe_parser_metadata_and_result_metrics( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -172,6 +222,33 @@ def test_post_prompt_mfa_enrichment_records_presence_and_source_only( assert "mfaCode" not in safe +def test_account_registration_history_is_separate_and_count_only( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolate(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=False) + _history.note_account_registration( + status="completed", created=1, reused=1, refreshed=0 + ) + _history.finish(handle, _result()) + + record = _history.list_records()[0] + event = _history.account_registration_event(record) + assert event is not None + assert event["kind"] == "account-registration.completed" + assert event["data"] == { + "eventSchemaVersion": _history.EVENT_SCHEMA_VERSION, + "redactionVersion": _history.REDACTION_VERSION, + "status": "completed", + "created": 1, + "reused": 1, + "refreshed": 0, + } + report = _history.status() + assert report["accountRegistrations"] == 1 + assert _history.check()["ok"] is True + + @pytest.mark.parametrize( ("error", "state", "outcome", "exit_code"), [ @@ -374,7 +451,7 @@ def test_history_list_show_report_status_and_check_are_human_friendly( list_output = capsys.readouterr().out assert "ID" in list_output assert "iam.role.get" in list_output - assert "Key: ✓ success" in list_output + assert "Key: OK success" in list_output assert _cli.console_main(["history", "show", identifier[:8]]).exit_code == 0 show_output = capsys.readouterr().out diff --git a/hacksaws/tests/test_save_history_cli.py b/hacksaws/tests/test_save_history_cli.py new file mode 100644 index 0000000..d55d776 --- /dev/null +++ b/hacksaws/tests/test_save_history_cli.py @@ -0,0 +1,365 @@ +"""Save grammar and redacted parse-history contracts.""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +from contextlib import closing +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hacksaws import _cli +from hacksaws import _configs +from hacksaws import _history + + +def _isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + + +def test_save_equals_and_explicit_name_have_one_namespace_contract() -> None: + parser = _cli._create_parser() + equals = parser.parse_args( + _cli._normalize_login_save_options(["web", "in", "debug", "--save=Agent"]) + ) + explicit = parser.parse_args(["web", "in", "debug", "--save-name", "Agent"]) + assert equals.save is False + assert equals.save_name == "Agent" + assert explicit.save is False + assert explicit.save_name == "Agent" + + assumed = parser.parse_args( + _cli._normalize_login_save_options( + [ + "assume", + "debug", + "--role", + "AgentRole", + "--self", + "--save=Agent", + "--save-source-account", + "Prod", + "--save-role-account", + "Tools", + "--save-boundary", + "Guard", + ] + ) + ) + assert assumed.save_name == "Agent" + assert assumed.save_source_account == "Prod" + assert assumed.save_role_account == "Tools" + assert assumed.save_boundary == "Guard" + + +def test_ambiguous_save_and_orphaned_overrides_are_usage_errors() -> None: + with pytest.raises(_configs.OperationalError, match="Ambiguous"): + _cli._normalize_login_save_options(["web", "in", "debug", "--save", "Agent"]) + namespace = argparse.Namespace( + save=False, + save_name=None, + save_source_account="Prod", + save_role_account=None, + save_boundary=None, + save_external_id=False, + store_policy_as=None, + external_id=None, + ) + with pytest.raises(_configs.OperationalError, match="require --save"): + _cli._validate_save_arguments(namespace) + + +def test_bare_save_in_json_fails_before_browser_authentication( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated(monkeypatch, tmp_path) + with patch("hacksaws._sessions.browser_login") as login: + result = _cli.console_main(["web", "in", "debug", "--save", "--json"]) + assert result.code == "SAVE_NAME_REQUIRED" + assert result.exit_code == _configs.EXIT_USAGE + login.assert_not_called() + payload = json.loads(capsys.readouterr().err) + assert payload["code"] == "SAVE_NAME_REQUIRED" + + +def test_target_from_session_dispatches_and_manual_shape_conflicts( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + expected = _configs.Result("TARGET_SAVED", "saved") + with patch( + "hacksaws._sessions.save_target_from_session", + return_value=expected, + create=True, + ) as save: + parsed = _cli._create_parser().parse_args( + [ + "target", + "add", + "Agent", + "--from-session", + "debug", + "--location", + "horizon", + ] + ) + assert _cli._run_resource(parsed) is expected + save.assert_called_once_with(parsed) + + conflicting = _cli._create_parser().parse_args( + [ + "target", + "add", + "Agent", + "--from-session", + "debug", + "--source-account", + "Prod", + ] + ) + with pytest.raises(_configs.OperationalError, match="reconstructs"): + _cli._run_resource(conflicting) + + explicit_default = _cli._create_parser().parse_args( + [ + "target", + "add", + "Agent", + "--from-session", + "debug", + "--source-profile", + "default", + ] + ) + with pytest.raises(_configs.OperationalError, match="--source-profile"): + _cli._run_resource(explicit_default) + + +def test_parse_failure_event_is_structural_searchable_and_cp1252_safe( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated(monkeypatch, tmp_path) + result = _cli.console_main(["web", "in", "debug", "--save-name", "--json"]) + assert result.code == "ARGUMENT_ERROR" + capsys.readouterr() + record = _history.list_records(failure="missing-option-value")[0] + assert record["command"] == "pk.login" + event = _history.parse_failure_event(record) + assert event is not None + assert event["kind"] == "parse.missing-option-value" + rendered = _cli._history_show_text(record) + rendered.encode("cp1252") + assert "Safe attempted shape:" in rendered + assert "raw arguments and values were never stored" in rendered + assert "debug" not in rendered + + +def test_parse_observer_never_stores_unknown_tokens_paths_or_values( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated(monkeypatch, tmp_path) + canary = "DO-NOT-STORE-PARSE-CANARY" + result = _cli.console_main( + [ + "web", + "in", + "debug", + f"--{canary}", + str(tmp_path / f"{canary}.yaml"), + "--external-id", + canary, + "--json", + ] + ) + assert result.exit_code == _configs.EXIT_USAGE + capsys.readouterr() + _history.list_records() + files = [ + _history.database_path(), + *_history.database_path().parent.glob("history.db-*"), + ] + assert all( + canary.encode() not in path.read_bytes() for path in files if path.exists() + ) + exported = _history.export_records(_history.list_records(), format_name="json") + assert canary not in exported + + +@pytest.mark.parametrize( + "arguments", + [ + ["web", "in", "profile-canary", "--directory", "/var/path-canary"], + [ + "web", + "in", + "profile-canary", + "--directory", + r"C:\Users\name\path-canary", + ], + ["web", "in", "profile-canary", "--", "tail-canary", "secret-canary"], + ], +) +def test_parse_observer_redacts_platform_paths_and_literal_tail( + arguments: list[str], +) -> None: + observed = _history.observe_arguments(_cli._create_parser(), arguments) + encoded = json.dumps(observed, sort_keys=True) + assert "profile-canary" not in encoded + assert "path-canary" not in encoded + assert "tail-canary" not in encoded + assert "secret-canary" not in encoded + assert int(observed["opaque"]["tail"]) <= 255 # type: ignore[index] + + +def test_parse_observer_caps_opaque_input_without_retaining_tokens() -> None: + canary = "never-store-long-token" + arguments = ["web", "in", *[f"--{canary}-{index}" for index in range(400)]] + observed = _history.observe_arguments(_cli._create_parser(), arguments) + encoded = json.dumps(observed, sort_keys=True) + assert canary not in encoded + assert observed["opaque"] == { + "options": 255, + "positionals": 0, + "tail": 0, + "truncated": True, + } + + +def test_parse_event_edge_cases_remain_bounded_and_typed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + parser = _cli._create_parser() + extra = _history.observe_arguments(parser, ["web", "in", "debug", "extra"]) + assert extra["inferredKind"] == "extra-positional" + + disabled = _history.HistoryHandle(id=None, started_monotonic=0, enabled=False) + _history.note_parse_failure(disabled, extra, phase="not-a-phase") + + handle = _history.begin(json_mode=False, interactive=False) + oversized = { + **extra, + "options": [ + { + "name": f"known-option-{index}", + "count": 1, + "valueClass": "identifier", + "valueState": "present", + } + for index in range(256) + ], + } + _history.note_parse_failure( + handle, oversized, phase="not-a-phase", kind="INVALID KIND" + ) + _history.note_session_save( + status="not-a-status", + requested=True, + credentials_active=True, + ) + _history.finish(handle, _configs.Result("ARGUMENT_ERROR", "", 2)) + + record = _history.list_records()[0] + event = _history.parse_failure_event(record) + assert event is not None + assert event["kind"] == "parse.invalid-syntax" + event_data = event["data"] + assert isinstance(event_data, dict) + assert event_data["phase"] == "argparse" + assert event_data["structure"] == { + "opaque": extra["opaque"], + "truncated": True, + } + + +def test_semantic_shape_error_is_classified_as_usage( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _isolated(monkeypatch, tmp_path) + result = _cli.console_main(["mfa", "in", "debug", "123456", "--policy", "ReadOnly"]) + assert result.code == "ARGUMENT_ERROR" + assert result.exit_code == _configs.EXIT_USAGE + capsys.readouterr() + record = _history.list_records(phase="semantic")[0] + event = _history.parse_failure_event(record) + assert event is not None + assert event["kind"] == "parse.invalid-combination" + + +def test_session_save_event_is_safe_visible_and_health_checked( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + canary = "do-not-store-save-path" + handle = _history.begin(json_mode=False, interactive=True) + _history.note_session_save( + status="failed", + target=str(tmp_path / f"{canary}.yaml"), + boundary="AgentBoundary", + requested=True, + credentials_active=True, + ) + _history.finish(handle, _configs.Result("PARTIAL_SUCCESS", "ignored", 1)) + + record = _history.list_records()[0] + event = _history.session_save_event(record) + assert event is not None + assert event["kind"] == "session-save.failed" + assert event["data"] == { + "eventSchemaVersion": 1, + "redactionVersion": 2, + "status": "failed", + "target": None, + "boundary": "AgentBoundary", + "requested": True, + "credentialsActive": True, + } + assert canary not in _history.export_records([record], format_name="json") + assert _history.status()["sessionSaves"] == 1 + assert _history.check()["ok"] is True + rendered = _cli._history_show_text(record) + assert "Session save: failed" in rendered + rendered.encode("cp1252") + + +def test_schema_two_indexes_parse_events( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=False) + _history.finish(handle, _configs.Result("OK", "")) + with closing(sqlite3.connect(_history.database_path())) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 2 + indexes = {row[1] for row in connection.execute("PRAGMA index_list(events)")} + assert "event_invocation_kind" in indexes + + +def test_schema_one_is_migrated_stepwise_to_schema_two( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _isolated(monkeypatch, tmp_path) + handle = _history.begin(json_mode=False, interactive=False) + _history.finish(handle, _configs.Result("OK", "")) + path = _history.database_path() + with closing(sqlite3.connect(path)) as connection: + connection.execute("DROP INDEX event_invocation_kind") + connection.execute("PRAGMA user_version = 1") + connection.commit() + _history._initialized_databases.discard(path) + + assert _history.status()["integrity"] == "ok" + with closing(sqlite3.connect(path)) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 2 + indexes = {row[1] for row in connection.execute("PRAGMA index_list(events)")} + assert "event_invocation_kind" in indexes diff --git a/hacksaws/tests/test_session_save.py b/hacksaws/tests/test_session_save.py new file mode 100644 index 0000000..60a7631 --- /dev/null +++ b/hacksaws/tests/test_session_save.py @@ -0,0 +1,1004 @@ +"""Post-login reusable configuration save and recovery tests.""" + +from __future__ import annotations + +import argparse +import dataclasses +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from typing import Any +from typing import cast +from unittest.mock import patch + +import pytest + +from hacksaws import _account_discovery +from hacksaws import _configs +from hacksaws import _session_save +from hacksaws import _sessions +from hacksaws import _state + +ACCOUNT = "123456789012" +ROLE_ACCOUNT = "210987654321" +ROLE = f"arn:aws:iam::{ROLE_ACCOUNT}:role/AgentSession" +POLICY = f"arn:aws:iam::{ROLE_ACCOUNT}:policy/hacksaws/ReadOnly" + + +def _args(**overrides: object) -> argparse.Namespace: + values: dict[str, object] = { + "save": False, + "save_name": "debug", + "save_source_account": "source", + "save_role_account": "role", + "save_boundary": None, + "save_external_id": False, + "store_policy_as": None, + "target": None, + "boundary": None, + "role": ROLE, + "policy": POLICY, + "external_id": "secret-external-id", + "duration": "1h", + "htl": None, + "mtl": None, + "stl": None, + "json": False, + "description": None, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def _discovery( + key: str, account_id: str, *, existing: bool = False +) -> _account_discovery.AccountDiscovery: + identity = _account_discovery.AccountIdentity( + partition="aws", + account_id=account_id, + arn=f"arn:aws:iam::{account_id}:root", + verified=True, + ) + record = ( + { + "id": account_id, + "partition": "aws", + "display_name": key, + "display_source": "user", + } + if existing + else None + ) + return _account_discovery.AccountDiscovery( + identity=identity, + source_identity=identity, + key=key, + key_source="existing" if existing else "user", + display_name=key, + display_source="user", + existing=existing, + _existing_record=record, + ) + + +def _plan(tmp_path: Path, **overrides: object) -> _session_save.SavePlan: + source = tmp_path / ".aws-source" + destination = tmp_path / ".aws-agent" + return _session_save.prepare( + _args(**overrides), + source_directory=source, + source_profile="admin", + destination_directory=destination, + destination_profile="debug", + region="us-west-2", + ) + + +def _accounts() -> _session_save.SaveAccounts: + return _session_save.SaveAccounts( + source=_discovery("source", ACCOUNT), + role=_discovery("role", ROLE_ACCOUNT), + ) + + +def _persist( + plan: _session_save.SavePlan, + accounts: _session_save.SaveAccounts, + session: dict[str, Any], +) -> _session_save.SaveOutcome: + return _session_save.persist( + plan, + accounts, + session, + begin=lambda paths: _sessions._begin(paths, kind="session-save"), + commit=_sessions._commit, + rollback=_sessions._rollback, + ) + + +def test_persist_builds_canonical_resources_and_is_idempotent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan(tmp_path) + session = { + "role": ROLE, + "policy_origin": "remote-customer", + "policy_arn": POLICY, + } + + first = _persist(plan, _accounts(), session) + second = _persist(plan, _accounts(), session) + + assert first.changed is True + assert second.changed is False + config = _state.load_config() + assert config["boundaries"]["debug"] == { + "role_arn": ROLE, + "account": "role", + "policy": POLICY, + "duration": 3600, + "verified": True, + } + assert config["targets"]["debug"] == { + "source_account": "source", + "source_profile": "admin", + "source_directory": str((tmp_path / ".aws-source").absolute()), + "destination_profile": "debug", + "destination_directory": str((tmp_path / ".aws-agent").absolute()), + "boundary": "debug", + "region": "us-west-2", + } + + +def test_external_id_requires_explicit_persistence_consent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + without = _plan(tmp_path) + session = {"role": ROLE} + _persist(without, _accounts(), session) + assert "external_id" not in _state.load_config()["boundaries"]["debug"] + + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "consented-state")) + with_consent = _plan(tmp_path, save_external_id=True) + _persist(with_consent, _accounts(), session) + assert ( + _state.load_config()["boundaries"]["debug"]["external_id"] + == "secret-external-id" + ) + + +def test_local_policy_is_promoted_transactionally_and_preserves_yaml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + policy = tmp_path / "debug.yaml" + policy.write_text( + "# keep this comment\nVersion: '2012-10-17'\nStatement: []\n", + encoding="utf-8", + ) + plan = _plan(tmp_path, policy=str(policy), store_policy_as="debug-policy") + + outcome = _persist( + plan, + _accounts(), + {"role": ROLE, "policy_origin": "local"}, + ) + + assert outcome.policy == "debug-policy" + stored = tmp_path / "state" / "stored_session_policies" / "debug-policy.yaml" + assert stored.read_text(encoding="utf-8").startswith("# keep this comment") + config = _state.load_config() + assert config["boundaries"]["debug"]["policy"] == "debug-policy" + + +def test_divergent_name_fails_without_replacing_existing_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan(tmp_path) + _persist(plan, _accounts(), {"role": ROLE}) + before = (tmp_path / "state" / "config.json").read_bytes() + divergent = dataclasses.replace(plan, source_profile="different") + + with pytest.raises(_configs.OperationalError, match="different settings"): + _persist(divergent, _accounts(), {"role": ROLE}) + + assert (tmp_path / "state" / "config.json").read_bytes() == before + + +def test_post_commit_save_failure_returns_partial_success_and_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan(tmp_path) + destination = plan.destination_directory + _state.save_sessions( + { + f"{destination}::debug": { + "destination": str(destination), + "profile": "debug", + "auth_method": "mfa", + } + } + ) + with patch( + "hacksaws._sessions._session_save.persist", + side_effect=_configs.OperationalError("config conflict"), + ): + result = _sessions._finish_session_save( + _configs.Result("MFA_LOGIN", "Logged in."), + plan=plan, + accounts=_accounts(), + discovery_error=None, + destination=destination, + profile="debug", + ) + + assert result.code == "PARTIAL_SUCCESS" + assert result.exit_code == 1 + result_data = cast("dict[str, Any]", result.data) + assert result_data["credentialsActive"] is True + assert "--from-session debug" in result_data["save"]["retryCommand"] + assert _state.load_sessions()[f"{destination}::debug"]["auth_method"] == "mfa" + + +def test_target_from_session_recovers_and_repeats_as_noop( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state = tmp_path / "state" + monkeypatch.setenv("HACKSAWS_HOME", str(state)) + destination = tmp_path / ".aws-agent" + source = tmp_path / ".aws-source" + expires = (datetime.now(UTC) + timedelta(hours=1)).isoformat() + _state.save_sessions( + { + f"{destination.absolute()}::debug": { + "destination": str(destination.absolute()), + "profile": "debug", + "source_destination": str(source.absolute()), + "source_profile": "admin", + "source_account": ACCOUNT, + "source_partition": "aws", + "target_account": ROLE_ACCOUNT, + "target_partition": "aws", + "role": ROLE, + "policy_origin": "remote-customer", + "policy_arn": POLICY, + "region": "us-west-2", + "expires_at": expires, + "auth_method": "mfa", + } + } + ) + args = _args( + resource_name="debug", + from_session="debug", + directory=str(destination), + location=None, + save_name=None, + save=True, + save_source_account="source", + save_role_account="role", + role=None, + policy=None, + external_id=None, + duration=None, + ) + + first = _sessions.save_target_from_session(args) + second = _sessions.save_target_from_session(args) + + first_data = cast("dict[str, Any]", first.data) + second_data = cast("dict[str, Any]", second.data) + assert first_data["changed"] is True + assert second_data["changed"] is False + assert _state.load_config()["targets"]["debug"]["boundary"] == "debug" + + +def test_existing_account_without_region_is_reused_without_mutation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + config = _state.default_config() + config["accounts"]["legacy"] = { + "id": ACCOUNT, + "partition": "aws", + "display_name": "legacy", + "display_source": "user", + } + _state.save_config(config) + plan = _plan( + tmp_path, + save_name=None, + save=False, + save_source_account=None, + save_role_account=None, + role=None, + policy=None, + duration=None, + ) + accounts = _session_save.SaveAccounts( + source=_discovery("legacy", ACCOUNT, existing=True), + role=None, + ) + + result = _persist(plan, accounts, {"role": None}) + + assert result.changed is False + assert "region" not in _state.load_config()["accounts"]["legacy"] + + +def test_locations_are_saved_as_portable_location_endpoints( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The normal and named AWS folders should not become machine-specific paths.""" + monkeypatch.setattr("hacksaws._session_save.Path.home", lambda: tmp_path) + + assert _session_save._logical_location(tmp_path / ".aws") == "default" + assert _session_save._logical_location(tmp_path / ".aws-horizon") == "horizon" + assert _session_save._endpoint( + tmp_path / ".aws-horizon", "admin", prefix="source" + ) == {"source_profile": "admin", "source_location": "horizon"} + + +def test_prepare_rejects_invalid_save_combinations_before_login( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + + with pytest.raises(_configs.OperationalError, match="require --save"): + _plan(tmp_path, save=False, save_name=None, save_source_account="source") + with pytest.raises(_configs.OperationalError, match="requires a role"): + _plan(tmp_path, role=None, save_boundary="debug") + with pytest.raises(_configs.OperationalError, match="requires a role"): + _plan(tmp_path, role=None, save_role_account="role") + with pytest.raises(_configs.OperationalError, match="requires a role"): + _plan(tmp_path, role=None, save_role_account=None, save_external_id=True) + with pytest.raises(_configs.OperationalError, match="only valid"): + _plan(tmp_path, store_policy_as="stored") + with pytest.raises(_configs.OperationalError, match="requires a session policy"): + _plan(tmp_path, policy=None, store_policy_as="stored") + + +def test_prepare_rejects_conflicting_target_before_login( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + config = _state.default_config() + config["accounts"]["source"] = { + "id": ACCOUNT, + "partition": "aws", + "display_name": "source", + "display_source": "user", + } + config["targets"]["debug"] = { + "source_account": "source", + "source_profile": "different", + "source_directory": str((tmp_path / ".aws-source").absolute()), + "region": "us-west-2", + } + _state.save_config(config) + + with pytest.raises(_configs.OperationalError, match="login was not attempted"): + _plan(tmp_path) + + +def test_discover_accounts_registers_source_before_resolving_role( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + source = _discovery("source", ACCOUNT) + role = _discovery("role", ROLE_ACCOUNT) + calls: list[tuple[dict[str, Any], str | None]] = [] + + def discover( + _session: object, + config: dict[str, Any], + *, + explicit_name: str | None, + role_arn: str | None = None, + ) -> _account_discovery.AccountDiscovery: + calls.append((config, role_arn)) + return role if role_arn else source + + with patch("hacksaws._session_save._account_discovery.discover_account", discover): + found = _session_save.discover_accounts( + _plan(tmp_path), + cast("_account_discovery.IntermediateSession", object()), + role_arn=ROLE, + ) + + assert found == _session_save.SaveAccounts(source=source, role=role) + assert calls[1][0]["accounts"]["source"]["region"] == "us-west-2" + assert calls[1][1] == ROLE + + +def test_recovery_account_discovery_validates_lineage_and_name_collisions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan(tmp_path) + + with pytest.raises(_configs.OperationalError, match="valid source account"): + _session_save.accounts_from_session(plan, {"source_account": "broken"}) + + config = _state.default_config() + config["accounts"]["source"] = { + "id": ROLE_ACCOUNT, + "partition": "aws", + "display_name": "source", + "display_source": "user", + } + _state.save_config(config) + with pytest.raises(_configs.OperationalError, match="different AWS account"): + _session_save.accounts_from_identity( + plan, + source_account=ACCOUNT, + source_partition="aws", + role_arn=None, + ) + + +def test_prepare_and_recovery_use_configured_resources_and_account_id_defaults( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + config = _state.default_config() + config["accounts"]["role"] = { + "id": ROLE_ACCOUNT, + "partition": "aws", + "display_name": "role", + "display_source": "user", + } + config["boundaries"]["agent"] = { + "role_arn": ROLE, + "account": "role", + "duration": 1800, + } + config["targets"]["saved"] = { + "source_account": "role", + "source_profile": "admin", + "source_directory": str((tmp_path / ".aws-source").absolute()), + "boundary": "agent", + "region": "us-west-2", + } + _state.save_config(config) + + prepared = _plan( + tmp_path, + target="+saved", + boundary=None, + role=None, + policy=None, + external_id=None, + duration=None, + save_source_account=None, + save_role_account=None, + ) + assert prepared.role == ROLE + assert prepared.duration == 1800 + assert prepared.boundary_name == "debug" + + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "fresh-state")) + recovered = _session_save.accounts_from_identity( + _plan( + tmp_path, + save_source_account=None, + save_role_account=None, + ), + source_account=ACCOUNT, + source_partition="aws", + role_arn=ROLE, + ) + assert recovered.source.key == f"account-{ACCOUNT}" + assert recovered.role is not None + assert recovered.role.key == f"account-{ROLE_ACCOUNT}" + + +def test_prompt_and_policy_canonicalization_handle_cancel_and_incomplete_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + with ( + patch("builtins.input", side_effect=EOFError), + pytest.raises(_session_save.SaveCancelled), + ): + _session_save._prompt_name("Save", "debug") + with ( + patch("builtins.input", return_value="q"), + pytest.raises(_session_save.SaveCancelled), + ): + _session_save._prompt_name("Save", "debug") + + assert _session_save._policy_output( + _plan(tmp_path, policy=None), target_name="debug" + ) == ( + None, + None, + None, + ) + assert _session_save._policy_output(_plan(tmp_path), target_name="debug") == ( + POLICY, + None, + None, + ) + with pytest.raises(_configs.OperationalError, match="source path was not retained"): + _session_save._canonical_policy( + _plan(tmp_path, policy=None), + {"policy_origin": "local"}, + target_name="debug", + ) + with pytest.raises(_configs.OperationalError, match="canonical managed-policy ARN"): + _session_save._canonical_policy( + _plan(tmp_path), {"policy_origin": "aws-managed"}, target_name="debug" + ) + assert _session_save._canonical_policy( + _plan(tmp_path), + {"policy_origin": "stored", "policy_reference": "saved"}, + target_name="debug", + ) == ("saved", None, None) + + +def test_local_policy_prompt_and_noninteractive_safeguard( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + policy = tmp_path / "read-only.json" + policy.write_text('{"Version":"2012-10-17","Statement":[]}', encoding="utf-8") + plan = _plan(tmp_path, policy=str(policy), store_policy_as=None) + + with pytest.raises(_configs.OperationalError, match="noninteractively"): + _session_save._policy_output(plan, target_name="debug") + interactive = dataclasses.replace(plan, interactive=True) + with patch("builtins.input", return_value="prompted-policy"): + name, path, encoded = _session_save._policy_output( + interactive, target_name="debug" + ) + + assert name == "prompted-policy" + assert path is not None + assert path.name == "prompted-policy.yaml" + assert encoded == b"Version: '2012-10-17'\nStatement: []\n" + + +def test_persist_handles_noninteractive_name_policy_conflict_and_cas( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + bare = _plan(tmp_path, save=True, save_name=None) + with pytest.raises(_configs.OperationalError, match="noninteractive save"): + _persist(bare, _accounts(), {"role": ROLE}) + + policy = tmp_path / "read.yaml" + policy.write_text("Version: '2012-10-17'\nStatement: []\n", encoding="utf-8") + local = _plan(tmp_path, policy=str(policy), store_policy_as="read") + stored = _state.root() / "stored_session_policies" / "read.yaml" + stored.parent.mkdir(parents=True, exist_ok=True) + stored.write_text("Version: '2012-10-17'\nStatement: [{}]\n", encoding="utf-8") + with pytest.raises(_configs.OperationalError, match="different content"): + _persist(local, _accounts(), {"role": ROLE, "policy_origin": "local"}) + + plan = _plan(tmp_path) + commits: list[str] = [] + + def conflict_begin(_paths: list[Path]) -> dict[str, Any]: + config = _state.default_config() + config["accounts"]["other"] = { + "id": "999999999999", + "partition": "aws", + "display_name": "other", + "display_source": "user", + } + _state.save_config(config) + return {} + + with pytest.raises(_session_save.SavePlanChanged, match="changed while"): + _session_save.persist( + plan, + _accounts(), + {"role": ROLE}, + begin=conflict_begin, + commit=lambda: commits.append("commit"), + rollback=lambda _journal: pytest.fail("no write should be rolled back"), + ) + assert commits == ["commit"] + assert "other" in _state.load_config()["accounts"] + + +def test_persist_prompts_for_bare_interactive_save_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = dataclasses.replace( + _plan(tmp_path, save=True, save_name=None), interactive=True + ) + with patch("builtins.input", return_value="prompted"): + outcome = _persist(plan, _accounts(), {"role": ROLE}) + + assert outcome.target == "prompted" + assert "prompted" in _state.load_config()["targets"] + + +def test_persist_rolls_back_after_a_started_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + policy = tmp_path / "read.yaml" + policy.write_text("Version: '2012-10-17'\nStatement: []\n", encoding="utf-8") + plan = _plan(tmp_path, policy=str(policy), store_policy_as="read") + rollback: list[dict[str, Any]] = [] + with ( + patch( + "hacksaws._session_save._state.save_config", + side_effect=OSError("disk full"), + ), + pytest.raises(OSError, match="disk full"), + ): + _session_save.persist( + plan, + _accounts(), + {"role": ROLE, "policy_origin": "local"}, + begin=lambda _paths: {"journal": "test"}, + commit=lambda: pytest.fail("commit should not run"), + rollback=rollback.append, + ) + assert rollback == [{"journal": "test"}] + + +def test_recovery_plan_outcome_and_retry_command_are_credential_free( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + session = { + "destination": str(tmp_path / ".aws-agent"), + "source_destination": str(tmp_path / ".aws-source"), + "source_profile": "admin", + "profile": "debug", + "role": ROLE, + "policy_reference": "stored-policy", + "region": "us-west-2", + } + plan = _session_save.recovery_plan( + _args(policy=None, external_id="recovery-external-id"), session, name="saved" + ) + assert plan.name == "saved" + assert plan.policy == "stored-policy" + assert plan.save_external_id is True + assert "recovery-external-id" not in _session_save.retry_command(plan) + assert _session_save.outcome_data( + _session_save.SaveOutcome( + target="saved", + boundary="saved", + source_account="source", + role_account="role", + changed=True, + policy="stored-policy", + ) + ) == { + "accountRegistration": {"created": 0, "reused": 0, "refreshed": 0}, + "bundleRequested": True, + "bundleSaved": True, + "bundleChanged": True, + "changed": True, + "target": "saved", + "boundary": "saved", + "sourceAccount": "source", + "roleAccount": "role", + "policy": "stored-policy", + } + + +def test_discovery_failure_reports_the_fallback_failure_without_credential_rollback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan(tmp_path) + discovery_failure = _configs.OperationalError("optional provider unavailable") + fallback_failure = _configs.OperationalError("account name collision") + with ( + patch( + "hacksaws._sessions._session_save.discover_accounts", + side_effect=discovery_failure, + ), + patch( + "hacksaws._sessions._session_save.accounts_from_identity", + side_effect=fallback_failure, + ), + ): + accounts, error = _sessions._discover_save_accounts( + plan, + object(), + role=ROLE, + source_account=ACCOUNT, + source_partition="aws", + ) + + assert accounts is None + assert error is fallback_failure + + +def test_finish_session_save_preserves_active_credentials_on_cancel_or_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan(tmp_path) + original = _configs.Result("WEB_LOGIN", "Logged in.", data={"source": "web"}) + destination = tmp_path / ".aws-agent" + + for error in ( + _session_save.SaveCancelled("Configuration save cancelled."), + _configs.OperationalError("policy conflict"), + ): + with ( + patch( + "hacksaws._sessions._persist_committed_session_save", side_effect=error + ), + patch("hacksaws._sessions._history.note_session_save") as history, + ): + result = _sessions._finish_session_save( + original, + plan=plan, + accounts=_accounts(), + discovery_error=None, + destination=destination, + profile="debug", + ) + + assert result.code == "PARTIAL_SUCCESS" + assert isinstance(result.data, dict) + assert result.data["credentialsActive"] is True + assert "Credentials remain active" in result.message + assert history.call_args.kwargs["status"] in {"cancelled", "failed"} + + +def test_finish_session_save_reports_saved_and_noop_suffixes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan(tmp_path) + original = _configs.Result("MFA_LOGIN", "Logged in.", data={"tier": "mfa"}) + destination = tmp_path / ".aws-agent" + + for changed, expected in ( + (True, "Saved target 'debug'."), + (False, "already current"), + ): + outcome = _session_save.SaveOutcome( + target="debug", + boundary="debug", + source_account="source", + role_account="role", + changed=changed, + ) + with ( + patch( + "hacksaws._sessions._persist_committed_session_save", + return_value=outcome, + ), + patch("hacksaws._sessions._history.note_session_save") as history, + ): + result = _sessions._finish_session_save( + original, + plan=plan, + accounts=_accounts(), + discovery_error=None, + destination=destination, + profile="debug", + ) + + assert expected in result.message + assert isinstance(result.data, dict) + saved = result.data["save"] + assert isinstance(saved, dict) + assert saved["changed"] is changed + assert history.call_args.kwargs["status"] == ("saved" if changed else "noop") + + +def test_finish_without_save_reports_only_account_registration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan( + tmp_path, + save=False, + save_name=None, + save_source_account=None, + save_role_account=None, + role=None, + policy=None, + duration=None, + ) + original = _configs.Result("WEB_LOGIN", "Logged in.") + outcome = _session_save.SaveOutcome( + target=None, + boundary=None, + source_account="source", + role_account=None, + changed=True, + bundle_requested=False, + bundle_changed=False, + accounts_created=1, + ) + with ( + patch( + "hacksaws._sessions._persist_committed_session_save", + return_value=outcome, + ), + patch("hacksaws._sessions._history.note_session_save") as save_history, + patch( + "hacksaws._sessions._history.note_account_registration" + ) as registration_history, + ): + result = _sessions._finish_session_save( + original, + plan=plan, + accounts=_accounts(), + discovery_error=None, + destination=tmp_path / ".aws-agent", + profile="debug", + ) + + assert result.message == "Logged in." + assert result.data == { + "accountRegistration": {"created": 1, "reused": 0, "refreshed": 0}, + "bundleRequested": False, + "bundleSaved": False, + } + save_history.assert_not_called() + registration_history.assert_called_once_with( + status="completed", created=1, reused=0, refreshed=0 + ) + + +def test_committed_save_requires_discovery_accounts_and_live_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + plan = _plan(tmp_path) + destination = tmp_path / ".aws-agent" + with pytest.raises(_configs.OperationalError, match="provider failure"): + _sessions._persist_committed_session_save( + plan, + _accounts(), + _configs.OperationalError("provider failure"), + destination, + "debug", + ) + with pytest.raises(_configs.OperationalError, match="did not produce"): + _sessions._persist_committed_session_save( + plan, None, None, destination, "debug" + ) + with pytest.raises(_configs.OperationalError, match="could not be found"): + _sessions._persist_committed_session_save( + plan, _accounts(), None, destination, "debug" + ) + + +def test_recovery_selector_rejects_missing_ambiguous_and_unusable_sessions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + args = _args( + resource_name="saved", from_session="debug", directory=None, location=None + ) + with pytest.raises(_configs.OperationalError, match="No Hacksaws-managed session"): + _sessions.save_target_from_session(args) + + first = tmp_path / ".aws-first" + second = tmp_path / ".aws-second" + _state.save_sessions( + { + f"{first.absolute()}::debug": { + "destination": str(first.absolute()), + "profile": "debug", + }, + f"{second.absolute()}::debug": { + "destination": str(second.absolute()), + "profile": "debug", + }, + } + ) + with pytest.raises(_configs.OperationalError, match="multiple AWS folders"): + _sessions.save_target_from_session(args) + + selected = _args( + resource_name="saved", from_session="debug", directory=str(first), location=None + ) + _state.save_sessions( + { + f"{first.absolute()}::debug": { + "destination": str(first.absolute()), + "profile": "debug", + "auth_method": "logout-residue", + } + } + ) + with pytest.raises( + _configs.OperationalError, match="Managed source session is logout-residue" + ): + _sessions.save_target_from_session(selected) + + +def test_recovery_requires_external_id_and_records_failure_saved_and_noop( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path / "state")) + destination = tmp_path / ".aws-agent" + session = { + "destination": str(destination), + "profile": "debug", + "auth_method": "mfa", + } + _state.save_sessions({f"{destination.absolute()}::debug": session}) + external = _args( + resource_name="saved", + from_session="debug", + directory=str(destination), + location=None, + save_external_id=True, + external_id=None, + ) + with pytest.raises(_configs.OperationalError, match="recovery-only"): + _sessions.save_target_from_session(external) + + args = _args( + resource_name="saved", + from_session="debug", + directory=str(destination), + location=None, + ) + plan = _plan(tmp_path) + outcomes = [ + _session_save.SaveOutcome( + target="saved", + boundary="saved", + source_account="source", + role_account="role", + changed=True, + ), + _session_save.SaveOutcome( + target="saved", + boundary="saved", + source_account="source", + role_account="role", + changed=False, + ), + ] + with ( + patch("hacksaws._sessions._session_save.recovery_plan", return_value=plan), + patch( + "hacksaws._sessions._session_save.accounts_from_session", + return_value=_accounts(), + ), + patch("hacksaws._sessions._session_save.persist", side_effect=outcomes), + patch("hacksaws._sessions._history.note_session_save") as history, + ): + saved = _sessions.save_target_from_session(args) + noop = _sessions.save_target_from_session(args) + + assert saved.kind == "success" + assert noop.kind == "info" + assert "already matches" in noop.message + assert [call.kwargs["status"] for call in history.call_args_list] == [ + "saved", + "noop", + ] + + with ( + patch("hacksaws._sessions._session_save.recovery_plan", return_value=plan), + patch( + "hacksaws._sessions._session_save.accounts_from_session", + return_value=_accounts(), + ), + patch( + "hacksaws._sessions._session_save.persist", + side_effect=_configs.OperationalError("different settings"), + ), + patch("hacksaws._sessions._history.note_session_save") as history, + pytest.raises(_configs.OperationalError, match="different settings"), + ): + _sessions.save_target_from_session(args) + assert history.call_args.kwargs["status"] == "failed" diff --git a/hacksaws/tests/test_v04.py b/hacksaws/tests/test_v04.py index ff1948d..e8c8aad 100644 --- a/hacksaws/tests/test_v04.py +++ b/hacksaws/tests/test_v04.py @@ -160,7 +160,11 @@ def test_unbounded_target_role_operands_fail_before_browser_auth( _minimal_target(tmp_path) with patch("hacksaws._sessions._aws_login") as aws_login: result = _cli.console_main(["web", "in", "+Prod", flag, value]) - assert result.exit_code == 1 + if flag == "--duration": + assert result.exit_code == 1 + else: + assert result.code == "ARGUMENT_ERROR" + assert result.exit_code == _configs.EXIT_USAGE aws_login.assert_not_called() @@ -182,7 +186,8 @@ def test_bounded_target_rejects_security_overrides( monkeypatch.setenv("HACKSAWS_HOME", str(tmp_path)) _minimal_target(tmp_path, boundary=True) result = _cli.console_main(["web", "in", "+Prod", *arguments]) - assert result.exit_code == 1 + assert result.code == "ARGUMENT_ERROR" + assert result.exit_code == _configs.EXIT_USAGE def test_unbounded_target_may_add_named_boundary( @@ -983,7 +988,8 @@ def test_ecr_region_partition_and_unknown_escape_are_strict( def test_direct_policy_requires_role(capsys: pytest.CaptureFixture[str]) -> None: result = _cli.console_main(["mfa", "in", "dev", "123456", "--policy", "x"]) - assert result.exit_code == 1 + assert result.code == "ARGUMENT_ERROR" + assert result.exit_code == _configs.EXIT_USAGE assert "requires --role" in capsys.readouterr().err diff --git a/pyproject.toml b/pyproject.toml index 8d927a5..658ab8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,9 @@ ignore = [ "C901", "E501", "FBT001", "PLR0911", "PLR0912", "PLR0915", "SLF001", "TRY003", "TRY203", ] +"hacksaws/_account_discovery.py" = [ + "ANN401", "PLR0911", "TRY003", +] "hacksaws/_duration.py" = ["TRY003"] "hacksaws/_ecr.py" = ["ANN401"] "hacksaws/_policies.py" = [